blendiff 0.2.0__tar.gz

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.
Files changed (46) hide show
  1. blendiff-0.2.0/.gitignore +34 -0
  2. blendiff-0.2.0/LICENSE +21 -0
  3. blendiff-0.2.0/PKG-INFO +147 -0
  4. blendiff-0.2.0/README.md +120 -0
  5. blendiff-0.2.0/blendiff/__init__.py +40 -0
  6. blendiff-0.2.0/blendiff/cli/__init__.py +6 -0
  7. blendiff-0.2.0/blendiff/cli/__main__.py +214 -0
  8. blendiff-0.2.0/blendiff/cli/api.py +281 -0
  9. blendiff-0.2.0/blendiff/data_model/__init__.py +13 -0
  10. blendiff-0.2.0/blendiff/data_model/conflict.py +196 -0
  11. blendiff-0.2.0/blendiff/data_model/diff.py +109 -0
  12. blendiff-0.2.0/blendiff/data_model/material.py +139 -0
  13. blendiff-0.2.0/blendiff/data_model/scene.py +131 -0
  14. blendiff-0.2.0/blendiff/diff_engine/__init__.py +2 -0
  15. blendiff-0.2.0/blendiff/diff_engine/diff_engine.py +249 -0
  16. blendiff-0.2.0/blendiff/diff_engine/material_diff.py +256 -0
  17. blendiff-0.2.0/blendiff/diff_engine/render_diff.py +84 -0
  18. blendiff-0.2.0/blendiff/export/__init__.py +1 -0
  19. blendiff-0.2.0/blendiff/export/html_exporter.py +547 -0
  20. blendiff-0.2.0/blendiff/extractor/__init__.py +2 -0
  21. blendiff-0.2.0/blendiff/extractor/material_extractor.py +185 -0
  22. blendiff-0.2.0/blendiff/extractor/render_extractor.py +83 -0
  23. blendiff-0.2.0/blendiff/extractor/scene_extractor.py +173 -0
  24. blendiff-0.2.0/blendiff/merge_engine/__init__.py +2 -0
  25. blendiff-0.2.0/blendiff/merge_engine/applier.py +252 -0
  26. blendiff-0.2.0/blendiff/merge_engine/merge_engine.py +305 -0
  27. blendiff-0.2.0/blendiff/serializer/__init__.py +2 -0
  28. blendiff-0.2.0/blendiff/serializer/scene_serializer.py +150 -0
  29. blendiff-0.2.0/blendiff/storage/__init__.py +1 -0
  30. blendiff-0.2.0/blendiff/storage/sidecar.py +243 -0
  31. blendiff-0.2.0/blendiff/ui/__init__.py +11 -0
  32. blendiff-0.2.0/blendiff/ui/merge_panel.py +219 -0
  33. blendiff-0.2.0/blendiff/ui/operators.py +580 -0
  34. blendiff-0.2.0/blendiff/ui/panels.py +257 -0
  35. blendiff-0.2.0/docs/architecture.md +181 -0
  36. blendiff-0.2.0/docs/ci_example.yml +78 -0
  37. blendiff-0.2.0/pyproject.toml +60 -0
  38. blendiff-0.2.0/tests/test_cli_api.py +381 -0
  39. blendiff-0.2.0/tests/test_data_model.py +66 -0
  40. blendiff-0.2.0/tests/test_diff_engine.py +242 -0
  41. blendiff-0.2.0/tests/test_html_exporter.py +294 -0
  42. blendiff-0.2.0/tests/test_material_diff.py +393 -0
  43. blendiff-0.2.0/tests/test_merge_engine.py +432 -0
  44. blendiff-0.2.0/tests/test_render_diff.py +385 -0
  45. blendiff-0.2.0/tests/test_serializer.py +168 -0
  46. blendiff-0.2.0/tests/test_sidecar.py +256 -0
