diffora 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- configdiff/__init__.py +5 -0
- configdiff/cli.py +225 -0
- configdiff/core.py +598 -0
- configdiff/patch.py +124 -0
- diffora-0.1.0.dist-info/METADATA +292 -0
- diffora-0.1.0.dist-info/RECORD +10 -0
- diffora-0.1.0.dist-info/WHEEL +5 -0
- diffora-0.1.0.dist-info/entry_points.txt +3 -0
- diffora-0.1.0.dist-info/licenses/LICENSE +21 -0
- diffora-0.1.0.dist-info/top_level.txt +1 -0
configdiff/__init__.py
ADDED
configdiff/cli.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import difflib
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
from .core import ConfigDiffError, Difference, _MISSING, compare_directories, compare_files, load_config, safe_value, serialize_differences
|
|
11
|
+
from .patch import apply_json_patch, build_json_patch
|
|
12
|
+
|
|
13
|
+
_RED = "\033[31m"
|
|
14
|
+
_GREEN = "\033[32m"
|
|
15
|
+
_YELLOW = "\033[33m"
|
|
16
|
+
_RESET = "\033[0m"
|
|
17
|
+
_SCHEMA_VERSION = "1.0"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _display(value, path, show_secrets, redaction_patterns=None):
|
|
21
|
+
if value is _MISSING:
|
|
22
|
+
return "<missing>"
|
|
23
|
+
safe = safe_value(value, path, show_secrets, redaction_patterns)
|
|
24
|
+
return json.dumps(safe, ensure_ascii=False, sort_keys=True, default=str)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _type_name(value):
|
|
28
|
+
if value is None:
|
|
29
|
+
return "null"
|
|
30
|
+
if value is _MISSING:
|
|
31
|
+
return "missing"
|
|
32
|
+
if isinstance(value, bool):
|
|
33
|
+
return "bool"
|
|
34
|
+
if isinstance(value, dict):
|
|
35
|
+
return "object"
|
|
36
|
+
if isinstance(value, list):
|
|
37
|
+
return "array"
|
|
38
|
+
return type(value).__name__
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _counts(differences):
|
|
42
|
+
counts = Counter(diff.kind for diff in differences)
|
|
43
|
+
return {"added": counts["added"], "removed": counts["removed"], "changed": counts["changed"]}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _render(differences: List[Difference], color: bool, show_secrets: bool, summary: bool = False, redaction_patterns=None) -> str:
|
|
47
|
+
lines = []
|
|
48
|
+
for diff in differences:
|
|
49
|
+
path = diff.path
|
|
50
|
+
if diff.kind == "added":
|
|
51
|
+
line = f"+ {path} = {_display(diff.new, path, show_secrets, redaction_patterns)}"
|
|
52
|
+
tone = _GREEN
|
|
53
|
+
elif diff.kind == "removed":
|
|
54
|
+
line = f"- {path} = {_display(diff.old, path, show_secrets, redaction_patterns)}"
|
|
55
|
+
tone = _RED
|
|
56
|
+
else:
|
|
57
|
+
old = _display(diff.old, path, show_secrets, redaction_patterns)
|
|
58
|
+
new = _display(diff.new, path, show_secrets, redaction_patterns)
|
|
59
|
+
if type(diff.old) is not type(diff.new):
|
|
60
|
+
old = f"{old} ({_type_name(diff.old)})"
|
|
61
|
+
new = f"{new} ({_type_name(diff.new)})"
|
|
62
|
+
line = f"~ {path}: {old} -> {new}"
|
|
63
|
+
tone = _YELLOW
|
|
64
|
+
lines.append(f"{tone}{line}{_RESET}" if color else line)
|
|
65
|
+
if summary:
|
|
66
|
+
counts = _counts(differences)
|
|
67
|
+
lines.append(f"Summary: {counts['added']} added, {counts['removed']} removed, {counts['changed']} changed")
|
|
68
|
+
return "\n".join(lines)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _markdown_cell(value: str) -> str:
|
|
72
|
+
return value.replace("|", "\\|").replace("\n", "<br>").replace("`", "\\`")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _render_markdown(differences: List[Difference], show_secrets: bool, summary: bool = False, redaction_patterns=None) -> str:
|
|
76
|
+
if not differences:
|
|
77
|
+
return "No differences found."
|
|
78
|
+
lines = ["| Path | Change | Old value | New value |", "| --- | --- | --- | --- |"]
|
|
79
|
+
for diff in differences:
|
|
80
|
+
old = "" if diff.old is _MISSING else _display(diff.old, diff.path, show_secrets, redaction_patterns)
|
|
81
|
+
new = "" if diff.new is _MISSING else _display(diff.new, diff.path, show_secrets, redaction_patterns)
|
|
82
|
+
lines.append("| " + " | ".join(_markdown_cell(value) for value in (diff.path, diff.kind, old, new)) + " |")
|
|
83
|
+
if summary:
|
|
84
|
+
counts = _counts(differences)
|
|
85
|
+
lines.extend(["", f"**Summary:** {counts['added']} added, {counts['removed']} removed, {counts['changed']} changed"])
|
|
86
|
+
return "\n".join(lines)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _render_unified(differences: List[Difference], show_secrets: bool, left: str, right: str, redaction_patterns=None) -> str:
|
|
90
|
+
old_lines = []
|
|
91
|
+
new_lines = []
|
|
92
|
+
for diff in differences:
|
|
93
|
+
if diff.kind != "added":
|
|
94
|
+
old_lines.append(f"{diff.path} = {_display(diff.old, diff.path, show_secrets, redaction_patterns)}")
|
|
95
|
+
if diff.kind != "removed":
|
|
96
|
+
new_lines.append(f"{diff.path} = {_display(diff.new, diff.path, show_secrets, redaction_patterns)}")
|
|
97
|
+
return "\n".join(difflib.unified_diff(
|
|
98
|
+
old_lines,
|
|
99
|
+
new_lines,
|
|
100
|
+
fromfile=left,
|
|
101
|
+
tofile=right,
|
|
102
|
+
lineterm="",
|
|
103
|
+
))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
107
|
+
parser = argparse.ArgumentParser(prog="diffora", description="Compare JSON, YAML, TOML and .env configuration files.")
|
|
108
|
+
parser.add_argument("left", help="Original configuration file, or - to read stdin")
|
|
109
|
+
parser.add_argument("right", help="Configuration file to compare against, or - to read stdin")
|
|
110
|
+
parser.add_argument("--left-format", choices=["json", "yaml", "toml", "env", "dotenv"])
|
|
111
|
+
parser.add_argument("--right-format", choices=["json", "yaml", "toml", "env", "dotenv"])
|
|
112
|
+
parser.add_argument("--format", choices=["text", "json", "markdown", "unified", "github-actions", "json-patch"], default="text", help="Output format")
|
|
113
|
+
parser.add_argument("--show-secrets", action="store_true", help="Display values whose keys look sensitive")
|
|
114
|
+
parser.add_argument("--redact", action="append", default=[], metavar="PATH", help="Redact an additional path or glob pattern; may be repeated")
|
|
115
|
+
parser.add_argument("--no-color", action="store_true", help="Disable ANSI colors")
|
|
116
|
+
parser.add_argument("--quiet", "-q", action="store_true", help="Suppress stdout; preserve exit codes for CI")
|
|
117
|
+
parser.add_argument("--list-key", help="Match lists by a stable key; use kubernetes for apiVersion/kind/namespace/name matching")
|
|
118
|
+
parser.add_argument("--ignore-order", action="store_true", help="Treat lists as unordered multisets when no --list-key is set")
|
|
119
|
+
parser.add_argument("--ignore", action="append", default=[], metavar="PATH", help="Ignore a path or glob pattern; may be repeated")
|
|
120
|
+
parser.add_argument("--summary", action="store_true", help="Print counts of added, removed, and changed values")
|
|
121
|
+
return parser
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _apply_command(argv: List[str]) -> int:
|
|
125
|
+
if len(argv) != 3:
|
|
126
|
+
print("usage: diffora apply PATCH.json TARGET.json", file=sys.stderr)
|
|
127
|
+
return 2
|
|
128
|
+
_, patch_path, target_path = argv
|
|
129
|
+
if Path(patch_path).resolve() == Path(target_path).resolve():
|
|
130
|
+
print("diffora: patch and target must be different files", file=sys.stderr)
|
|
131
|
+
return 2
|
|
132
|
+
try:
|
|
133
|
+
patch = load_config(patch_path, "json")
|
|
134
|
+
target = load_config(target_path, "json")
|
|
135
|
+
updated = apply_json_patch(target, patch)
|
|
136
|
+
Path(target_path).write_text(json.dumps(updated, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
137
|
+
except (ConfigDiffError, OSError, TypeError, ValueError) as exc:
|
|
138
|
+
print(f"diffora: {exc}", file=sys.stderr)
|
|
139
|
+
return 2
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
144
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
145
|
+
if argv and argv[0] == "apply":
|
|
146
|
+
return _apply_command(argv)
|
|
147
|
+
parser = build_parser()
|
|
148
|
+
args = parser.parse_args(argv)
|
|
149
|
+
if args.left == "-" and args.right == "-":
|
|
150
|
+
parser.error("only one input can read from stdin at a time; use a file for the other input")
|
|
151
|
+
if args.left == "-" and not args.left_format:
|
|
152
|
+
parser.error("--left-format is required when left input is stdin (-)")
|
|
153
|
+
if args.right == "-" and not args.right_format:
|
|
154
|
+
parser.error("--right-format is required when right input is stdin (-)")
|
|
155
|
+
try:
|
|
156
|
+
left_is_dir, right_is_dir = os.path.isdir(args.left), os.path.isdir(args.right)
|
|
157
|
+
if left_is_dir or right_is_dir:
|
|
158
|
+
if not (left_is_dir and right_is_dir):
|
|
159
|
+
raise ConfigDiffError("Both inputs must be directories when comparing directories")
|
|
160
|
+
if args.left_format or args.right_format:
|
|
161
|
+
raise ConfigDiffError("Explicit file formats cannot be used for directory comparison")
|
|
162
|
+
differences = compare_directories(args.left, args.right, args.list_key, args.ignore, args.ignore_order)
|
|
163
|
+
else:
|
|
164
|
+
differences = compare_files(args.left, args.right, args.left_format, args.right_format, args.list_key, args.ignore, args.ignore_order)
|
|
165
|
+
except ConfigDiffError as exc:
|
|
166
|
+
print(f"diffora: {exc}", file=sys.stderr)
|
|
167
|
+
return 2
|
|
168
|
+
if args.quiet:
|
|
169
|
+
return 1 if differences else 0
|
|
170
|
+
if args.format == "json-patch":
|
|
171
|
+
if left_is_dir or right_is_dir:
|
|
172
|
+
print("diffora: --format json-patch supports files, not directory comparisons", file=sys.stderr)
|
|
173
|
+
return 2
|
|
174
|
+
if args.ignore or args.redact:
|
|
175
|
+
print("diffora: --format json-patch cannot be combined with --ignore or --redact because patches must reproduce the complete target", file=sys.stderr)
|
|
176
|
+
return 2
|
|
177
|
+
if not args.show_secrets:
|
|
178
|
+
print("diffora: --format json-patch requires --show-secrets because a usable patch may contain secret values", file=sys.stderr)
|
|
179
|
+
return 2
|
|
180
|
+
try:
|
|
181
|
+
old = load_config(args.left, args.left_format)
|
|
182
|
+
new = load_config(args.right, args.right_format)
|
|
183
|
+
except ConfigDiffError as exc:
|
|
184
|
+
print(f"diffora: {exc}", file=sys.stderr)
|
|
185
|
+
return 2
|
|
186
|
+
print(json.dumps(build_json_patch(old, new), ensure_ascii=False, indent=2, default=str))
|
|
187
|
+
elif args.format == "json":
|
|
188
|
+
result = {
|
|
189
|
+
"schema_version": _SCHEMA_VERSION,
|
|
190
|
+
"differences": serialize_differences(differences, args.show_secrets, args.redact),
|
|
191
|
+
}
|
|
192
|
+
if args.summary:
|
|
193
|
+
result["summary"] = _counts(differences)
|
|
194
|
+
print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
|
|
195
|
+
elif args.format == "markdown":
|
|
196
|
+
print(_render_markdown(differences, args.show_secrets, args.summary, args.redact))
|
|
197
|
+
elif args.format == "unified":
|
|
198
|
+
rendered = _render_unified(differences, args.show_secrets, args.left, args.right, args.redact)
|
|
199
|
+
if rendered:
|
|
200
|
+
print(rendered)
|
|
201
|
+
elif args.format == "github-actions":
|
|
202
|
+
for diff in differences:
|
|
203
|
+
old = "" if diff.kind == "added" else _display(diff.old, diff.path, args.show_secrets, args.redact)
|
|
204
|
+
new = "" if diff.kind == "removed" else _display(diff.new, diff.path, args.show_secrets, args.redact)
|
|
205
|
+
message = f"{diff.kind}: {diff.path}"
|
|
206
|
+
if diff.kind == "changed":
|
|
207
|
+
message += f" ({old} -> {new})"
|
|
208
|
+
elif diff.kind == "added":
|
|
209
|
+
message += f" ({new})"
|
|
210
|
+
else:
|
|
211
|
+
message += f" ({old})"
|
|
212
|
+
message = message.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
|
|
213
|
+
print(f"::warning title=Diffora configuration difference::{message}")
|
|
214
|
+
elif not differences:
|
|
215
|
+
print("No differences found.")
|
|
216
|
+
if args.summary:
|
|
217
|
+
print("Summary: 0 added, 0 removed, 0 changed")
|
|
218
|
+
else:
|
|
219
|
+
color = sys.stdout.isatty() and not args.no_color and not os.environ.get("NO_COLOR")
|
|
220
|
+
print(_render(differences, color, args.show_secrets, args.summary, args.redact))
|
|
221
|
+
return 1 if differences else 0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
if __name__ == "__main__":
|
|
225
|
+
raise SystemExit(main())
|
configdiff/core.py
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import tomllib
|
|
12
|
+
except ModuleNotFoundError:
|
|
13
|
+
import tomli as tomllib
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import orjson
|
|
19
|
+
except ImportError:
|
|
20
|
+
orjson = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ConfigDiffError(ValueError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _MissingValue:
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_MISSING = _MissingValue()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Difference:
|
|
36
|
+
path: str
|
|
37
|
+
kind: str
|
|
38
|
+
old: Any = _MISSING
|
|
39
|
+
new: Any = _MISSING
|
|
40
|
+
|
|
41
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
42
|
+
return asdict(self)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_FORMATS = {
|
|
46
|
+
".json": "json",
|
|
47
|
+
".yaml": "yaml",
|
|
48
|
+
".yml": "yaml",
|
|
49
|
+
".toml": "toml",
|
|
50
|
+
".env": "env",
|
|
51
|
+
}
|
|
52
|
+
_SECRET_PATTERN = re.compile(r"password|passwd|secret|token|api[_-]?key|private[_-]?key|client[_-]?secret|credential|authorization|\bauth\b|cookie|session|connection[_-]?string|\bdsn\b|signing[_-]?key|encryption[_-]?key", re.I)
|
|
53
|
+
_MAX_INPUT_BYTES = 16 * 1024 * 1024
|
|
54
|
+
_MAX_STRUCTURE_NODES = 500_000
|
|
55
|
+
_MAX_STRUCTURE_DEPTH = 256
|
|
56
|
+
_IDENTIFIER_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_-]*")
|
|
57
|
+
|
|
58
|
+
@lru_cache(maxsize=4096)
|
|
59
|
+
def _is_identifier(value: str) -> bool:
|
|
60
|
+
return _IDENTIFIER_PATTERN.fullmatch(value) is not None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def detect_format(path: os.PathLike) -> str:
|
|
64
|
+
name = Path(path).name.lower()
|
|
65
|
+
if name == ".env" or name.startswith(".env."):
|
|
66
|
+
return "env"
|
|
67
|
+
suffix = Path(name).suffix
|
|
68
|
+
try:
|
|
69
|
+
return _FORMATS[suffix]
|
|
70
|
+
except KeyError as exc:
|
|
71
|
+
raise ConfigDiffError("Cannot detect format from extension; use --left-format or --right-format") from exc
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _parse_env(text: str) -> Dict[str, str]:
|
|
75
|
+
result: Dict[str, str] = {}
|
|
76
|
+
for number, raw_line in enumerate(text.splitlines(), 1):
|
|
77
|
+
line = raw_line.strip()
|
|
78
|
+
if not line or line.startswith("#"):
|
|
79
|
+
continue
|
|
80
|
+
if line.startswith("export "):
|
|
81
|
+
line = line[7:].lstrip()
|
|
82
|
+
if "=" not in line:
|
|
83
|
+
raise ConfigDiffError(f"Invalid .env entry at line {number}, column {len(line) + 1}: expected KEY=VALUE")
|
|
84
|
+
key, value = line.split("=", 1)
|
|
85
|
+
key = key.strip()
|
|
86
|
+
if not key or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", key):
|
|
87
|
+
raise ConfigDiffError(f"Invalid .env key at line {number}, column 1")
|
|
88
|
+
value = value.strip()
|
|
89
|
+
if value.startswith(("'", '"')):
|
|
90
|
+
quote = value[0]
|
|
91
|
+
closing = value.find(quote, 1)
|
|
92
|
+
if closing < 0:
|
|
93
|
+
raise ConfigDiffError(f"Unterminated quoted value at line {number}, column {len(key) + 2}")
|
|
94
|
+
trailing = value[closing + 1:].strip()
|
|
95
|
+
if trailing and not trailing.startswith("#"):
|
|
96
|
+
raise ConfigDiffError(f"Unexpected text after quoted value at line {number}, column {closing + 2}")
|
|
97
|
+
value = value[1:closing]
|
|
98
|
+
elif " #" in value:
|
|
99
|
+
value = value.split(" #", 1)[0].rstrip()
|
|
100
|
+
result[key] = value
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _read_input(file_path: Path, selected: str, is_stdin: bool) -> Any:
|
|
105
|
+
try:
|
|
106
|
+
if is_stdin:
|
|
107
|
+
stream = getattr(sys.stdin, "buffer", None)
|
|
108
|
+
data = stream.read(_MAX_INPUT_BYTES + 1) if stream is not None else sys.stdin.read(_MAX_INPUT_BYTES + 1)
|
|
109
|
+
else:
|
|
110
|
+
with file_path.open("rb") as stream:
|
|
111
|
+
data = stream.read(_MAX_INPUT_BYTES + 1)
|
|
112
|
+
except (OSError, ValueError) as exc:
|
|
113
|
+
raise ConfigDiffError(f"Cannot read {file_path}: {exc}") from exc
|
|
114
|
+
size = len(data) if isinstance(data, bytes) else len(data.encode("utf-8"))
|
|
115
|
+
if size > _MAX_INPUT_BYTES:
|
|
116
|
+
raise ConfigDiffError(f"Input exceeds the maximum size of {_MAX_INPUT_BYTES} bytes")
|
|
117
|
+
if selected == "json":
|
|
118
|
+
return data
|
|
119
|
+
if isinstance(data, str):
|
|
120
|
+
return data.lstrip("\ufeff")
|
|
121
|
+
try:
|
|
122
|
+
return data.decode("utf-8-sig")
|
|
123
|
+
except UnicodeDecodeError:
|
|
124
|
+
raise ConfigDiffError(f"Cannot decode {file_path} as UTF-8") from None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _validate_structure(value: Any) -> None:
|
|
128
|
+
"""Validate a parsed tree with O(depth), rather than O(width), traversal memory."""
|
|
129
|
+
nodes = 1
|
|
130
|
+
if nodes > _MAX_STRUCTURE_NODES:
|
|
131
|
+
raise ConfigDiffError(f"Configuration exceeds the {_MAX_STRUCTURE_NODES} node limit")
|
|
132
|
+
if not isinstance(value, (dict, list)):
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
active = {id(value)}
|
|
136
|
+
children = value.values() if isinstance(value, dict) else value
|
|
137
|
+
stack = [(iter(children), id(value), 0)]
|
|
138
|
+
while stack:
|
|
139
|
+
iterator, container_id, parent_depth = stack[-1]
|
|
140
|
+
try:
|
|
141
|
+
current = next(iterator)
|
|
142
|
+
except StopIteration:
|
|
143
|
+
active.remove(container_id)
|
|
144
|
+
stack.pop()
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
depth = parent_depth + 1
|
|
148
|
+
nodes += 1
|
|
149
|
+
if nodes > _MAX_STRUCTURE_NODES:
|
|
150
|
+
raise ConfigDiffError(f"Configuration exceeds the {_MAX_STRUCTURE_NODES} node limit")
|
|
151
|
+
if depth > _MAX_STRUCTURE_DEPTH:
|
|
152
|
+
raise ConfigDiffError(f"Configuration exceeds the maximum nesting depth of {_MAX_STRUCTURE_DEPTH}")
|
|
153
|
+
current_type = type(current)
|
|
154
|
+
if current_type is dict or current_type is list:
|
|
155
|
+
is_container = True
|
|
156
|
+
elif current_type in (str, int, float, bool, type(None)):
|
|
157
|
+
is_container = False
|
|
158
|
+
else:
|
|
159
|
+
# Preserve support for container subclasses without paying for
|
|
160
|
+
# isinstance() on the common scalar types in parsed config trees.
|
|
161
|
+
is_container = isinstance(current, (dict, list))
|
|
162
|
+
if is_container:
|
|
163
|
+
identity = id(current)
|
|
164
|
+
if identity in active:
|
|
165
|
+
raise ConfigDiffError("Cyclic configuration references are not supported")
|
|
166
|
+
active.add(identity)
|
|
167
|
+
children = current.values() if isinstance(current, dict) else current
|
|
168
|
+
stack.append((iter(children), identity, depth))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _reject_json_constant(value: str) -> None:
|
|
172
|
+
raise ValueError(f"Non-standard JSON constant: {value}")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def load_config(path: os.PathLike, fmt: Optional[str] = None) -> Any:
|
|
176
|
+
file_path = Path(path)
|
|
177
|
+
is_stdin = str(path) == "-"
|
|
178
|
+
if is_stdin and fmt is None:
|
|
179
|
+
raise ConfigDiffError("Input from stdin requires an explicit --left-format or --right-format")
|
|
180
|
+
selected = (fmt or detect_format(file_path)).lower()
|
|
181
|
+
if selected == "dotenv":
|
|
182
|
+
selected = "env"
|
|
183
|
+
if selected not in {"json", "yaml", "toml", "env"}:
|
|
184
|
+
raise ConfigDiffError(f"Unsupported format: {selected}")
|
|
185
|
+
text = _read_input(file_path, selected, is_stdin)
|
|
186
|
+
try:
|
|
187
|
+
if selected == "json":
|
|
188
|
+
if orjson is not None:
|
|
189
|
+
try:
|
|
190
|
+
value = orjson.loads(text)
|
|
191
|
+
except orjson.JSONDecodeError:
|
|
192
|
+
value = json.loads(text, parse_constant=_reject_json_constant)
|
|
193
|
+
else:
|
|
194
|
+
value = json.loads(text, parse_constant=_reject_json_constant)
|
|
195
|
+
elif selected == "yaml":
|
|
196
|
+
value = yaml.safe_load(text)
|
|
197
|
+
if value is None:
|
|
198
|
+
value = {}
|
|
199
|
+
elif selected == "toml":
|
|
200
|
+
value = tomllib.loads(text)
|
|
201
|
+
else:
|
|
202
|
+
value = _parse_env(text)
|
|
203
|
+
if selected == "yaml":
|
|
204
|
+
_validate_structure(value)
|
|
205
|
+
return value
|
|
206
|
+
except ConfigDiffError:
|
|
207
|
+
raise
|
|
208
|
+
except RecursionError:
|
|
209
|
+
raise ConfigDiffError("Configuration exceeds the supported parser nesting depth") from None
|
|
210
|
+
except Exception as exc:
|
|
211
|
+
location = ""
|
|
212
|
+
line = getattr(exc, "lineno", None)
|
|
213
|
+
column = getattr(exc, "colno", None)
|
|
214
|
+
problem_mark = getattr(exc, "problem_mark", None)
|
|
215
|
+
if problem_mark is not None:
|
|
216
|
+
line = problem_mark.line + 1
|
|
217
|
+
column = problem_mark.column + 1
|
|
218
|
+
if line is None or column is None:
|
|
219
|
+
match = re.search(r"at line (\d+), column (\d+)", str(exc))
|
|
220
|
+
if match:
|
|
221
|
+
line = line or int(match.group(1))
|
|
222
|
+
column = column or int(match.group(2))
|
|
223
|
+
if line is not None and column is not None:
|
|
224
|
+
location = f" at line {line}, column {column}"
|
|
225
|
+
elif line is not None:
|
|
226
|
+
location = f" at line {line}"
|
|
227
|
+
raise ConfigDiffError(f"Cannot parse {file_path} as {selected.upper()}{location}: invalid syntax") from None
|
|
228
|
+
|
|
229
|
+
def _child_path(path: str, key: Any, is_index: bool = False) -> str:
|
|
230
|
+
if is_index:
|
|
231
|
+
return f"{path}[{key}]"
|
|
232
|
+
if isinstance(key, str) and _is_identifier(key):
|
|
233
|
+
return f"{path}.{key}" if path else key
|
|
234
|
+
encoded = json.dumps(str(key), ensure_ascii=False)
|
|
235
|
+
return f"{path}[{encoded}]" if path else f"[{encoded}]"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@lru_cache(maxsize=4096)
|
|
239
|
+
def _glob_match(path: str, pattern: str) -> bool:
|
|
240
|
+
path_index = 0
|
|
241
|
+
pattern_index = 0
|
|
242
|
+
star_index = -1
|
|
243
|
+
retry_index = 0
|
|
244
|
+
while path_index < len(path):
|
|
245
|
+
if pattern_index < len(pattern) and (pattern[pattern_index] == "?" or pattern[pattern_index] == path[path_index]):
|
|
246
|
+
path_index += 1
|
|
247
|
+
pattern_index += 1
|
|
248
|
+
elif pattern_index < len(pattern) and pattern[pattern_index] == "*":
|
|
249
|
+
star_index = pattern_index
|
|
250
|
+
pattern_index += 1
|
|
251
|
+
retry_index = path_index
|
|
252
|
+
elif star_index >= 0:
|
|
253
|
+
pattern_index = star_index + 1
|
|
254
|
+
retry_index += 1
|
|
255
|
+
path_index = retry_index
|
|
256
|
+
else:
|
|
257
|
+
return False
|
|
258
|
+
while pattern_index < len(pattern) and pattern[pattern_index] == "*":
|
|
259
|
+
pattern_index += 1
|
|
260
|
+
return pattern_index == len(pattern)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@lru_cache(maxsize=4096)
|
|
264
|
+
def _is_ignored_cached(path: str, patterns: tuple) -> bool:
|
|
265
|
+
for pattern in patterns:
|
|
266
|
+
if _glob_match(path, pattern):
|
|
267
|
+
return True
|
|
268
|
+
if not any(char in pattern for char in "*?"):
|
|
269
|
+
if path.startswith(pattern + ".") or path.startswith(pattern + "["):
|
|
270
|
+
return True
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _is_ignored(path: str, patterns) -> bool:
|
|
275
|
+
if not patterns:
|
|
276
|
+
return False
|
|
277
|
+
normalized = patterns if isinstance(patterns, tuple) else tuple(patterns)
|
|
278
|
+
return _is_ignored_cached(path, normalized)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _append_tree(value: Any, path: str, kind: str, differences: List[Difference], ignore_paths: List[str]) -> None:
|
|
282
|
+
if _is_ignored(path, ignore_paths):
|
|
283
|
+
return
|
|
284
|
+
if isinstance(value, dict) and value:
|
|
285
|
+
for key in sorted(value, key=lambda item: str(item)):
|
|
286
|
+
_append_tree(value[key], _child_path(path, key), kind, differences, ignore_paths)
|
|
287
|
+
return
|
|
288
|
+
if isinstance(value, list) and value:
|
|
289
|
+
for index, item in enumerate(value):
|
|
290
|
+
_append_tree(item, _child_path(path, index, True), kind, differences, ignore_paths)
|
|
291
|
+
return
|
|
292
|
+
differences.append(Difference(path or "$", kind, _MISSING if kind == "added" else value, value if kind == "added" else _MISSING))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _keyed_list_index(items: List[Any], key: str) -> Optional[Dict[Any, Any]]:
|
|
296
|
+
"""Index by a stable identity; reject missing or duplicate identities.
|
|
297
|
+
|
|
298
|
+
The built-in kubernetes key handles cluster-scoped resources by treating
|
|
299
|
+
an absent metadata.namespace as the empty namespace.
|
|
300
|
+
"""
|
|
301
|
+
kubernetes = key.strip().lower() == "kubernetes"
|
|
302
|
+
fields = ("apiVersion", "kind", "metadata.namespace", "metadata.name") if kubernetes else tuple(part.strip() for part in key.split(","))
|
|
303
|
+
if not fields or any(not field for field in fields):
|
|
304
|
+
return None
|
|
305
|
+
# Parse dotted selectors once, rather than allocating segment lists for
|
|
306
|
+
# every field of every item in a large resource list.
|
|
307
|
+
field_segments = tuple((field, tuple(field.split("."))) for field in fields)
|
|
308
|
+
indexed = {}
|
|
309
|
+
try:
|
|
310
|
+
for item in items:
|
|
311
|
+
if not isinstance(item, dict):
|
|
312
|
+
return None
|
|
313
|
+
identity_parts = []
|
|
314
|
+
for field, segments in field_segments:
|
|
315
|
+
value = item
|
|
316
|
+
for segment in segments:
|
|
317
|
+
if not isinstance(value, dict):
|
|
318
|
+
return None
|
|
319
|
+
if segment not in value:
|
|
320
|
+
if kubernetes and field == "metadata.namespace":
|
|
321
|
+
value = ""
|
|
322
|
+
break
|
|
323
|
+
return None
|
|
324
|
+
value = value[segment]
|
|
325
|
+
if value is None or isinstance(value, (dict, list)):
|
|
326
|
+
return None
|
|
327
|
+
identity_parts.append(value)
|
|
328
|
+
identity = tuple(identity_parts) if kubernetes or len(identity_parts) > 1 else identity_parts[0]
|
|
329
|
+
if identity in indexed:
|
|
330
|
+
return None
|
|
331
|
+
indexed[identity] = item
|
|
332
|
+
except TypeError:
|
|
333
|
+
return None
|
|
334
|
+
return indexed if len(indexed) == len(items) else None
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _keyed_path(path: str, key: str, identity: Any) -> str:
|
|
338
|
+
if key.strip().lower() == "kubernetes":
|
|
339
|
+
fields = "apiVersion,kind,metadata.namespace,metadata.name"
|
|
340
|
+
else:
|
|
341
|
+
fields = ",".join(part.strip() for part in key.split(","))
|
|
342
|
+
if isinstance(identity, str) and "\\\\" not in identity and '"' not in identity and identity.isprintable():
|
|
343
|
+
encoded = f'"{identity}"'
|
|
344
|
+
else:
|
|
345
|
+
encoded = json.dumps(identity, ensure_ascii=False, sort_keys=True)
|
|
346
|
+
selector = f"{fields}={encoded}"
|
|
347
|
+
return f"{path}[{selector}]" if path else f"[{selector}]"
|
|
348
|
+
|
|
349
|
+
def _append_keyed_item(value: Any, path: str, kind: str, key: str, differences: List[Difference], ignore_paths: List[str]) -> None:
|
|
350
|
+
if isinstance(value, dict):
|
|
351
|
+
has_remaining = any(name != key for name in value)
|
|
352
|
+
if has_remaining:
|
|
353
|
+
for name in sorted(value, key=lambda item: str(item)):
|
|
354
|
+
if name != key:
|
|
355
|
+
_append_tree(value[name], _child_path(path, name), kind, differences, ignore_paths)
|
|
356
|
+
return
|
|
357
|
+
_append_tree({}, path, kind, differences, ignore_paths)
|
|
358
|
+
return
|
|
359
|
+
_append_tree(value, path, kind, differences, ignore_paths)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _unordered_fingerprint(value: Any) -> int:
|
|
363
|
+
"""Build a compact, order-aware fingerprint without retaining a duplicate tree.
|
|
364
|
+
|
|
365
|
+
Fingerprints only select candidate buckets: callers still verify exact equality,
|
|
366
|
+
so hash collisions cannot turn unequal values into matches.
|
|
367
|
+
"""
|
|
368
|
+
if isinstance(value, dict):
|
|
369
|
+
combined = 0
|
|
370
|
+
for key, item in value.items():
|
|
371
|
+
combined ^= hash((_unordered_fingerprint(key), _unordered_fingerprint(item)))
|
|
372
|
+
return hash((dict, len(value), combined))
|
|
373
|
+
if isinstance(value, list):
|
|
374
|
+
result = hash((list, len(value)))
|
|
375
|
+
for item in value:
|
|
376
|
+
result = hash((result, _unordered_fingerprint(item)))
|
|
377
|
+
return result
|
|
378
|
+
if isinstance(value, float) and value != value:
|
|
379
|
+
return hash((float, "nan"))
|
|
380
|
+
try:
|
|
381
|
+
return hash((type(value), value))
|
|
382
|
+
except TypeError:
|
|
383
|
+
return hash((type(value), repr(value)))
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _values_equal(old: Any, new: Any) -> bool:
|
|
387
|
+
"""Compare JSON-like trees while preserving strict scalar types."""
|
|
388
|
+
if old is new:
|
|
389
|
+
return True
|
|
390
|
+
if type(old) is not type(new):
|
|
391
|
+
return False
|
|
392
|
+
if isinstance(old, dict):
|
|
393
|
+
if old.keys() != new.keys():
|
|
394
|
+
return False
|
|
395
|
+
for key in old:
|
|
396
|
+
if not _values_equal(old[key], new[key]):
|
|
397
|
+
return False
|
|
398
|
+
return True
|
|
399
|
+
if isinstance(old, list):
|
|
400
|
+
if len(old) != len(new):
|
|
401
|
+
return False
|
|
402
|
+
for index, left in enumerate(old):
|
|
403
|
+
if not _values_equal(left, new[index]):
|
|
404
|
+
return False
|
|
405
|
+
return True
|
|
406
|
+
return old == new
|
|
407
|
+
|
|
408
|
+
def _nested_types_equal(old: Any, new: Any) -> bool:
|
|
409
|
+
"""Backward-compatible alias for strict recursive equality."""
|
|
410
|
+
return _values_equal(old, new)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _compare_values(old: Any, new: Any, path: str, list_key: Optional[str], ignored: tuple, differences: List[Difference], ignore_order: bool = False) -> None:
|
|
414
|
+
if ignored and _is_ignored_cached(path, ignored):
|
|
415
|
+
return
|
|
416
|
+
if old is new:
|
|
417
|
+
return
|
|
418
|
+
if isinstance(old, dict) and isinstance(new, dict):
|
|
419
|
+
keys = list(old) if old.keys() == new.keys() else list(old.keys() | new.keys())
|
|
420
|
+
try:
|
|
421
|
+
keys.sort()
|
|
422
|
+
except TypeError:
|
|
423
|
+
keys.sort(key=str)
|
|
424
|
+
for key in keys:
|
|
425
|
+
if key in old and key in new:
|
|
426
|
+
left, right = old[key], new[key]
|
|
427
|
+
if not (list_key and isinstance(left, list) and isinstance(right, list)) and _values_equal(left, right):
|
|
428
|
+
continue
|
|
429
|
+
child = _child_path(path, key)
|
|
430
|
+
if key not in old:
|
|
431
|
+
_append_tree(new[key], child, "added", differences, ignored)
|
|
432
|
+
elif key not in new:
|
|
433
|
+
_append_tree(old[key], child, "removed", differences, ignored)
|
|
434
|
+
else:
|
|
435
|
+
_compare_values(old[key], new[key], child, list_key, ignored, differences, ignore_order)
|
|
436
|
+
return
|
|
437
|
+
if isinstance(old, list) and isinstance(new, list):
|
|
438
|
+
if (not list_key and _values_equal(old, new)) or old is new:
|
|
439
|
+
return
|
|
440
|
+
if ignore_order and not list_key:
|
|
441
|
+
new_buckets = {}
|
|
442
|
+
for index, item in enumerate(new):
|
|
443
|
+
new_buckets.setdefault(_unordered_fingerprint(item), []).append(index)
|
|
444
|
+
unmatched_old = []
|
|
445
|
+
for old_index, old_item in enumerate(old):
|
|
446
|
+
candidates = new_buckets.get(_unordered_fingerprint(old_item), [])
|
|
447
|
+
match_position = next((position for position, index in enumerate(candidates) if _values_equal(old_item, new[index])), None)
|
|
448
|
+
if match_position is None:
|
|
449
|
+
unmatched_old.append((old_index, old_item))
|
|
450
|
+
else:
|
|
451
|
+
candidates.pop(match_position)
|
|
452
|
+
unmatched_new = sorted(index for indexes in new_buckets.values() for index in indexes)
|
|
453
|
+
for index, item in unmatched_old:
|
|
454
|
+
_append_tree(item, _child_path(path, index, True), "removed", differences, ignored)
|
|
455
|
+
for index in unmatched_new:
|
|
456
|
+
_append_tree(new[index], _child_path(path, index, True), "added", differences, ignored)
|
|
457
|
+
return
|
|
458
|
+
old_index = _keyed_list_index(old, list_key) if list_key else None
|
|
459
|
+
new_index = _keyed_list_index(new, list_key) if list_key else None
|
|
460
|
+
if old_index is not None and new_index is not None:
|
|
461
|
+
identities = list(old_index)
|
|
462
|
+
identities.extend(identity for identity in new_index if identity not in old_index)
|
|
463
|
+
try:
|
|
464
|
+
identities.sort()
|
|
465
|
+
except TypeError:
|
|
466
|
+
identities.sort(key=str)
|
|
467
|
+
for identity in identities:
|
|
468
|
+
if identity not in old_index:
|
|
469
|
+
child = _keyed_path(path, list_key, identity)
|
|
470
|
+
_append_keyed_item(new_index[identity], child, "added", list_key, differences, ignored)
|
|
471
|
+
elif identity not in new_index:
|
|
472
|
+
child = _keyed_path(path, list_key, identity)
|
|
473
|
+
_append_keyed_item(old_index[identity], child, "removed", list_key, differences, ignored)
|
|
474
|
+
elif not _values_equal(old_index[identity], new_index[identity]):
|
|
475
|
+
child = _keyed_path(path, list_key, identity)
|
|
476
|
+
_compare_values(old_index[identity], new_index[identity], child, list_key, ignored, differences, ignore_order)
|
|
477
|
+
return
|
|
478
|
+
for index in range(max(len(old), len(new))):
|
|
479
|
+
child = _child_path(path, index, True)
|
|
480
|
+
if index >= len(old):
|
|
481
|
+
_append_tree(new[index], child, "added", differences, ignored)
|
|
482
|
+
elif index >= len(new):
|
|
483
|
+
_append_tree(old[index], child, "removed", differences, ignored)
|
|
484
|
+
elif not _values_equal(old[index], new[index]):
|
|
485
|
+
_compare_values(old[index], new[index], child, list_key, ignored, differences, ignore_order)
|
|
486
|
+
return
|
|
487
|
+
if type(old) is not type(new) or old != new:
|
|
488
|
+
differences.append(Difference(path or "$", "changed", old, new))
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _compare_validated_values(old: Any, new: Any, path: str, list_key: Optional[str], ignore_paths: Optional[List[str]], ignore_order: bool = False) -> List[Difference]:
|
|
492
|
+
differences: List[Difference] = []
|
|
493
|
+
try:
|
|
494
|
+
_compare_values(old, new, path, list_key, tuple(ignore_paths or ()), differences, ignore_order)
|
|
495
|
+
except RecursionError:
|
|
496
|
+
raise ConfigDiffError("Comparison exceeds the supported nesting depth") from None
|
|
497
|
+
return differences
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def compare_values(old: Any, new: Any, path: str = "", list_key: Optional[str] = None, ignore_paths: Optional[List[str]] = None, ignore_order: bool = False) -> List[Difference]:
|
|
501
|
+
_validate_structure(old)
|
|
502
|
+
_validate_structure(new)
|
|
503
|
+
return _compare_validated_values(old, new, path, list_key, ignore_paths, ignore_order)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def compare_files(left: os.PathLike, right: os.PathLike, left_format: Optional[str] = None, right_format: Optional[str] = None, list_key: Optional[str] = None, ignore_paths: Optional[List[str]] = None, ignore_order: bool = False) -> List[Difference]:
|
|
507
|
+
old = load_config(left, left_format)
|
|
508
|
+
new = load_config(right, right_format)
|
|
509
|
+
return _compare_validated_values(old, new, "", list_key, ignore_paths, ignore_order)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def compare_directories(left: os.PathLike, right: os.PathLike, list_key: Optional[str] = None, ignore_paths: Optional[List[str]] = None, ignore_order: bool = False) -> List[Difference]:
|
|
513
|
+
left_root, right_root = Path(left), Path(right)
|
|
514
|
+
if not left_root.is_dir() or not right_root.is_dir():
|
|
515
|
+
raise ConfigDiffError("Directory comparison requires two existing directories")
|
|
516
|
+
|
|
517
|
+
def inventory(root: Path) -> Dict[str, Path]:
|
|
518
|
+
files = {}
|
|
519
|
+
for candidate in root.rglob("*"):
|
|
520
|
+
if candidate.is_symlink() or not candidate.is_file():
|
|
521
|
+
continue
|
|
522
|
+
files[candidate.relative_to(root).as_posix()] = candidate
|
|
523
|
+
return files
|
|
524
|
+
|
|
525
|
+
left_files, right_files = inventory(left_root), inventory(right_root)
|
|
526
|
+
differences: List[Difference] = []
|
|
527
|
+
for relative in sorted(left_files.keys() | right_files.keys()):
|
|
528
|
+
if relative not in left_files:
|
|
529
|
+
differences.append(Difference(relative, "added", _MISSING, "<file>"))
|
|
530
|
+
continue
|
|
531
|
+
if relative not in right_files:
|
|
532
|
+
differences.append(Difference(relative, "removed", "<file>", _MISSING))
|
|
533
|
+
continue
|
|
534
|
+
left_file, right_file = left_files[relative], right_files[relative]
|
|
535
|
+
try:
|
|
536
|
+
fmt = detect_format(left_file)
|
|
537
|
+
except ConfigDiffError:
|
|
538
|
+
try:
|
|
539
|
+
with left_file.open("rb") as left_stream, right_file.open("rb") as right_stream:
|
|
540
|
+
while True:
|
|
541
|
+
left_chunk, right_chunk = left_stream.read(65536), right_stream.read(65536)
|
|
542
|
+
if left_chunk != right_chunk:
|
|
543
|
+
same = False
|
|
544
|
+
break
|
|
545
|
+
if not left_chunk:
|
|
546
|
+
same = True
|
|
547
|
+
break
|
|
548
|
+
except OSError as exc:
|
|
549
|
+
raise ConfigDiffError(f"Cannot read directory entry {relative}: {exc}") from exc
|
|
550
|
+
if not same:
|
|
551
|
+
differences.append(Difference(relative, "changed", "<file>", "<file>"))
|
|
552
|
+
continue
|
|
553
|
+
try:
|
|
554
|
+
file_differences = compare_files(left_file, right_file, fmt, fmt, list_key, ignore_paths, ignore_order)
|
|
555
|
+
except ConfigDiffError as exc:
|
|
556
|
+
raise ConfigDiffError(f"In {relative}: {exc}") from None
|
|
557
|
+
for diff in file_differences:
|
|
558
|
+
differences.append(Difference(f"{relative}::{diff.path}", diff.kind, diff.old, diff.new))
|
|
559
|
+
return differences
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _is_secret_path(path: str, redaction_patterns: Optional[List[str]] = None) -> bool:
|
|
563
|
+
segments = re.findall(r'[^.\[\]]+', path)
|
|
564
|
+
if any(_SECRET_PATTERN.search(segment.strip(chr(34) + chr(39))) for segment in segments):
|
|
565
|
+
return True
|
|
566
|
+
for pattern in redaction_patterns or []:
|
|
567
|
+
if _glob_match(path, pattern):
|
|
568
|
+
return True
|
|
569
|
+
if not any(char in pattern for char in "*?"):
|
|
570
|
+
if path.startswith(pattern + ".") or path.startswith(pattern + "["):
|
|
571
|
+
return True
|
|
572
|
+
return False
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def safe_value(value: Any, path: str, show_secrets: bool = False, redaction_patterns: Optional[List[str]] = None) -> Any:
|
|
576
|
+
if value is _MISSING:
|
|
577
|
+
return None
|
|
578
|
+
if not show_secrets and _is_secret_path(path, redaction_patterns):
|
|
579
|
+
return "********"
|
|
580
|
+
if isinstance(value, dict):
|
|
581
|
+
return {key: safe_value(item, _child_path(path, key), show_secrets, redaction_patterns) for key, item in value.items()}
|
|
582
|
+
if isinstance(value, list):
|
|
583
|
+
return [safe_value(item, _child_path(path, index, True), show_secrets, redaction_patterns) for index, item in enumerate(value)]
|
|
584
|
+
return value
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def serialize_differences(differences: List[Difference], show_secrets: bool = False, redaction_patterns: Optional[List[str]] = None) -> List[Dict[str, Any]]:
|
|
588
|
+
result = []
|
|
589
|
+
for diff in differences:
|
|
590
|
+
result.append({
|
|
591
|
+
"path": diff.path,
|
|
592
|
+
"kind": diff.kind,
|
|
593
|
+
"old": safe_value(diff.old, diff.path, show_secrets, redaction_patterns),
|
|
594
|
+
"new": safe_value(diff.new, diff.path, show_secrets, redaction_patterns),
|
|
595
|
+
"old_missing": diff.old is _MISSING,
|
|
596
|
+
"new_missing": diff.new is _MISSING,
|
|
597
|
+
})
|
|
598
|
+
return result
|
configdiff/patch.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
from typing import Any, Dict, List, Optional
|
|
3
|
+
|
|
4
|
+
from .core import ConfigDiffError, _values_equal
|
|
5
|
+
|
|
6
|
+
# Keep exact list diffing bounded: quadratic LCS is useful for small config
|
|
7
|
+
# arrays, but a whole-list replacement is safer and faster for large arrays.
|
|
8
|
+
_MAX_LIST_DIFF_CELLS = 4096
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _pointer(parts: List[Any]) -> str:
|
|
12
|
+
return "".join("/" + str(part).replace("~", "~0").replace("/", "~1") for part in parts)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _list_patch(old: List[Any], new: List[Any], path: List[Any]) -> List[Dict[str, Any]]:
|
|
16
|
+
n, m = len(old), len(new)
|
|
17
|
+
if n * m > _MAX_LIST_DIFF_CELLS:
|
|
18
|
+
return [{"op": "replace", "path": _pointer(path), "value": copy.deepcopy(new)}]
|
|
19
|
+
|
|
20
|
+
# Longest common subsequence minimizes add/remove operations while keeping
|
|
21
|
+
# array indices valid as each operation is applied from left to right.
|
|
22
|
+
lcs = [[0] * (m + 1) for _ in range(n + 1)]
|
|
23
|
+
for i in range(n - 1, -1, -1):
|
|
24
|
+
row, next_row = lcs[i], lcs[i + 1]
|
|
25
|
+
for j in range(m - 1, -1, -1):
|
|
26
|
+
if _values_equal(old[i], new[j]):
|
|
27
|
+
row[j] = next_row[j + 1] + 1
|
|
28
|
+
else:
|
|
29
|
+
row[j] = max(next_row[j], row[j + 1])
|
|
30
|
+
|
|
31
|
+
operations: List[Dict[str, Any]] = []
|
|
32
|
+
i = j = index = 0
|
|
33
|
+
while i < n or j < m:
|
|
34
|
+
if i < n and j < m and _values_equal(old[i], new[j]):
|
|
35
|
+
i += 1
|
|
36
|
+
j += 1
|
|
37
|
+
index += 1
|
|
38
|
+
elif i < n and (j == m or lcs[i + 1][j] >= lcs[i][j + 1]):
|
|
39
|
+
operations.append({"op": "remove", "path": _pointer(path + [index])})
|
|
40
|
+
i += 1
|
|
41
|
+
else:
|
|
42
|
+
operations.append({
|
|
43
|
+
"op": "add",
|
|
44
|
+
"path": _pointer(path + [index]),
|
|
45
|
+
"value": copy.deepcopy(new[j]),
|
|
46
|
+
})
|
|
47
|
+
j += 1
|
|
48
|
+
index += 1
|
|
49
|
+
return operations
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_json_patch(old: Any, new: Any, path: Optional[List[Any]] = None) -> List[Dict[str, Any]]:
|
|
53
|
+
operations: List[Dict[str, Any]] = []
|
|
54
|
+
path = list(path or [])
|
|
55
|
+
if _values_equal(old, new):
|
|
56
|
+
return operations
|
|
57
|
+
if type(old) is type(new) and isinstance(old, dict):
|
|
58
|
+
for key in sorted(old.keys() - new.keys(), key=str):
|
|
59
|
+
operations.append({"op": "remove", "path": _pointer(path + [key])})
|
|
60
|
+
for key in sorted(new.keys() - old.keys(), key=str):
|
|
61
|
+
operations.append({"op": "add", "path": _pointer(path + [key]), "value": copy.deepcopy(new[key])})
|
|
62
|
+
for key in sorted(old.keys() & new.keys(), key=str):
|
|
63
|
+
operations.extend(build_json_patch(old[key], new[key], path + [key]))
|
|
64
|
+
return operations
|
|
65
|
+
if isinstance(old, list) and isinstance(new, list):
|
|
66
|
+
return _list_patch(old, new, path)
|
|
67
|
+
return [{"op": "replace", "path": _pointer(path), "value": copy.deepcopy(new)}]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _decode_pointer(pointer: str) -> List[str]:
|
|
71
|
+
if pointer == "":
|
|
72
|
+
return []
|
|
73
|
+
if not pointer.startswith("/"):
|
|
74
|
+
raise ConfigDiffError("Invalid JSON Pointer in patch")
|
|
75
|
+
return [part.replace("~1", "/").replace("~0", "~") for part in pointer[1:].split("/")]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def apply_json_patch(document: Any, operations: Any) -> Any:
|
|
79
|
+
if not isinstance(operations, list):
|
|
80
|
+
raise ConfigDiffError("JSON Patch must be an array")
|
|
81
|
+
result = copy.deepcopy(document)
|
|
82
|
+
for number, operation in enumerate(operations):
|
|
83
|
+
if not isinstance(operation, dict) or operation.get("op") not in {"add", "remove", "replace"} or not isinstance(operation.get("path"), str):
|
|
84
|
+
raise ConfigDiffError(f"Invalid JSON Patch operation at index {number}")
|
|
85
|
+
op, parts = operation["op"], _decode_pointer(operation["path"])
|
|
86
|
+
if op in {"add", "replace"} and "value" not in operation:
|
|
87
|
+
raise ConfigDiffError(f"Missing value in JSON Patch operation at index {number}")
|
|
88
|
+
if not parts:
|
|
89
|
+
if op == "remove":
|
|
90
|
+
raise ConfigDiffError("Removing the document root is not supported")
|
|
91
|
+
result = copy.deepcopy(operation["value"])
|
|
92
|
+
continue
|
|
93
|
+
parent = result
|
|
94
|
+
try:
|
|
95
|
+
for part in parts[:-1]:
|
|
96
|
+
parent = parent[int(part)] if isinstance(parent, list) else parent[part]
|
|
97
|
+
key = parts[-1]
|
|
98
|
+
if isinstance(parent, list):
|
|
99
|
+
if op == "add":
|
|
100
|
+
if key == "-":
|
|
101
|
+
parent.append(copy.deepcopy(operation["value"]))
|
|
102
|
+
else:
|
|
103
|
+
index = int(key)
|
|
104
|
+
if index < 0 or index > len(parent):
|
|
105
|
+
raise IndexError
|
|
106
|
+
parent.insert(index, copy.deepcopy(operation["value"]))
|
|
107
|
+
elif op == "remove":
|
|
108
|
+
del parent[int(key)]
|
|
109
|
+
else:
|
|
110
|
+
parent[int(key)] = copy.deepcopy(operation["value"])
|
|
111
|
+
elif isinstance(parent, dict):
|
|
112
|
+
if op == "add":
|
|
113
|
+
parent[key] = copy.deepcopy(operation["value"])
|
|
114
|
+
elif op == "remove":
|
|
115
|
+
del parent[key]
|
|
116
|
+
else:
|
|
117
|
+
if key not in parent:
|
|
118
|
+
raise KeyError(key)
|
|
119
|
+
parent[key] = copy.deepcopy(operation["value"])
|
|
120
|
+
else:
|
|
121
|
+
raise TypeError
|
|
122
|
+
except (KeyError, IndexError, ValueError, TypeError) as exc:
|
|
123
|
+
raise ConfigDiffError(f"Cannot apply JSON Patch operation at index {number}: path does not exist or is invalid") from exc
|
|
124
|
+
return result
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: diffora
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Secret-aware semantic configuration diffs for JSON, YAML, TOML and .env files.
|
|
5
|
+
Author: HashPalace
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/hashpalace/ConfigDiff
|
|
8
|
+
Project-URL: Repository, https://github.com/hashpalace/ConfigDiff
|
|
9
|
+
Project-URL: Issues, https://github.com/hashpalace/ConfigDiff/issues
|
|
10
|
+
Project-URL: Documentation, https://github.com/hashpalace/ConfigDiff#readme
|
|
11
|
+
Keywords: config,configuration,configuration-diff,config-drift,diff,diff-tool,semantic-diff,devops,infrastructure,json,yaml,toml,dotenv,secrets,debugging,ci,github-actions
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Topic :: Software Development
|
|
17
|
+
Classifier: Topic :: Utilities
|
|
18
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: PyYAML>=6.0
|
|
29
|
+
Requires-Dist: orjson>=3.9
|
|
30
|
+
Requires-Dist: tomli>=2.0; python_version < "3.11"
|
|
31
|
+
Provides-Extra: test
|
|
32
|
+
Requires-Dist: build>=1.2; extra == "test"
|
|
33
|
+
Requires-Dist: coverage>=7.6; extra == "test"
|
|
34
|
+
Requires-Dist: hypothesis>=6.100; extra == "test"
|
|
35
|
+
Requires-Dist: ruff>=0.9; extra == "test"
|
|
36
|
+
Requires-Dist: twine>=6; extra == "test"
|
|
37
|
+
Dynamic: license-file
|
|
38
|
+
|
|
39
|
+
# Diffora
|
|
40
|
+
|
|
41
|
+
[](https://github.com/hashpalace/ConfigDiff/actions/workflows/tests.yml)
|
|
42
|
+
[](https://www.python.org/)
|
|
43
|
+
[](LICENSE)
|
|
44
|
+
[](https://github.com/hashpalace/ConfigDiff/stargazers)
|
|
45
|
+
[](https://github.com/hashpalace/ConfigDiff/issues)
|
|
46
|
+
|
|
47
|
+
**Stop guessing what changed between configs.** Diffora compares parsed configuration values and pinpoints added, removed, and changed fields—even across different formats. Pass two directories to compare their file inventories and the semantic contents of supported configuration files.
|
|
48
|
+
|
|
49
|
+
JSON · YAML · TOML · `.env` · cross-format · directory comparison · secret-aware · CI-friendly
|
|
50
|
+
|
|
51
|
+
## Why Diffora?
|
|
52
|
+
|
|
53
|
+
- **Custom redaction:** add `--redact PATH` rules for sensitive fields that don't use conventional secret-like names.
|
|
54
|
+
|
|
55
|
+
If this saves you time, [star the repository](https://github.com/hashpalace/ConfigDiff) and share it with another developer. Stars help other people discover useful open-source tools.
|
|
56
|
+
|
|
57
|
+
- **Useful paths, not noisy text diffs:** `services[0].port` or `services[id="api"].port`.
|
|
58
|
+
- **Added and removed objects are expanded:** see the individual leaf values instead of one giant object blob.
|
|
59
|
+
- **Match lists by identity:** reordering services won't create fake changes when you use `--list-key id`.
|
|
60
|
+
- **Ignore known differences:** repeat `--ignore` or use glob patterns.
|
|
61
|
+
- **Safer output by default:** values under sensitive-looking keys are masked.
|
|
62
|
+
- **Automation-ready:** JSON output and predictable exit codes.
|
|
63
|
+
- **Safe by default:** comparisons never modify input files; the separate `apply` command only writes when explicitly requested.
|
|
64
|
+
|
|
65
|
+
## Install
|
|
66
|
+
|
|
67
|
+
From a local clone:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
python -m pip install .
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Or install the latest code directly from GitHub:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
python -m pip install "git+https://github.com/hashpalace/ConfigDiff.git"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Install from PyPI with `python -m pip install diffora`. Requires Python 3.9+. YAML parsing uses PyYAML. Python 3.9 and 3.10 use `tomli` for TOML; Python 3.11+ uses the standard library. The `configdiff` command remains available as a compatibility alias.
|
|
80
|
+
|
|
81
|
+
## Quick start
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
diffora old.json new.json
|
|
85
|
+
diffora config.yaml config.toml
|
|
86
|
+
diffora .env.production .env.staging
|
|
87
|
+
diffora ./config/staging ./config/production
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Example output:
|
|
91
|
+
|
|
92
|
+
```diff
|
|
93
|
+
+ database.pool_size = 20
|
|
94
|
+
- legacy_endpoint = "https://old.example"
|
|
95
|
+
~ database.port: 5432 -> 5433
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Try the included demo files from the repository root:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
diffora examples/before.json examples/after.json --summary
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Formats are detected from file extensions. For files with custom names, specify each format:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
diffora before.conf after.conf --left-format json --right-format yaml
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Supported format names: `json`, `yaml`, `toml`, `env`, and `dotenv`.
|
|
111
|
+
|
|
112
|
+
## Compare lists by a stable key
|
|
113
|
+
|
|
114
|
+
By default, lists are compared by index. If entries are objects with a stable identifier, match them by that field to avoid noisy diffs when entries are reordered. `--list-key` also accepts comma-separated nested fields, such as `apiVersion,kind,metadata.name` for Kubernetes resources. Alternatively, `--ignore-order` compares lists as multisets: order is ignored but duplicate counts still matter; unmatched changed objects are represented as removals and additions:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
diffora old.json new.json --list-key id
|
|
118
|
+
diffora old.json new.json --ignore-order
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
For example, changes are reported as `services[id="api"].port` instead of depending on the item's array position. The option applies to lists of objects that contain a unique, non-null value for the chosen key. If either list doesn't meet that requirement, Diffora safely falls back to index comparison. For Kubernetes resource lists, use --list-key kubernetes: resources are matched by apiVersion, kind, metadata.namespace, and metadata.name; a missing namespace is treated as the empty namespace for cluster-scoped resources.
|
|
122
|
+
|
|
123
|
+
## Ignore paths
|
|
124
|
+
|
|
125
|
+
Pass `--ignore` multiple times to skip fields that are expected to differ:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
diffora old.yaml new.yaml --ignore metadata.generated_at --ignore 'services[*].replicas'
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Patterns support `*` (any sequence of characters) and `?` (one character). An exact path also ignores its descendants. For example, ignoring `database` ignores all of `database.host`, `database.port`, and nested paths below it.
|
|
132
|
+
|
|
133
|
+
## JSON output and exit codes
|
|
134
|
+
|
|
135
|
+
Use machine-readable output in scripts. JSON output is a versioned object with `schema_version` and `differences`:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
diffora old.json new.json --format json
|
|
139
|
+
diffora old.json new.json --format json --summary
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Each difference includes `path`, `kind`, `old`, `new`, `old_missing`, and `new_missing`. The missing flags distinguish an absent value from an explicit JSON `null`. `kind` is `added`, `removed`, or `changed`. The output contract is documented in [`schemas/differences.schema.json`](schemas/differences.schema.json); consumers should check `schema_version` before relying on the structure.
|
|
143
|
+
|
|
144
|
+
| Exit code | Meaning |
|
|
145
|
+
| --- | --- |
|
|
146
|
+
| `0` | No differences found |
|
|
147
|
+
| `1` | Differences found |
|
|
148
|
+
| `2` | File, input, or parsing error |
|
|
149
|
+
|
|
150
|
+
For a concise text summary:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
diffora old.yaml new.yaml --summary
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Disable terminal colors with `--no-color`. Colors are otherwise enabled only when writing to an interactive terminal. Set the `NO_COLOR` environment variable to disable colors without changing your command.
|
|
157
|
+
|
|
158
|
+
## Stdin, quiet mode, and output formats
|
|
159
|
+
|
|
160
|
+
Read one side from a pipe by using `-` and explicitly setting that side's format. A single stdin stream can supply only one side per invocation.
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
cat new.json | diffora old.json - --right-format json
|
|
164
|
+
git show HEAD:config.yaml | diffora - config.yaml --left-format yaml
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
For CI checks that only need a pass/fail result, `--quiet` suppresses standard output while preserving exit codes: `0` means identical, `1` means differences, and `2` means an input or parsing error.
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
diffora production.yaml staging.yaml --quiet
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Choose `--format markdown` for a readable table in pull-request comments, `--format unified` for a unified diff of normalized paths and values, `--format github-actions` to emit escaped workflow warning annotations, or `--format json-patch` to generate an RFC 6902 patch:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
diffora before.json after.json --format markdown
|
|
177
|
+
diffora before.json after.json --format unified
|
|
178
|
+
diffora before.json after.json --format github-actions
|
|
179
|
+
diffora before.json after.json --format json-patch --show-secrets > patch.json
|
|
180
|
+
diffora apply patch.json before.json
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
A unified output is a semantic diff of parsed paths and values, not a byte-for-byte diff of the source files.
|
|
184
|
+
|
|
185
|
+
## `.env` parsing notes
|
|
186
|
+
|
|
187
|
+
Diffora handles common dotenv lines: `KEY=value`, optional `export KEY=value`, surrounding whitespace, blank values, quoted values, and comments. For quoted values, text after the closing quote must be whitespace or a comment. Values are compared literally: Diffora does not expand variables, execute shell syntax, or interpret escape sequences. Duplicate keys use the last value. This is a practical parser for comparison, not a full shell or dotenv runtime.
|
|
188
|
+
|
|
189
|
+
## Secret handling
|
|
190
|
+
|
|
191
|
+
Built-in key-name masking is best-effort. Add project-specific paths or glob patterns with repeatable `--redact`; for example:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
diffora before.yaml after.yaml --redact database.url --redact 'services[*].endpoint'
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Exact custom paths also mask their descendants. Glob patterns use `*` and `?`. These rules are added to the built-in sensitive-key detection. `--show-secrets` disables both built-in and custom masking, so use it deliberately.
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
Values whose path names look sensitive (such as `password`, `token`, `secret`, `api_key`, or `private_key`) are masked by default in both text and JSON output.
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
diffora old.env new.env
|
|
205
|
+
diffora old.env new.env --show-secrets
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Only use `--show-secrets` when you are certain the output won't be exposed. Redaction is a best-effort key-name safeguard, not a complete secret scanner.
|
|
209
|
+
|
|
210
|
+
## Input safety limits
|
|
211
|
+
|
|
212
|
+
Diffora reads at most 16 MiB from each input, including standard input. YAML aliases that create cyclic references are rejected; YAML structures are limited to 500,000 nodes and 256 levels of nesting. Direct calls to the Python `compare_values` API receive the same cycle, node-count, and nesting checks. These safeguards help avoid runaway recursion and excessive resource use when comparing untrusted configuration files. Parser diagnostics report the format and location when available without echoing source values.
|
|
213
|
+
|
|
214
|
+
## Custom redaction
|
|
215
|
+
|
|
216
|
+
Add project-specific paths or glob patterns when a sensitive field does not have an obvious secret-like name:
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
diffora before.yaml after.yaml --redact database.url --redact 'services[*].endpoint'
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Exact custom paths also mask descendants. Glob patterns support `*` and `?`. These rules are added to built-in sensitive-key detection and apply to text, JSON, Markdown, and unified output. `--show-secrets` disables both built-in and custom masking; use it deliberately.
|
|
223
|
+
|
|
224
|
+
## CLI reference
|
|
225
|
+
|
|
226
|
+
```text
|
|
227
|
+
diffora LEFT RIGHT [options]
|
|
228
|
+
|
|
229
|
+
Options:
|
|
230
|
+
--left-format FORMAT Format of the original file
|
|
231
|
+
--right-format FORMAT Format of the comparison file
|
|
232
|
+
--format FORMAT Output: text, json, markdown, unified, github-actions, or json-patch
|
|
233
|
+
--show-secrets Display values under sensitive-looking keys
|
|
234
|
+
--redact PATH Redact an additional path or glob pattern; repeatable
|
|
235
|
+
--no-color Disable ANSI colors
|
|
236
|
+
--list-key KEY Match lists by stable key; use kubernetes for Kubernetes manifests
|
|
237
|
+
--ignore-order Ignore list order while preserving duplicate counts
|
|
238
|
+
--ignore PATH Ignore a path or glob pattern; repeatable
|
|
239
|
+
--summary Print counts of added, removed, and changed values
|
|
240
|
+
-h, --help Show help
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
JSON Patch output may contain secret values needed to reproduce the target exactly, so `--format json-patch` requires the explicit `--show-secrets` opt-in. Review the patch before sharing it. `diffora apply PATCH.json TARGET.json` applies add/remove/replace operations to a JSON target file; the target is only rewritten after the patch validates and applies successfully. Small lists use bounded LCS matching to generate minimal add/remove operations; very large list comparisons fall back to replacing the array to keep patch generation predictable.
|
|
244
|
+
|
|
245
|
+
## Shell and CI examples
|
|
246
|
+
|
|
247
|
+
Fail a shell script when configuration values differ:
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
set -eu
|
|
251
|
+
diffora config.example.yaml config.production.yaml --quiet
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
GitHub Actions step that fails only when differences exist (parse errors also fail):
|
|
255
|
+
|
|
256
|
+
```yaml
|
|
257
|
+
- name: Check configuration drift
|
|
258
|
+
run: diffora config/staging.yaml config/production.yaml --quiet
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
If you want to show the differences in a CI log, omit `--quiet`; use `--format json` when a later step consumes the structured output.
|
|
262
|
+
|
|
263
|
+
## Development
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
git clone https://github.com/hashpalace/ConfigDiff.git
|
|
267
|
+
cd ConfigDiff
|
|
268
|
+
python -m pip install -e .
|
|
269
|
+
python -m unittest discover -s tests -v
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
GitHub Actions tests Python 3.9–3.13.
|
|
273
|
+
|
|
274
|
+
## Changelog
|
|
275
|
+
|
|
276
|
+
See [CHANGELOG.md](CHANGELOG.md) for version history.
|
|
277
|
+
|
|
278
|
+
## Real-world examples
|
|
279
|
+
|
|
280
|
+
The `examples/` directory includes a small service configuration comparison. Run `diffora examples/services-before.yaml examples/services-after.yaml --list-key id --summary` to see it. The `benchmarks/` directory contains paired 100-service JSON fixtures for repeatable performance checks; run `diffora benchmarks/before.json benchmarks/after.json --list-key id --summary`. To measure Diffora's core latency and enforce the 5 ms median budget, use `python -m benchmarks.bench_compare --runs 50 --budget-ms 5`. To compare the same synthetic changes against DeepDiff, jd, and diffyml, install those tools and run `python -m benchmarks.bench_competitors --runs 30 --require-all`. The cross-tool report separates in-process library timings from end-to-end CLI timings (including process startup and parsing); diffyml receives an equivalent YAML serialization of the same dataset. All values are synthetic and illustrative, not production configuration.
|
|
281
|
+
|
|
282
|
+
## Roadmap
|
|
283
|
+
|
|
284
|
+
Milestones and contribution ideas live in [ROADMAP.md](ROADMAP.md).
|
|
285
|
+
|
|
286
|
+
## Contributing
|
|
287
|
+
|
|
288
|
+
Bug reports, ideas, and pull requests are welcome. Please include a focused test for behavior changes and read [CONTRIBUTING.md](CONTRIBUTING.md) before submitting a PR.
|
|
289
|
+
|
|
290
|
+
## License
|
|
291
|
+
|
|
292
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
configdiff/__init__.py,sha256=tHxX1np6rIaX4KEV4ZvVFE6YYrw27xDmNqJfqX7ekU0,223
|
|
2
|
+
configdiff/cli.py,sha256=rLzLBHpI6HE18gSVM9xxx0IfpaOedUnyJgxmH82ONlI,10860
|
|
3
|
+
configdiff/core.py,sha256=jqngFh60uLax_b9kX4g7iDhUEhIvuyLieVARC4d5-Zw,25344
|
|
4
|
+
configdiff/patch.py,sha256=2KWQxTJIUGLCRJRQini7M8BI3vwvpNsGqhdIMbtJyik,5412
|
|
5
|
+
diffora-0.1.0.dist-info/licenses/LICENSE,sha256=l5keh_f-OrbRqd-U6EolmzK1KvXxaMLVz32svHjD2SM,1067
|
|
6
|
+
diffora-0.1.0.dist-info/METADATA,sha256=9nKuufE4tPQva8N-8rwr_CcJbCiJBM8N02ekMgnsqRE,14468
|
|
7
|
+
diffora-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
8
|
+
diffora-0.1.0.dist-info/entry_points.txt,sha256=jq9F1vo5L7bkZWD6HvaKHlQpn367HaZPU5SSvqjgohM,81
|
|
9
|
+
diffora-0.1.0.dist-info/top_level.txt,sha256=vsVJN11EiBLRQJ4LxI2g0a45_Mhp19YHyt2G4ArqPSs,11
|
|
10
|
+
diffora-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 HashPalace
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
configdiff
|