Magento Module Configuration Diff
A Python script to easily generate a diff of module state between different Magento Configuration files.
Especially useful in CI pipelines for large config changes (Magento Updates) to attach a PR comment to aid in code review and ensure that changes to module configuration are intentional and expected.
Usage
Usage is simple, you call the script with up to 2 config file paths or git refs, and it will output the differences in module configuration between them. If no arguments are provided, it defaults to comparing the current local app/etc/config.php with the last committed version in git.
# Compare current config file with a specific git branch (production)
mage-module-compare production
# Comparing current config file with a specific git branch (staging) using a non standard path
mage-module-compare staging:src/app/etc/config.php
# Compare two specific git branches
mage-module-compare production staging
# Compare two specific git hashes
mage-module-compare 123abc 456def
# Compare two specific config files
mage-module-compare ~/Projects/project1/app/etc/config.php ~/Projects/project2/app/etc/config.php
# Compare piped config content with local config file
git show HEAD~6:app/etc/config.php | mage-module-compare
Installation
- Ensure you have Python 3, PHP and Git installed on your system.
- Save the script to a file, e.g.
~/.local/bin/mage-module-compare. - Make the script executable:
chmod +x ~/.local/bin/mage-module-compare. - Optionally, ensure the script is in your PATH for easy access.
- Run the script with the desired config file paths or git refs to compare Magento module configurations.
Script
#!/usr/bin/env python3
# This script compares module config in magento config.php files, parsing module
# configuration and outputting differences in various formats.
#
# Usage:
# python3 mage-module-compare.py <path_to_config1> <path_to_config2> # Compare two files
# python3 mage-module-compare.py <git_ref1:config_path> <git_ref2:config_path> # Compare two refs
# python3 mage-module-compare.py <path_to_config> # Compare with app/etc/config.php
# python3 mage-module-compare.py # Compare HEAD:app/etc/config.php with local app/etc/config.php
# cat config.php | python3 mage-module-compare.py # Compare piped input with app/etc/config.php
# python3 mage-module-compare.py HEAD:app/etc/config.php production:app/etc/config.php
#
# Output format:
# --format list|markdown|csv|json # default: list
import argparse
import csv
import json
import os
import subprocess
import sys
DEFAULT_CONFIG_PATH = "app/etc/config.php"
def extract_modules_from_content(config_content):
"""Extract modules from PHP config content by reading it directly."""
command = "php -r '$config = eval(\"?>\".file_get_contents(\"php://stdin\")); echo json_encode($config[\"modules\"] ?? []);'"
result = subprocess.run(command, shell=True, capture_output=True, text=True, input=config_content)
if result.returncode != 0:
print(f"Error extracting modules from config: {result.stderr}", file=sys.stderr)
sys.exit(1)
if result.stdout.strip() == "" or result.stdout == "null":
print("No module configuration found in config", file=sys.stderr)
sys.exit(1)
try:
return json.loads(result.stdout)
except json.JSONDecodeError as error:
print(f"Error parsing JSON output from PHP command: {error}", file=sys.stderr)
print(f"Raw output was: {result.stdout}", file=sys.stderr)
sys.exit(1)
def read_file_content(file_path):
"""Read content from local disk, or from git using ref:path (or ref + default path)."""
if os.path.exists(file_path):
try:
with open(file_path, "r") as file_handle:
return file_handle.read()
except Exception as error:
print(f"Error reading file {file_path}: {error}", file=sys.stderr)
sys.exit(1)
git_source = file_path if ":" in file_path else f"{file_path}:{DEFAULT_CONFIG_PATH}"
result = subprocess.run(
["git", "show", git_source],
capture_output=True,
text=True,
)
if result.returncode != 0:
print(
f"Error reading source '{file_path}' (resolved as '{git_source}'): {result.stderr.strip()}",
file=sys.stderr,
)
sys.exit(1)
return result.stdout
def extract_modules(config_path):
"""Extract modules from a config file path."""
content = read_file_content(config_path)
return extract_modules_from_content(content)
def get_differences(config1, config2):
modules1 = set(config1.keys())
modules2 = set(config2.keys())
return {
"added": sorted(modules2 - modules1),
"removed": sorted(modules1 - modules2),
"enabled": sorted(module for module in modules1 & modules2 if config1[module] == 0 and config2[module] == 1),
"disabled": sorted(module for module in modules1 & modules2 if config1[module] == 1 and config2[module] == 0),
}
def output_list(differences):
print("Added Modules:")
for module_name in differences["added"]:
print(f" {module_name}")
print("\nRemoved Modules:")
for module_name in differences["removed"]:
print(f" {module_name}")
print("\nEnabled Modules:")
for module_name in differences["enabled"]:
print(f" {module_name}")
print("\nDisabled Modules:")
for module_name in differences["disabled"]:
print(f" {module_name}")
def output_markdown(differences):
sections = [
("Added Modules", differences["added"]),
("Removed Modules", differences["removed"]),
("Enabled Modules", differences["enabled"]),
("Disabled Modules", differences["disabled"]),
]
for index, (title, modules) in enumerate(sections):
print(f"## {title}")
if modules:
for module_name in modules:
print(f"- {module_name}")
else:
print("- None")
if index < len(sections) - 1:
print()
def output_csv(differences):
writer = csv.writer(sys.stdout)
writer.writerow(["change_type", "module"])
for change_type in ["added", "removed", "enabled", "disabled"]:
for module_name in differences[change_type]:
writer.writerow([change_type, module_name])
def output_json(differences):
print(json.dumps(differences, indent=2))
def output_differences(differences, output_format):
if output_format == "list":
output_list(differences)
elif output_format == "markdown":
output_markdown(differences)
elif output_format == "csv":
output_csv(differences)
elif output_format == "json":
output_json(differences)
else:
print(f"Error: Unsupported output format '{output_format}'", file=sys.stderr)
sys.exit(1)
def parse_args():
parser = argparse.ArgumentParser(
description="Compare Magento module configuration differences.",
)
parser.add_argument(
"configs",
nargs="*",
help="0, 1, or 2 config sources (local file paths, git ref paths like HEAD:app/etc/config.php, or bare refs like HEAD)",
)
parser.add_argument(
"-f",
"--format",
default="list",
choices=["list", "markdown", "csv", "json"],
help="Output format (default: list)",
)
return parser.parse_args()
if __name__ == "__main__":
args = parse_args()
is_piped = not sys.stdin.isatty()
num_args = len(args.configs)
output_format = args.format
if num_args == 2:
config1_path = args.configs[0]
config2_path = args.configs[1]
if output_format == "list":
print(f"Comparing {config1_path} with {config2_path}\n")
config1 = extract_modules(config1_path)
config2 = extract_modules(config2_path)
elif num_args == 1:
config1_path = args.configs[0]
config2_path = DEFAULT_CONFIG_PATH
if not os.path.exists(config2_path):
print(f"Error: Default config file {config2_path} not found", file=sys.stderr)
sys.exit(1)
if output_format == "list":
print(f"Comparing {config1_path} with {config2_path}\n")
config1 = extract_modules(config1_path)
config2 = extract_modules(config2_path)
elif num_args == 0 and is_piped:
config2_path = DEFAULT_CONFIG_PATH
if not os.path.exists(config2_path):
print(f"Error: Default config file {config2_path} not found", file=sys.stderr)
sys.exit(1)
if output_format == "list":
print(f"Comparing piped input with {config2_path}\n")
piped_content = sys.stdin.read()
config1 = extract_modules_from_content(piped_content)
config2 = extract_modules(config2_path)
elif num_args == 0:
local_config_path = DEFAULT_CONFIG_PATH
committed_config_path = f"HEAD:{DEFAULT_CONFIG_PATH}"
if not os.path.exists(local_config_path):
print(f"Error: Local config file {local_config_path} not found", file=sys.stderr)
sys.exit(1)
if output_format == "list":
print(f"Comparing {committed_config_path} with local {local_config_path}\n")
config1 = extract_modules(committed_config_path)
config2 = extract_modules(local_config_path)
else:
print("Error: expected 0, 1, or 2 config arguments", file=sys.stderr)
print("Run with --help for usage.", file=sys.stderr)
sys.exit(2)
differences = get_differences(config1, config2)
output_differences(differences, output_format)