@@ -0,0 +1,34 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+
8
+ # Packaging
9
+ dist/
10
+ build/
11
+ *.egg-info/
12
+ *.egg
13
+ MANIFEST
14
+
15
+ # PyPI / Hatch
16
+ .hatch/
17
+
18
+ # Testing
19
+ .pytest_cache/
20
+ .coverage
21
+ htmlcov/
22
+
23
+ # Blender
24
+ blendiff.zip
25
+ *.blend1
26
+ *.blend2
27
+
28
+ # OS
29
+ .DS_Store
30
+ Thumbs.db
31
+
32
+ # IDE
33
+ .vscode/
34
+ .idea/
blendiff-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vishrut
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,147 @@
1
+ Metadata-Version: 2.4
2
+ Name: blendiff
3
+ Version: 0.2.0
4
+ Summary: Semantic diff, snapshot history, and assisted merge for Blender .blend files
5
+ Project-URL: Homepage, https://github.com/Vishrut2403/blendiff
6
+ Project-URL: Repository, https://github.com/Vishrut2403/blendiff
7
+ Project-URL: Bug Tracker, https://github.com/Vishrut2403/blendiff/issues
8
+ Author: Vishrut
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: 3d,addon,blender,diff,pipeline,vfx
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Multimedia :: Graphics :: 3D Modeling
21
+ Classifier: Topic :: Software Development :: Version Control
22
+ Requires-Python: >=3.10
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest-cov; extra == 'dev'
25
+ Requires-Dist: pytest>=7.0; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # BlenDiff
29
+
30
+ **Semantic diff, snapshot history, and assisted merge for Blender `.blend` files.**
31
+
32
+ BlenDiff compares two `.blend` file states using Blender's Python API — not binary diffing — and provides a full assisted merge system. It ships as both a Blender addon and a pip-installable pure-Python library for headless/CI use.
33
+
34
+ ---
35
+
36
+ ## Features
37
+
38
+ - **Snapshot history** — named, timestamped snapshots stored in a human-readable `.blendiff` JSON sidecar next to your `.blend` file
39
+ - **Object & collection diffing** — detects added, removed, and modified objects with per-property change tracking (transform, visibility, collection membership)
40
+ - **Material node graph diffing** — per-node, per-socket comparison including image names, input values, and rewired links
41
+ - **Render settings diffing** — engine, resolution, sampling, output format, color management, Cycles and EEVEE sub-settings
42
+ - **Three-way merge** — conflict detection and per-property resolution (Use A / Use B / Use Base) with a Blender UI
43
+ - **Annotated HTML export** — self-contained dark-themed report with per-entry annotation textareas and JSON round-trip
44
+ - **Headless CLI** — run diffs in CI without launching Blender
45
+
46
+ ---
47
+
48
+ ## Installation
49
+
50
+ ### As a pip library (no Blender required)
51
+
52
+ ```bash
53
+ pip install blendiff
54
+ ```
55
+
56
+ ### As a Blender addon
57
+
58
+ Download the latest `blendiff.zip` from [Releases](https://github.com/Vishrut2403/blendiff/releases) and install via **Edit → Preferences → Add-ons → Install**.
59
+
60
+ ---
61
+
62
+ ## CLI Usage
63
+
64
+ ```bash
65
+ # List snapshots in a sidecar file
66
+ blendiff list scene.blendiff
67
+
68
+ # Compare two snapshots
69
+ blendiff compare scene.blendiff "Before rigging" "After rigging"
70
+
71
+ # Fail CI if any changes exist
72
+ blendiff latest scene.blendiff --fail-on-changes
73
+
74
+ # Export an HTML report
75
+ blendiff compare scene.blendiff "v1" "v2" --output report.html
76
+ ```
77
+
78
+ ---
79
+
80
+ ## Python API
81
+
82
+ ```python
83
+ from blendiff.storage.sidecar import SidecarManager
84
+ from blendiff.diff_engine.diff_engine import DiffEngine
85
+
86
+ mgr = SidecarManager("scene.blendiff")
87
+ snaps = {s.label: s for s in mgr.list_snapshots()}
88
+
89
+ engine = DiffEngine()
90
+ result = engine.compare(snaps["v1"].data, snaps["v2"].data)
91
+
92
+ # Render settings diff
93
+ print(result.render_diff.summary())
94
+
95
+ # Object diffs
96
+ for diff in result.object_diffs:
97
+ print(diff.name, diff.kind)
98
+ for change in diff.changes:
99
+ print(" ", change.property_path, change.old_value, "→", change.new_value)
100
+ ```
101
+
102
+ ---
103
+
104
+ ## Architecture
105
+
106
+ ```
107
+ blendiff/
108
+ ├── data_model/ # Dataclasses — SceneDiff, RenderDiff, MergeProposal, …
109
+ ├── diff_engine/ # Pure comparison logic — no bpy
110
+ ├── serializer/ # mathutils → JSON-safe types
111
+ ├── storage/ # .blendiff sidecar CRUD
112
+ ├── export/ # Self-contained HTML report generation
113
+ ├── cli/ # Headless CLI + importable Python API
114
+ ├── extractor/ # bpy readers (Blender-only)
115
+ └── ui/ # Blender panels and operators (Blender-only)
116
+ ```
117
+
118
+ The extractor is the **only** bpy-touching module. Everything downstream is pure Python and fully testable without Blender.
119
+
120
+ ---
121
+
122
+ ## Running Tests
123
+
124
+ ```bash
125
+ pip install blendiff[dev]
126
+ pytest tests/ -v
127
+ ```
128
+
129
+ 224+ tests, all passing without a Blender installation.
130
+
131
+ ---
132
+
133
+ ## CI Integration
134
+
135
+ ```yaml
136
+ # .github/workflows/diff.yml
137
+ - name: Check for scene changes
138
+ run: blendiff latest scene.blendiff --fail-on-changes
139
+ ```
140
+
141
+ See `docs/ci_example.yml` for a full GitHub Actions workflow template.
142
+
143
+ ---
144
+
145
+ ## License
146
+
147
+ MIT
@@ -0,0 +1,120 @@
1
+ # BlenDiff
2
+
3
+ **Semantic diff, snapshot history, and assisted merge for Blender `.blend` files.**
4
+
5
+ BlenDiff compares two `.blend` file states using Blender's Python API — not binary diffing — and provides a full assisted merge system. It ships as both a Blender addon and a pip-installable pure-Python library for headless/CI use.
6
+
7
+ ---
8
+
9
+ ## Features
10
+
11
+ - **Snapshot history** — named, timestamped snapshots stored in a human-readable `.blendiff` JSON sidecar next to your `.blend` file
12
+ - **Object & collection diffing** — detects added, removed, and modified objects with per-property change tracking (transform, visibility, collection membership)
13
+ - **Material node graph diffing** — per-node, per-socket comparison including image names, input values, and rewired links
14
+ - **Render settings diffing** — engine, resolution, sampling, output format, color management, Cycles and EEVEE sub-settings
15
+ - **Three-way merge** — conflict detection and per-property resolution (Use A / Use B / Use Base) with a Blender UI
16
+ - **Annotated HTML export** — self-contained dark-themed report with per-entry annotation textareas and JSON round-trip
17
+ - **Headless CLI** — run diffs in CI without launching Blender
18
+
19
+ ---
20
+
21
+ ## Installation
22
+
23
+ ### As a pip library (no Blender required)
24
+
25
+ ```bash
26
+ pip install blendiff
27
+ ```
28
+
29
+ ### As a Blender addon
30
+
31
+ Download the latest `blendiff.zip` from [Releases](https://github.com/Vishrut2403/blendiff/releases) and install via **Edit → Preferences → Add-ons → Install**.
32
+
33
+ ---
34
+
35
+ ## CLI Usage
36
+
37
+ ```bash
38
+ # List snapshots in a sidecar file
39
+ blendiff list scene.blendiff
40
+
41
+ # Compare two snapshots
42
+ blendiff compare scene.blendiff "Before rigging" "After rigging"
43
+
44
+ # Fail CI if any changes exist
45
+ blendiff latest scene.blendiff --fail-on-changes
46
+
47
+ # Export an HTML report
48
+ blendiff compare scene.blendiff "v1" "v2" --output report.html
49
+ ```
50
+
51
+ ---
52
+
53
+ ## Python API
54
+
55
+ ```python
56
+ from blendiff.storage.sidecar import SidecarManager
57
+ from blendiff.diff_engine.diff_engine import DiffEngine
58
+
59
+ mgr = SidecarManager("scene.blendiff")
60
+ snaps = {s.label: s for s in mgr.list_snapshots()}
61
+
62
+ engine = DiffEngine()
63
+ result = engine.compare(snaps["v1"].data, snaps["v2"].data)
64
+
65
+ # Render settings diff
66
+ print(result.render_diff.summary())
67
+
68
+ # Object diffs
69
+ for diff in result.object_diffs:
70
+ print(diff.name, diff.kind)
71
+ for change in diff.changes:
72
+ print(" ", change.property_path, change.old_value, "→", change.new_value)
73
+ ```
74
+
75
+ ---
76
+
77
+ ## Architecture
78
+
79
+ ```
80
+ blendiff/
81
+ ├── data_model/ # Dataclasses — SceneDiff, RenderDiff, MergeProposal, …
82
+ ├── diff_engine/ # Pure comparison logic — no bpy
83
+ ├── serializer/ # mathutils → JSON-safe types
84
+ ├── storage/ # .blendiff sidecar CRUD
85
+ ├── export/ # Self-contained HTML report generation
86
+ ├── cli/ # Headless CLI + importable Python API
87
+ ├── extractor/ # bpy readers (Blender-only)
88
+ └── ui/ # Blender panels and operators (Blender-only)
89
+ ```
90
+
91
+ The extractor is the **only** bpy-touching module. Everything downstream is pure Python and fully testable without Blender.
92
+
93
+ ---
94
+
95
+ ## Running Tests
96
+
97
+ ```bash
98
+ pip install blendiff[dev]
99
+ pytest tests/ -v
100
+ ```
101
+
102
+ 224+ tests, all passing without a Blender installation.
103
+
104
+ ---
105
+
106
+ ## CI Integration
107
+
108
+ ```yaml
109
+ # .github/workflows/diff.yml
110
+ - name: Check for scene changes
111
+ run: blendiff latest scene.blendiff --fail-on-changes
112
+ ```
113
+
114
+ See `docs/ci_example.yml` for a full GitHub Actions workflow template.
115
+
116
+ ---
117
+
118
+ ## License
119
+
120
+ MIT
@@ -0,0 +1,40 @@
1
+ """
2
+ BlenDiff — semantic diff, snapshot history, and assisted merge for .blend files.
3
+
4
+ When imported outside Blender (e.g. via pip), only the pure-Python core is
5
+ available: data_model, diff_engine, serializer, storage, export, cli.
6
+
7
+ When imported inside Blender, the full addon registers its UI panels and
8
+ operators on top of the core.
9
+ """
10
+
11
+ __version__ = "0.2.0"
12
+
13
+ bl_info = {
14
+ "name": "BlenDiff",
15
+ "author": "Vishrut",
16
+ "version": (0, 2, 0),
17
+ "blender": (3, 6, 0),
18
+ "location": "3D Viewport > Sidebar > BlenDiff",
19
+ "description": "Semantic scene diff, snapshot history, and assisted merge for .blend files",
20
+ "category": "Scene",
21
+ }
22
+
23
+ try:
24
+ import bpy
25
+ _IN_BLENDER = True
26
+ except ModuleNotFoundError:
27
+ _IN_BLENDER = False
28
+
29
+ if _IN_BLENDER:
30
+ from .ui import panels, operators, merge_panel
31
+
32
+ def register() -> None:
33
+ operators.register()
34
+ panels.register()
35
+ merge_panel.register()
36
+
37
+ def unregister() -> None:
38
+ merge_panel.unregister()
39
+ panels.unregister()
40
+ operators.unregister()
@@ -0,0 +1,6 @@
1
+ from .api import (
2
+ list_snapshots,
3
+ compare_snapshots,
4
+ compare_snapshots_by_label,
5
+ compare_latest_two,
6
+ )
@@ -0,0 +1,214 @@
1
+ """
2
+ blendiff.cli.__main__
3
+ ~~~~~~~~~~~~~~~~~~~~~~
4
+ Entry point for: python -m blendiff.cli <command> [args]
5
+
6
+ Commands
7
+ --------
8
+ list <sidecar> List all snapshots in a sidecar
9
+ compare <sidecar> <label_a> <label_b> Diff two snapshots by label
10
+ latest <sidecar> Diff the two most recent snapshots
11
+
12
+ Global flags
13
+ ------------
14
+ --output <path> Write HTML report to this path (default: print JSON)
15
+ --json Print result as JSON to stdout
16
+ --fail-on-changes Exit with code 1 if any changes detected (for CI gates)
17
+ --quiet Suppress all output except errors
18
+
19
+ Exit codes
20
+ ----------
21
+ 0 — success, no changes (or changes detected but --fail-on-changes not set)
22
+ 1 — changes detected and --fail-on-changes is set
23
+ 2 — error (file not found, snapshot not found, parse failure, etc.)
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import json
30
+ import os
31
+ import sys
32
+
33
+
34
+ def _build_parser() -> argparse.ArgumentParser:
35
+ parser = argparse.ArgumentParser(
36
+ prog="python -m blendiff.cli",
37
+ description="BlenDiff headless CLI — diff Blender scene snapshots without opening Blender.",
38
+ )
39
+
40
+ sub = parser.add_subparsers(dest="command", metavar="COMMAND")
41
+ sub.required = True
42
+
43
+ # ── list ──────────────────────────────────────────────────────────────
44
+ p_list = sub.add_parser("list", help="List all snapshots in a .blendiff sidecar")
45
+ p_list.add_argument("sidecar", help="Path to the .blendiff file")
46
+ p_list.add_argument("--json", dest="as_json", action="store_true",
47
+ help="Output as JSON")
48
+
49
+ # ── compare ───────────────────────────────────────────────────────────
50
+ p_compare = sub.add_parser(
51
+ "compare",
52
+ help="Diff two snapshots by label (most recent match used if duplicates exist)",
53
+ )
54
+ p_compare.add_argument("sidecar", help="Path to the .blendiff file")
55
+ p_compare.add_argument("label_a", help="Label of the base snapshot (before)")
56
+ p_compare.add_argument("label_b", help="Label of the target snapshot (after)")
57
+ p_compare.add_argument("--output", metavar="PATH",
58
+ help="Write HTML report to this path")
59
+ p_compare.add_argument("--json", dest="as_json", action="store_true",
60
+ help="Print result as JSON to stdout")
61
+ p_compare.add_argument("--fail-on-changes", action="store_true",
62
+ help="Exit with code 1 if changes are detected")
63
+ p_compare.add_argument("--quiet", action="store_true",
64
+ help="Suppress output except errors")
65
+
66
+ # ── latest ────────────────────────────────────────────────────────────
67
+ p_latest = sub.add_parser(
68
+ "latest",
69
+ help="Diff the two most recent snapshots",
70
+ )
71
+ p_latest.add_argument("sidecar", help="Path to the .blendiff file")
72
+ p_latest.add_argument("--output", metavar="PATH",
73
+ help="Write HTML report to this path")
74
+ p_latest.add_argument("--json", dest="as_json", action="store_true",
75
+ help="Print result as JSON to stdout")
76
+ p_latest.add_argument("--fail-on-changes", action="store_true",
77
+ help="Exit with code 1 if changes are detected")
78
+ p_latest.add_argument("--quiet", action="store_true",
79
+ help="Suppress output except errors")
80
+
81
+ return parser
82
+
83
+
84
+ def _cmd_list(args) -> int:
85
+ from .api import list_snapshots
86
+
87
+ try:
88
+ snapshots = list_snapshots(args.sidecar)
89
+ except FileNotFoundError as e:
90
+ print(f"Error: {e}", file=sys.stderr)
91
+ return 2
92
+
93
+ if args.as_json:
94
+ print(json.dumps(snapshots, indent=2))
95
+ return 0
96
+
97
+ if not snapshots:
98
+ print("No snapshots found.")
99
+ return 0
100
+
101
+ print(f"{'#':<4} {'Label':<30} {'Timestamp':<22} {'ID'}")
102
+ print("-" * 80)
103
+ for i, s in enumerate(snapshots, 1):
104
+ print(f"{i:<4} {s['label']:<30} {s['timestamp_display']:<22} {s['id'][:8]}")
105
+
106
+ return 0
107
+
108
+
109
+ def _cmd_compare(args, use_latest: bool = False) -> int:
110
+ from .api import compare_snapshots_by_label, compare_latest_two
111
+ from ..export.html_exporter import export_to_file, build_output_path
112
+
113
+ quiet = getattr(args, "quiet", False)
114
+
115
+ try:
116
+ if use_latest:
117
+ result = compare_latest_two(args.sidecar)
118
+ else:
119
+ result = compare_snapshots_by_label(
120
+ args.sidecar, args.label_a, args.label_b
121
+ )
122
+ except FileNotFoundError as e:
123
+ print(f"Error: {e}", file=sys.stderr)
124
+ return 2
125
+ except ValueError as e:
126
+ print(f"Error: {e}", file=sys.stderr)
127
+ return 2
128
+
129
+ # JSON output
130
+ if getattr(args, "as_json", False):
131
+ print(json.dumps(result, indent=2, default=str))
132
+
133
+ # HTML output
134
+ elif getattr(args, "output", None):
135
+ output_path = args.output
136
+ snapshot_label = (
137
+ f"{result['snapshot_a']['label']} → {result['snapshot_b']['label']}"
138
+ )
139
+ export_to_file(
140
+ result=result,
141
+ snapshot_label=snapshot_label,
142
+ blend_filepath=args.sidecar,
143
+ output_path=output_path,
144
+ )
145
+ if not quiet:
146
+ print(f"HTML report written to: {output_path}")
147
+
148
+ # Default: human-readable summary
149
+ else:
150
+ if not quiet:
151
+ snap_a = result["snapshot_a"]
152
+ snap_b = result["snapshot_b"]
153
+ print(f"\nBlenDiff — comparing snapshots")
154
+ print(f" Before : '{snap_a['label']}' ({snap_a['timestamp']})")
155
+ print(f" After : '{snap_b['label']}' ({snap_b['timestamp']})")
156
+ print(f"\n {result['summary']}\n")
157
+
158
+ added = result["added_objects"]
159
+ removed = result["removed_objects"]
160
+ modified = result["modified_objects"]
161
+ col_diffs = result["collection_diffs"]
162
+
163
+ if added:
164
+ print(f" Added objects ({len(added)}):")
165
+ for name in added:
166
+ print(f" + {name}")
167
+
168
+ if removed:
169
+ print(f" Removed objects ({len(removed)}):")
170
+ for name in removed:
171
+ print(f" - {name}")
172
+
173
+ if modified:
174
+ print(f" Modified objects ({len(modified)}):")
175
+ for obj in modified:
176
+ print(f" ~ {obj['name']}")
177
+ for c in obj["changes"]:
178
+ print(f" {c['property_path']}")
179
+ print(f" {c['old_value']} → {c['new_value']}")
180
+
181
+ if col_diffs:
182
+ print(f" Collection changes ({len(col_diffs)}):")
183
+ for cd in col_diffs:
184
+ print(f" {cd['kind'].capitalize()}: {cd['path']}")
185
+
186
+ if not result["has_changes"]:
187
+ print(" No changes detected.")
188
+
189
+ print()
190
+
191
+ # Exit code
192
+ if getattr(args, "fail_on_changes", False) and result["has_changes"]:
193
+ return 1
194
+
195
+ return 0
196
+
197
+
198
+ def main(argv=None) -> int:
199
+ parser = _build_parser()
200
+ args = parser.parse_args(argv)
201
+
202
+ if args.command == "list":
203
+ return _cmd_list(args)
204
+ elif args.command == "compare":
205
+ return _cmd_compare(args, use_latest=False)
206
+ elif args.command == "latest":
207
+ return _cmd_compare(args, use_latest=True)
208
+
209
+ parser.print_help()
210
+ return 2
211
+
212
+
213
+ if __name__ == "__main__":
214
+ sys.exit(main())