davinci-resolve-mcp 2.67.0 → 2.68.0

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.
@@ -0,0 +1,375 @@
1
+ """Differential qualification for the in-app bridge: two transports, one answer.
2
+
3
+ The bridge is a *transport*. It is correct exactly when a call through it is
4
+ indistinguishable from the same call over Blackmagic's own scripting API. That is
5
+ a property you can test mechanically rather than argue about, and this module is
6
+ the machinery for it:
7
+
8
+ 1. **Enumerate** the Resolve API surface the MCP tools actually call, by
9
+ intersecting the documented API with what the source really invokes. Testing
10
+ the whole documented API would waste effort on methods we never reach; testing
11
+ an ad-hoc list would miss the ones we do.
12
+ 2. **Plan** a call for each method that can be driven without side effects, by
13
+ walking the live object graph from `resolve` outwards.
14
+ 3. **Normalise** each result into a comparable form — live objects become their
15
+ shape, not their address, since two transports necessarily mint different
16
+ handles for the same object.
17
+ 4. **Diff** the two runs and report, including what could *not* be exercised and
18
+ why. Silent coverage gaps read as "everything passed".
19
+
20
+ ## Why the comparison has to run against one Resolve
21
+
22
+ External scripting is Studio-gated, so a free edition can only be reached through
23
+ the bridge — there is no native side to diff against. But Studio speaks *both*,
24
+ so the differential runs there: one application, one project, one object graph,
25
+ two ways in. Anything that differs is the transport's doing, which is the whole
26
+ question. On the free edition the same plan runs bridge-only, which proves
27
+ reachability and shape rather than equivalence.
28
+
29
+ ## What "identical" has to tolerate
30
+
31
+ Nothing structural. Handles differ by construction and are normalised away.
32
+ Everything else — types, values, ordering, None-vs-False — is compared exactly,
33
+ because every one of those distinctions has been a real bug in this codebase:
34
+ False-means-failure, None-means-absent, and ordering that a caller relies on.
35
+
36
+ A small set of readings are genuinely time-varying (playhead position, render
37
+ progress). Those are compared by *type only*, and each one is named here rather
38
+ than pattern-matched, so the exemption list stays auditable.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import ast
44
+ import re
45
+ from pathlib import Path
46
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple
47
+
48
+ REPO_ROOT = Path(__file__).resolve().parent.parent.parent
49
+ API_DOCS = REPO_ROOT / "docs" / "reference" / "resolve_scripting_api.txt"
50
+ SOURCE_ROOTS = ("src",)
51
+
52
+ #: Readings that legitimately differ between two calls a moment apart. Compared
53
+ #: by type instead of value. Named individually — a regex over "Timecode" or
54
+ #: "Progress" would quietly excuse a real difference in something else.
55
+ VOLATILE_METHODS = frozenset({
56
+ "GetCurrentTimecode",
57
+ "GetCurrentVideoItem",
58
+ "GetCurrentVideoTimecode",
59
+ "IsRenderingInProgress",
60
+ "GetRenderJobStatus",
61
+ "IsPlayheadOnVideoFrame",
62
+ })
63
+
64
+ #: Never invoked by the harness, whatever the enumeration says. These either
65
+ #: change the project, touch the filesystem, or block on UI. The write paths are
66
+ #: exercised by named scenarios instead, where the setup and teardown are
67
+ #: explicit and a failure can be cleaned up.
68
+ _UNSAFE_VERBS = (
69
+ "Delete", "Remove", "Create", "Add", "Set", "Import", "Export", "Save",
70
+ "Load", "Append", "Insert", "Paste", "Copy", "Duplicate", "Move", "Render",
71
+ "Start", "Stop", "Open", "Close", "Quit", "Restore", "Archive", "Refresh",
72
+ "Transcribe", "Apply", "Link", "Unlink", "Relink", "Replace", "Convert",
73
+ "Detect", "Generate", "Auto", "Reset", "Clear", "Enable", "Disable",
74
+ )
75
+
76
+ _CLASS_HEADING_RE = re.compile(r"^([A-Z][A-Za-z]+)\s*$")
77
+ _METHOD_RE = re.compile(r"^\s+([A-Z][A-Za-z0-9_]+)\s*\(")
78
+
79
+
80
+ # ── 1. what the tools actually call ──────────────────────────────────────────
81
+
82
+
83
+ def documented_methods(docs_path: Path = API_DOCS) -> Dict[str, Set[str]]:
84
+ """`{class_name: {method, ...}}` from Blackmagic's own API reference.
85
+
86
+ Deprecated and unsupported sections are cut: a bridge is not obliged to carry
87
+ calls the vendor has withdrawn, and including them would report failures that
88
+ say nothing about the transport.
89
+ """
90
+ classes: Dict[str, Set[str]] = {}
91
+ text = docs_path.read_text(encoding="utf-8", errors="replace")
92
+ for marker in ("\nDeprecated Resolve API Functions", "\nUnsupported Resolve API Functions"):
93
+ index = text.find(marker)
94
+ if index != -1:
95
+ text = text[:index]
96
+ current: Optional[str] = None
97
+ for line in text.splitlines():
98
+ if not line.strip():
99
+ continue
100
+ if not line.startswith(" "):
101
+ match = _CLASS_HEADING_RE.match(line)
102
+ current = match.group(1) if match and len(match.group(1).split()) == 1 else None
103
+ continue
104
+ if current is None:
105
+ continue
106
+ method = _METHOD_RE.match(line)
107
+ if method:
108
+ classes.setdefault(current, set()).add(method.group(1))
109
+ return classes
110
+
111
+
112
+ def called_methods(roots: Sequence[str] = SOURCE_ROOTS) -> Dict[str, Set[str]]:
113
+ """`{method: {file, ...}}` — Resolve API methods this codebase invokes.
114
+
115
+ AST rather than grep: a method name inside a docstring, a log line or a tool
116
+ description is not a call, and counting it would inflate the surface the
117
+ bridge is asked to carry.
118
+ """
119
+ documented = {m for methods in documented_methods().values() for m in methods}
120
+ found: Dict[str, Set[str]] = {}
121
+ for root in roots:
122
+ for path in sorted((REPO_ROOT / root).rglob("*.py")):
123
+ try:
124
+ tree = ast.parse(path.read_text(encoding="utf-8", errors="replace"))
125
+ except SyntaxError: # pragma: no cover - a broken file is its own alarm
126
+ continue
127
+ relative = str(path.relative_to(REPO_ROOT))
128
+ for node in ast.walk(tree):
129
+ if not isinstance(node, ast.Call):
130
+ continue
131
+ target = node.func
132
+ name = None
133
+ if isinstance(target, ast.Attribute):
134
+ name = target.attr
135
+ elif isinstance(target, ast.Name):
136
+ name = target.id
137
+ if name in documented:
138
+ found.setdefault(name, set()).add(relative)
139
+ # getattr(obj, "SetLUT", None) is a capability check, and the
140
+ # bridge has to answer it as truthfully as a call.
141
+ if isinstance(target, ast.Name) and target.id == "getattr" and node.args[1:]:
142
+ probe = node.args[1]
143
+ if isinstance(probe, ast.Constant) and probe.value in documented:
144
+ found.setdefault(probe.value, set()).add(relative)
145
+ return found
146
+
147
+
148
+ def is_safe_to_probe(method: str) -> bool:
149
+ """Can the harness call this blind, on a project it does not own?
150
+
151
+ Read-only by name is a weak test in general — but here it is backed by the
152
+ rule that anything failing it is not skipped, it is routed to a named
153
+ scenario with explicit setup and teardown. The conservative answer costs
154
+ coverage in one place and buys it back in the other.
155
+ """
156
+ if method.startswith(_UNSAFE_VERBS):
157
+ return False
158
+ return method.startswith(("Get", "Is", "Has", "List", "Find", "Count"))
159
+
160
+
161
+ def probe_surface(roots: Sequence[str] = SOURCE_ROOTS) -> Dict[str, Any]:
162
+ """The enumeration, split into what can be probed and what cannot."""
163
+ calls = called_methods(roots)
164
+ safe = sorted(m for m in calls if is_safe_to_probe(m))
165
+ unsafe = sorted(m for m in calls if not is_safe_to_probe(m))
166
+ return {
167
+ "called": sorted(calls),
168
+ "safe_to_probe": safe,
169
+ "needs_a_scenario": unsafe,
170
+ "sources": {m: sorted(files) for m, files in calls.items()},
171
+ }
172
+
173
+
174
+ # ── 2. walking the live object graph ─────────────────────────────────────────
175
+
176
+
177
+ #: How the harness reaches each object worth probing, as a chain of zero-argument
178
+ #: calls from the Resolve root. Kept declarative so the same walk runs over either
179
+ #: transport without the driver knowing which it holds.
180
+ #: `("[]", (index,))` takes an element of a returned list. Without it the walk
181
+ #: stops at the collection accessors and never reaches TimelineItem,
182
+ #: MediaPoolItem or Graph — which between them carry most of the read surface
183
+ #: worth qualifying (LUTs, node graphs, source timings, metadata).
184
+ _INDEX = "[]"
185
+
186
+ _PM = ("GetProjectManager", ())
187
+ _PROJECT = (_PM, ("GetCurrentProject", ()))
188
+ _POOL = _PROJECT + (("GetMediaPool", ()),)
189
+ _TIMELINE = _PROJECT + (("GetCurrentTimeline", ()),)
190
+ _TIMELINE_ITEM = _TIMELINE + (("GetItemListInTrack", ("video", 1)), (_INDEX, (0,)))
191
+ _GALLERY = _PROJECT + (("GetGallery", ()),)
192
+
193
+ OBJECT_GRAPH: Tuple[Tuple[str, Tuple[Any, ...]], ...] = (
194
+ ("resolve", ()),
195
+ ("project_manager", (_PM,)),
196
+ ("project", _PROJECT),
197
+ ("media_pool", _POOL),
198
+ ("root_folder", _POOL + (("GetRootFolder", ()),)),
199
+ ("media_pool_item", _POOL + (("GetRootFolder", ()), ("GetClipList", ()), (_INDEX, (0,)))),
200
+ ("timeline", _TIMELINE),
201
+ ("timeline_node_graph", _TIMELINE + (("GetNodeGraph", ()),)),
202
+ ("timeline_item", _TIMELINE_ITEM),
203
+ ("timeline_item_node_graph", _TIMELINE_ITEM + (("GetNodeGraph", ()),)),
204
+ # Unreachable until a project actually has a colour group. Listed anyway so
205
+ # its three methods report as "no such object here" rather than vanishing
206
+ # from the coverage count with no explanation.
207
+ ("color_group", _PROJECT + (("GetColorGroupsList", ()), (_INDEX, (0,)))),
208
+ ("gallery", _GALLERY),
209
+ ("gallery_still_album", _GALLERY + (("GetCurrentStillAlbum", ()),)),
210
+ ("media_storage", (("GetMediaStorage", ()),)),
211
+ )
212
+
213
+
214
+ def walk(resolve: Any, chain: Iterable[Tuple[str, Tuple[Any, ...]]]) -> Any:
215
+ """Follow a chain from the Resolve root, returning None if it breaks.
216
+
217
+ A missing link is a legitimate state — no project open, no current timeline,
218
+ an empty track — and must not read as a transport failure, so it returns None
219
+ rather than raising. The distinction matters: the report separates "this
220
+ object was unreachable" from "this method disagreed".
221
+ """
222
+ current = resolve
223
+ for method, args in chain:
224
+ if current is None:
225
+ return None
226
+ if method == _INDEX:
227
+ index = args[0]
228
+ if not isinstance(current, (list, tuple)) or len(current) <= index:
229
+ return None
230
+ current = current[index]
231
+ continue
232
+ function = getattr(current, method, None)
233
+ if function is None:
234
+ return None
235
+ try:
236
+ current = function(*args)
237
+ except Exception: # noqa: BLE001 - an unreachable branch, not a verdict
238
+ return None
239
+ return current
240
+
241
+
242
+ # ── 3. normalising a result so two transports can be compared ────────────────
243
+
244
+
245
+ def normalise(value: Any, *, depth: int = 0) -> Any:
246
+ """Reduce a result to what two transports must agree on.
247
+
248
+ Live objects become a marker rather than a value: the bridge returns a
249
+ handle, native scripting returns a `PyRemoteObject`, and neither address says
250
+ anything about correctness. Everything else is preserved exactly — including
251
+ the difference between None and False, which this codebase has been bitten by
252
+ often enough to make it a compared value rather than a detail.
253
+ """
254
+ if value is None or isinstance(value, (bool, int, str)):
255
+ return value
256
+ if isinstance(value, float):
257
+ # Two transports serialise the same double identically; rounding only
258
+ # guards against a JSON round trip introducing a last-bit difference.
259
+ return round(value, 9)
260
+ if depth > 6:
261
+ return "<deep>"
262
+ if isinstance(value, (list, tuple)):
263
+ return [normalise(item, depth=depth + 1) for item in value]
264
+ if isinstance(value, dict):
265
+ return {str(k): normalise(v, depth=depth + 1) for k, v in sorted(value.items(), key=lambda kv: str(kv[0]))}
266
+ return "<object>"
267
+
268
+
269
+ def describe(value: Any) -> str:
270
+ """A type label that survives the transport difference."""
271
+ if value is None:
272
+ return "None"
273
+ if isinstance(value, bool):
274
+ return "bool"
275
+ if isinstance(value, (list, tuple)):
276
+ return "list[%d]" % len(value)
277
+ if isinstance(value, dict):
278
+ return "dict[%d]" % len(value)
279
+ if isinstance(value, (int, float, str)):
280
+ return type(value).__name__
281
+ return "object"
282
+
283
+
284
+ # ── 4. running and diffing ───────────────────────────────────────────────────
285
+
286
+
287
+ def probe_one(target: Any, method: str) -> Dict[str, Any]:
288
+ """Call one method, recording the outcome rather than propagating it.
289
+
290
+ A raising method is a *result* here, not an abort: whether both transports
291
+ raise the same way is exactly what the diff is for.
292
+ """
293
+ function = getattr(target, method, None)
294
+ if function is None:
295
+ return {"outcome": "absent"}
296
+ try:
297
+ value = function()
298
+ except Exception as exc: # noqa: BLE001 - the exception IS the observation
299
+ return {"outcome": "raised", "error": type(exc).__name__, "message": str(exc)[:200]}
300
+ return {"outcome": "returned", "value": normalise(value), "type": describe(value)}
301
+
302
+
303
+ def run_probes(resolve: Any, methods: Sequence[str]) -> Dict[str, Dict[str, Any]]:
304
+ """Every enumerated safe method, against every object that has it.
305
+
306
+ Keyed `object.method` so the diff can say *where* two transports disagreed,
307
+ not merely that they did.
308
+ """
309
+ results: Dict[str, Dict[str, Any]] = {}
310
+ for label, chain in OBJECT_GRAPH:
311
+ target = walk(resolve, chain)
312
+ if target is None:
313
+ results[f"{label}.<unreachable>"] = {"outcome": "unreachable"}
314
+ continue
315
+ for method in methods:
316
+ if getattr(target, method, None) is None:
317
+ continue # not on this object; absence is reported by the diff
318
+ results[f"{label}.{method}"] = probe_one(target, method)
319
+ return results
320
+
321
+
322
+ def compare(native: Dict[str, Dict[str, Any]], bridged: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
323
+ """Diff two probe runs. Empty `differences` is the pass condition."""
324
+ differences: List[Dict[str, Any]] = []
325
+ for key in sorted(set(native) | set(bridged)):
326
+ left, right = native.get(key), bridged.get(key)
327
+ if left is None or right is None:
328
+ differences.append({
329
+ "key": key, "kind": "only_one_transport_reached_it",
330
+ "native": left, "bridge": right,
331
+ })
332
+ continue
333
+ method = key.rsplit(".", 1)[-1]
334
+ if method in VOLATILE_METHODS:
335
+ # Value moves on its own; the transport is still on the hook for
336
+ # returning the same *kind* of thing.
337
+ if left.get("type") != right.get("type") or left["outcome"] != right["outcome"]:
338
+ differences.append({"key": key, "kind": "volatile_type_mismatch",
339
+ "native": left.get("type"), "bridge": right.get("type")})
340
+ continue
341
+ if left != right:
342
+ differences.append({"key": key, "kind": "value_mismatch",
343
+ "native": left, "bridge": right})
344
+ agreed = len(set(native) & set(bridged)) - len(
345
+ [d for d in differences if d["kind"] != "only_one_transport_reached_it"]
346
+ )
347
+ return {
348
+ "compared": len(set(native) | set(bridged)),
349
+ "agreed": agreed,
350
+ "differences": differences,
351
+ "identical": not differences,
352
+ }
353
+
354
+
355
+ def coverage_report(surface: Dict[str, Any], results: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
356
+ """What the run actually exercised — and what it did not, with the reason.
357
+
358
+ A harness that reports only its passes reads as complete when it is not, so
359
+ the untouched methods are named. Most of them are writes routed to scenarios;
360
+ the rest are methods no reachable object carried.
361
+ """
362
+ exercised = {key.rsplit(".", 1)[-1] for key in results if not key.endswith(".<unreachable>")}
363
+ safe = set(surface["safe_to_probe"])
364
+ unreachable = sorted(key.split(".", 1)[0] for key in results if key.endswith(".<unreachable>"))
365
+ return {
366
+ "called_by_tools": len(surface["called"]),
367
+ "safe_to_probe": len(safe),
368
+ "exercised": len(safe & exercised),
369
+ "not_exercised": sorted(safe - exercised),
370
+ # Named, because "this project has no colour group" and "the bridge
371
+ # cannot reach colour groups" are different findings and a bare count
372
+ # of exercised methods conflates them.
373
+ "unreachable_objects": unreachable,
374
+ "needs_a_scenario": surface["needs_a_scenario"],
375
+ }