blendiff 0.2.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.
blendiff/__init__.py ADDED
@@ -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())
blendiff/cli/api.py ADDED
@@ -0,0 +1,281 @@
1
+ """
2
+ blendiff.cli.api
3
+ ~~~~~~~~~~~~~~~~~
4
+ Pure Python API for comparing snapshots outside Blender.
5
+
6
+ This module is the core of the headless/CI feature. It has:
7
+ - Zero bpy imports
8
+ - No argparse / CLI concerns
9
+ - No side effects — all functions return data, callers decide what to do
10
+
11
+ Intended usage:
12
+
13
+ # In a script or CI pipeline
14
+ from blendiff.cli.api import compare_snapshots, list_snapshots
15
+
16
+ snapshots = list_snapshots("my_scene.blendiff")
17
+ result = compare_snapshots("my_scene.blendiff", "snap_id_a", "snap_id_b")
18
+ print(result["summary"])
19
+
20
+ # Or by label instead of ID
21
+ result = compare_snapshots_by_label(
22
+ "my_scene.blendiff", "Last approved", "Current"
23
+ )
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import os
29
+ from typing import Optional
30
+
31
+ from ..storage.sidecar import SidecarManager, Snapshot
32
+ from ..diff_engine.diff_engine import DiffEngine
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Public API
37
+ # ---------------------------------------------------------------------------
38
+
39
+ def list_snapshots(sidecar_path: str) -> list[dict]:
40
+ """
41
+ List all snapshots in a .blendiff sidecar file.
42
+
43
+ Parameters
44
+ ----------
45
+ sidecar_path:
46
+ Path to the .blendiff file.
47
+
48
+ Returns
49
+ -------
50
+ list of dicts with keys: id, label, timestamp, scene_name
51
+ Data field is excluded for brevity.
52
+
53
+ Raises
54
+ ------
55
+ FileNotFoundError if sidecar_path does not exist.
56
+ """
57
+ _require_sidecar(sidecar_path)
58
+ mgr = _manager_for_sidecar(sidecar_path)
59
+ snapshots = mgr.list_snapshots()
60
+ return [
61
+ {
62
+ "id": s.id,
63
+ "label": s.label,
64
+ "timestamp": s.timestamp,
65
+ "timestamp_display": s.timestamp_display(),
66
+ "scene_name": s.scene_name,
67
+ }
68
+ for s in snapshots
69
+ ]
70
+
71
+
72
+ def compare_snapshots(
73
+ sidecar_path: str,
74
+ snapshot_id_a: str,
75
+ snapshot_id_b: str,
76
+ ) -> dict:
77
+ """
78
+ Diff two snapshots by UUID.
79
+
80
+ Parameters
81
+ ----------
82
+ sidecar_path:
83
+ Path to the .blendiff file.
84
+ snapshot_id_a:
85
+ UUID of the base snapshot (the "before").
86
+ snapshot_id_b:
87
+ UUID of the target snapshot (the "after").
88
+
89
+ Returns
90
+ -------
91
+ dict with keys:
92
+ summary — human-readable summary string
93
+ added_objects — list of names
94
+ removed_objects — list of names
95
+ modified_objects — list of {name, changes: [{property_path, old_value, new_value}]}
96
+ collection_diffs — list of {path, kind, changes}
97
+ has_changes — bool
98
+ snapshot_a — {id, label, timestamp}
99
+ snapshot_b — {id, label, timestamp}
100
+
101
+ Raises
102
+ ------
103
+ FileNotFoundError if sidecar_path does not exist.
104
+ ValueError if either snapshot ID is not found.
105
+ """
106
+ _require_sidecar(sidecar_path)
107
+ mgr = _manager_for_sidecar(sidecar_path)
108
+
109
+ snap_a = mgr.get_snapshot(snapshot_id_a)
110
+ snap_b = mgr.get_snapshot(snapshot_id_b)
111
+
112
+ if snap_a is None:
113
+ raise ValueError(f"Snapshot not found: {snapshot_id_a}")
114
+ if snap_b is None:
115
+ raise ValueError(f"Snapshot not found: {snapshot_id_b}")
116
+
117
+ return _run_diff(snap_a, snap_b)
118
+
119
+
120
+ def compare_snapshots_by_label(
121
+ sidecar_path: str,
122
+ label_a: str,
123
+ label_b: str,
124
+ ) -> dict:
125
+ """
126
+ Diff two snapshots by label.
127
+
128
+ If multiple snapshots share a label, the most recent one is used.
129
+
130
+ Parameters
131
+ ----------
132
+ sidecar_path:
133
+ Path to the .blendiff file.
134
+ label_a:
135
+ Label of the base snapshot.
136
+ label_b:
137
+ Label of the target snapshot.
138
+
139
+ Returns
140
+ -------
141
+ Same structure as compare_snapshots().
142
+
143
+ Raises
144
+ ------
145
+ FileNotFoundError if sidecar_path does not exist.
146
+ ValueError if either label is not found.
147
+ """
148
+ _require_sidecar(sidecar_path)
149
+ mgr = _manager_for_sidecar(sidecar_path)
150
+
151
+ # list_snapshots() returns newest-first, so first match = most recent
152
+ all_snapshots = mgr.list_snapshots()
153
+
154
+ snap_a = _find_by_label(all_snapshots, label_a)
155
+ snap_b = _find_by_label(all_snapshots, label_b)
156
+
157
+ if snap_a is None:
158
+ raise ValueError(f"No snapshot found with label: '{label_a}'")
159
+ if snap_b is None:
160
+ raise ValueError(f"No snapshot found with label: '{label_b}'")
161
+
162
+ return _run_diff(snap_a, snap_b)
163
+
164
+
165
+ def compare_latest_two(sidecar_path: str) -> dict:
166
+ """
167
+ Diff the two most recent snapshots.
168
+
169
+ Useful for CI: "what changed since the last snapshot?"
170
+
171
+ Raises
172
+ ------
173
+ FileNotFoundError if sidecar_path does not exist.
174
+ ValueError if fewer than 2 snapshots exist.
175
+ """
176
+ _require_sidecar(sidecar_path)
177
+ mgr = _manager_for_sidecar(sidecar_path)
178
+ snapshots = mgr.list_snapshots() # newest first
179
+
180
+ if len(snapshots) < 2:
181
+ raise ValueError(
182
+ f"Need at least 2 snapshots to diff, found {len(snapshots)}."
183
+ )
184
+
185
+ # newest = snapshots[0], second-newest = snapshots[1]
186
+ # We diff second-newest → newest (chronological order)
187
+ return _run_diff(snapshots[1], snapshots[0])
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # Internal helpers
192
+ # ---------------------------------------------------------------------------
193
+
194
+ def _manager_for_sidecar(sidecar_path: str) -> SidecarManager:
195
+ """
196
+ Build a SidecarManager from a direct sidecar path.
197
+
198
+ SidecarManager normally derives the sidecar path from a .blend path.
199
+ Here we already have the sidecar path, so we reconstruct a fake blend
200
+ path just for the manager's internal use — the actual file it reads
201
+ is set directly.
202
+ """
203
+ # Derive a synthetic blend path so SidecarManager is satisfied
204
+ base = sidecar_path
205
+ if base.endswith(".blendiff"):
206
+ base = base[: -len(".blendiff")]
207
+ blend_path = base + ".blend"
208
+
209
+ mgr = SidecarManager(blend_path)
210
+ # Override the sidecar path to use the actual file given
211
+ mgr._sidecar_path = sidecar_path
212
+ return mgr
213
+
214
+
215
+ def _require_sidecar(sidecar_path: str) -> None:
216
+ if not os.path.exists(sidecar_path):
217
+ raise FileNotFoundError(f"Sidecar file not found: {sidecar_path}")
218
+
219
+
220
+ def _find_by_label(snapshots: list[Snapshot], label: str) -> Optional[Snapshot]:
221
+ """Return the first (most recent) snapshot matching label."""
222
+ for s in snapshots:
223
+ if s.label == label:
224
+ return s
225
+ return None
226
+
227
+
228
+ def _run_diff(snap_a: Snapshot, snap_b: Snapshot) -> dict:
229
+ """Run DiffEngine on two Snapshot objects and return a result dict."""
230
+ engine = DiffEngine()
231
+ diff = engine.compare(snap_a.data, snap_b.data)
232
+ s = diff.summary()
233
+
234
+ return {
235
+ "summary": (
236
+ f"Added: {s['added']} Removed: {s['removed']} "
237
+ f"Modified: {s['modified']} Collections: {s['collection_changes']}"
238
+ ),
239
+ "has_changes": diff.has_changes,
240
+ "added_objects": [o.name for o in diff.added_objects],
241
+ "removed_objects": [o.name for o in diff.removed_objects],
242
+ "modified_objects": [
243
+ {
244
+ "name": o.name,
245
+ "changes": [
246
+ {
247
+ "property_path": c.property_path,
248
+ "old_value": c.old_value,
249
+ "new_value": c.new_value,
250
+ }
251
+ for c in o.changes
252
+ ],
253
+ }
254
+ for o in diff.modified_objects
255
+ ],
256
+ "collection_diffs": [
257
+ {
258
+ "path": cd.path,
259
+ "kind": cd.kind.value,
260
+ "changes": [
261
+ {
262
+ "property_path": c.property_path,
263
+ "old_value": c.old_value,
264
+ "new_value": c.new_value,
265
+ }
266
+ for c in cd.changes
267
+ ],
268
+ }
269
+ for cd in diff.collection_diffs
270
+ ],
271
+ "snapshot_a": {
272
+ "id": snap_a.id,
273
+ "label": snap_a.label,
274
+ "timestamp": snap_a.timestamp_display(),
275
+ },
276
+ "snapshot_b": {
277
+ "id": snap_b.id,
278
+ "label": snap_b.label,
279
+ "timestamp": snap_b.timestamp_display(),
280
+ },
281
+ }
@@ -0,0 +1,13 @@
1
+ from .scene import (
2
+ Transform, MaterialSlot, SceneObject, CollectionNode, SerializedScene,
3
+ )
4
+ from .diff import (
5
+ ChangeKind, PropertyChange, ObjectDiff, CollectionDiff, SceneDiff,
6
+ )
7
+ __all__ = [
8
+ "Transform", "MaterialSlot", "SceneObject", "CollectionNode",
9
+ "SerializedScene",
10
+ "RenderDiff",
11
+ "ChangeKind", "PropertyChange", "ObjectDiff", "CollectionDiff",
12
+ "SceneDiff",
13
+ ]