resolvescript 0.1.2__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.
Files changed (49) hide show
  1. resolve_script/__init__.py +3 -0
  2. resolve_script/analyze.py +277 -0
  3. resolve_script/cli.py +748 -0
  4. resolve_script/config.py +62 -0
  5. resolve_script/consolidate.py +604 -0
  6. resolve_script/fetch.py +90 -0
  7. resolve_script/install/__init__.py +49 -0
  8. resolve_script/install/discovery.py +57 -0
  9. resolve_script/install/installer.py +397 -0
  10. resolve_script/install/registry.py +106 -0
  11. resolve_script/manifest/__init__.py +1 -0
  12. resolve_script/manifest/json_reader.py +40 -0
  13. resolve_script/manifest/model.py +316 -0
  14. resolve_script/manifest/validation.py +81 -0
  15. resolve_script/manifest/xml_reader.py +162 -0
  16. resolve_script/package.py +103 -0
  17. resolve_script/resolver.py +204 -0
  18. resolve_script/sandbox/__init__.py +38 -0
  19. resolve_script/sandbox/api.py +393 -0
  20. resolve_script/sandbox/env.py +82 -0
  21. resolve_script/sandbox/loader.py +72 -0
  22. resolve_script/sandbox/repl.py +57 -0
  23. resolve_script/sandbox/smoke.py +104 -0
  24. resolve_script/scaffold.py +126 -0
  25. resolve_script/semver.py +236 -0
  26. resolve_script/sources/__init__.py +15 -0
  27. resolve_script/sources/archive.py +82 -0
  28. resolve_script/sources/git.py +107 -0
  29. resolve_script/sources/known.py +47 -0
  30. resolve_script/sources/release.py +55 -0
  31. resolve_script/spec.py +137 -0
  32. resolve_script/templates/extension/@NAME@/__init__.py +7 -0
  33. resolve_script/templates/extension/@NAME@/menu.py +12 -0
  34. resolve_script/templates/extension/@NAME@.py +13 -0
  35. resolve_script/templates/extension/README.md +20 -0
  36. resolve_script/templates/extension/conftest.py +13 -0
  37. resolve_script/templates/extension/manifest.json.j2 +23 -0
  38. resolve_script/templates/extension/manifest.xml.j2 +24 -0
  39. resolve_script/templates/extension/tests/test_smoke.py +26 -0
  40. resolve_script/templates/inapp/register.py +28 -0
  41. resolve_script/testing/__init__.py +6 -0
  42. resolve_script/testing/fixtures.py +47 -0
  43. resolve_script/workspace.py +66 -0
  44. resolvescript-0.1.2.dist-info/METADATA +146 -0
  45. resolvescript-0.1.2.dist-info/RECORD +49 -0
  46. resolvescript-0.1.2.dist-info/WHEEL +5 -0
  47. resolvescript-0.1.2.dist-info/entry_points.txt +2 -0
  48. resolvescript-0.1.2.dist-info/licenses/LICENSE +21 -0
  49. resolvescript-0.1.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,393 @@
1
+ """Mock DaVinci Resolve / Fusion API objects for headless development.
2
+
3
+ The classes here mirror the surface area a Resolve script actually touches:
4
+ Resolve connection, project management, timelines, clips, the media pool,
5
+ and a Fusion comp (tools, splines, strokes). They are intentionally small —
6
+ enough to be useful, not a full emulation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ _SPLINE_TYPES = ("BSpline", "Polyline")
15
+
16
+
17
+ class FakeResolve:
18
+ def __init__(self, project_manager: Any):
19
+ self._pm = project_manager
20
+
21
+ def GetProjectManager(self) -> Any:
22
+ return self._pm
23
+
24
+ def __repr__(self) -> str:
25
+ return "<FakeResolve>"
26
+
27
+
28
+ class FakeFusion:
29
+ def __init__(self, comp: Any):
30
+ self._comp = comp
31
+
32
+ def GetCurrentComp(self) -> Any:
33
+ return self._comp
34
+
35
+ def __repr__(self) -> str:
36
+ return "<FakeFusion>"
37
+
38
+
39
+ class FakeProjectManager:
40
+ def __init__(self, projects: dict[str, Any], current: str):
41
+ self._projects = projects
42
+ self._current = current
43
+
44
+ def GetCurrentProject(self) -> Any:
45
+ return self._projects.get(self._current)
46
+
47
+ def GetProject(self, name: str) -> Any | None:
48
+ return self._projects.get(name)
49
+
50
+ def GetProjectList(self) -> list[str]:
51
+ return list(self._projects)
52
+
53
+ def __repr__(self) -> str:
54
+ return f"<FakeProjectManager projects={list(self._projects)}>"
55
+
56
+
57
+ class FakeProject:
58
+ def __init__(self, name: str, timelines: list[Any], media_pool: Any):
59
+ self._name = name
60
+ self._timelines = timelines
61
+ self._media_pool = media_pool
62
+ self._current = 0
63
+
64
+ def GetName(self) -> str:
65
+ return self._name
66
+
67
+ def GetTimelineCount(self) -> int:
68
+ return len(self._timelines)
69
+
70
+ def GetTimelineByIndex(self, index: int) -> Any:
71
+ return self._timelines[index - 1]
72
+
73
+ def GetTimelineByName(self, name: str) -> Any | None:
74
+ for timeline in self._timelines:
75
+ if timeline.GetName() == name:
76
+ return timeline
77
+ return None
78
+
79
+ def GetCurrentTimeline(self) -> Any:
80
+ return self._timelines[self._current]
81
+
82
+ def GetMediaPool(self) -> Any:
83
+ return self._media_pool
84
+
85
+ def GetRenderJobList(self) -> list[Any]:
86
+ return []
87
+
88
+ def __repr__(self) -> str:
89
+ return f"<FakeProject {self._name!r}>"
90
+
91
+
92
+ class FakeTimeline:
93
+ def __init__(
94
+ self,
95
+ name: str,
96
+ start_frame: int,
97
+ end_frame: int,
98
+ fps: float,
99
+ video_tracks: list[list[Any]],
100
+ audio_tracks: list[list[Any]],
101
+ ):
102
+ self._name = name
103
+ self._start = start_frame
104
+ self._end = end_frame
105
+ self._fps = fps
106
+ self._video = video_tracks
107
+ self._audio = audio_tracks
108
+ self._tc = "01:00:00:00"
109
+
110
+ def GetName(self) -> str:
111
+ return self._name
112
+
113
+ def GetStartFrame(self) -> int:
114
+ return self._start
115
+
116
+ def GetEndFrame(self) -> int:
117
+ return self._end
118
+
119
+ def GetSetting(self, key: str) -> Any:
120
+ if key == "timelineFrameRate":
121
+ return self._fps
122
+ return None
123
+
124
+ def GetTrackCount(self, track_type: str) -> int:
125
+ return len(self._video if track_type == "video" else self._audio)
126
+
127
+ def GetItemListInTrack(self, track_type: str, track_index: int) -> list[Any]:
128
+ tracks = self._video if track_type == "video" else self._audio
129
+ if track_index < 1 or track_index > len(tracks):
130
+ return []
131
+ return list(tracks[track_index - 1])
132
+
133
+ def GetTrackName(self, track_type: str, track_index: int) -> str:
134
+ return f"{track_type.capitalize()} Track {track_index}"
135
+
136
+ def SetCurrentTimecode(self, timecode: str) -> bool:
137
+ self._tc = timecode
138
+ return True
139
+
140
+ def GetCurrentTimecode(self) -> str:
141
+ return self._tc
142
+
143
+ def __repr__(self) -> str:
144
+ return f"<FakeTimeline {self._name!r}>"
145
+
146
+
147
+ class FakeClip:
148
+ def __init__(self, name: str, start: int, end: int, media_item: Any):
149
+ self._name = name
150
+ self._start = start
151
+ self._end = end
152
+ self._item = media_item
153
+ self._comps: list[Any] = []
154
+
155
+ def GetName(self) -> str:
156
+ return self._name
157
+
158
+ def GetStart(self) -> int:
159
+ return self._start
160
+
161
+ def GetEnd(self) -> int:
162
+ return self._end
163
+
164
+ def GetDuration(self) -> int:
165
+ return self._end - self._start + 1
166
+
167
+ def GetMediaPoolItem(self) -> Any:
168
+ return self._item
169
+
170
+ def GetFusionCompCount(self) -> int:
171
+ return len(self._comps)
172
+
173
+ def GetFusionCompByIndex(self, index: int) -> Any | None:
174
+ if 1 <= index <= len(self._comps):
175
+ return self._comps[index - 1]
176
+ return None
177
+
178
+ def AddFusionComp(self) -> Any:
179
+ comp = FakeComp(f"{self._name} Comp {len(self._comps) + 1}")
180
+ self._comps.append(comp)
181
+ return comp
182
+
183
+ def __repr__(self) -> str:
184
+ return f"<FakeClip {self._name!r}>"
185
+
186
+
187
+ class FakeMediaPoolItem:
188
+ def __init__(self, name: str, props: dict[str, str] | None = None):
189
+ self._name = name
190
+ self._props = props or {"FPS": "24", "Duration": "100", "Width": "1920", "Height": "1080"}
191
+
192
+ def GetName(self) -> str:
193
+ return self._name
194
+
195
+ def GetClipProperty(self, key: str) -> str | None:
196
+ return self._props.get(key)
197
+
198
+ def SetClipProperty(self, key: str, value: str) -> bool:
199
+ self._props[key] = value
200
+ return True
201
+
202
+ def __repr__(self) -> str:
203
+ return f"<FakeMediaPoolItem {self._name!r}>"
204
+
205
+
206
+ class FakeFolder:
207
+ def __init__(self, name: str, clips: list[Any], subfolders: list[Any] | None = None):
208
+ self._name = name
209
+ self._clips = clips
210
+ self._subfolders = subfolders or []
211
+
212
+ def GetName(self) -> str:
213
+ return self._name
214
+
215
+ def GetClipList(self) -> list[Any]:
216
+ return list(self._clips)
217
+
218
+ def GetSubFolderList(self) -> list[Any]:
219
+ return list(self._subfolders)
220
+
221
+
222
+ class FakeMediaPool:
223
+ def __init__(self, root: Any):
224
+ self._root = root
225
+
226
+ def GetRootFolder(self) -> Any:
227
+ return self._root
228
+
229
+ def ImportMedia(self, file_paths: list[str]) -> list[Any]:
230
+ return [FakeMediaPoolItem(Path(p).stem) for p in file_paths]
231
+
232
+
233
+ class FakeKey:
234
+ def __init__(self, frame: int, x: float, y: float):
235
+ self.Frame = frame
236
+ self.X = x
237
+ self.Y = y
238
+
239
+
240
+ class FakeSpline:
241
+ def __init__(self) -> None:
242
+ self._keys: dict[int, FakeKey] = {}
243
+
244
+ def AddKey(self, frame: int, value: dict[str, float]) -> None:
245
+ self._keys[frame] = FakeKey(frame, value.get("X", 0.0), value.get("Y", 0.0))
246
+
247
+ def DeleteKey(self, frame: int) -> None:
248
+ self._keys.pop(frame, None)
249
+
250
+ def DeleteAllKeys(self) -> None:
251
+ self._keys.clear()
252
+
253
+ def GetKeyCount(self) -> int:
254
+ return len(self._keys)
255
+
256
+ def GetKey(self, index: int) -> FakeKey:
257
+ ordered = sorted(self._keys.values(), key=lambda k: k.Frame)
258
+ return ordered[index]
259
+
260
+
261
+ class FakeStroke:
262
+ def __init__(self, number: int):
263
+ self._attrs = {
264
+ "TOOLS_Name": f"Stroke_{number}",
265
+ "BrushType": "Stroke",
266
+ "BrushColor": (1.0, 1.0, 1.0, 1.0),
267
+ "BrushSize": 10.0,
268
+ "TOOLB_StartFrame": 0,
269
+ "TOOLB_EndFrame": 0,
270
+ }
271
+ self._points: list[tuple[float, float, float]] = []
272
+
273
+ def SetAttrs(self, attrs: dict[str, Any]) -> None:
274
+ self._attrs.update(attrs)
275
+
276
+ def GetAttrs(self) -> dict[str, Any]:
277
+ return dict(self._attrs)
278
+
279
+ def AddPoint(self, x: float, y: float, frame: float) -> None:
280
+ self._points.append((x, y, frame))
281
+ int_frame = int(frame)
282
+ prev_start = self._attrs.get("TOOLB_StartFrame") or int_frame
283
+ prev_end = self._attrs.get("TOOLB_EndFrame") or int_frame
284
+ self._attrs["TOOLB_StartFrame"] = min(prev_start, int_frame)
285
+ self._attrs["TOOLB_EndFrame"] = max(prev_end, int_frame)
286
+
287
+
288
+ class FakeTool:
289
+ def __init__(self, regid: str, name: str, x: float, y: float):
290
+ self._attrs = {
291
+ "TOOLS_Name": name,
292
+ "TOOLS_RegID": regid,
293
+ "TOOLS_PosX": x,
294
+ "TOOLS_PosY": y,
295
+ }
296
+ self._spline = FakeSpline() if regid in _SPLINE_TYPES else None
297
+ self._strokes: dict[str, Any] = {}
298
+
299
+ def GetAttrs(self) -> dict[str, Any]:
300
+ return dict(self._attrs)
301
+
302
+ def SetAttrs(self, attrs: dict[str, Any]) -> None:
303
+ self._attrs.update(attrs)
304
+
305
+ def GetInput(self, name: str) -> Any:
306
+ return self._spline if name == "Spline" else None
307
+
308
+ def GetInputList(self) -> dict[str, Any]:
309
+ return {}
310
+
311
+ def GetOutputList(self) -> dict[str, Any]:
312
+ return {}
313
+
314
+ def SetInput(self, index: int | str, value: Any) -> bool:
315
+ return True
316
+
317
+ def ConnectInput(self, index: int | str, source: Any) -> bool:
318
+ self._attrs.setdefault("_connected", {})[index] = source
319
+ return True
320
+
321
+ def AddStroke(self) -> Any:
322
+ stroke = FakeStroke(len(self._strokes) + 1)
323
+ self._strokes[f"Stroke{len(self._strokes) + 1}"] = stroke
324
+ return stroke
325
+
326
+ def GetStrokeList(self) -> dict[str, Any]:
327
+ return dict(self._strokes)
328
+
329
+ def DeleteStroke(self, stroke: Any) -> None:
330
+ for key, value in list(self._strokes.items()):
331
+ if value is stroke:
332
+ del self._strokes[key]
333
+
334
+ def __repr__(self) -> str:
335
+ return f"<FakeTool {self._attrs['TOOLS_Name']!r} {self._attrs['TOOLS_RegID']}>"
336
+
337
+
338
+ class FakeComp:
339
+ def __init__(
340
+ self,
341
+ name: str,
342
+ width: int = 1920,
343
+ height: int = 1080,
344
+ fps: float = 24.0,
345
+ start: int = 0,
346
+ end: int = 100,
347
+ ):
348
+ self._attrs = {
349
+ "COMPS_Name": name,
350
+ "COMPS_Width": width,
351
+ "COMPS_Height": height,
352
+ "COMPS_FrameRate": fps,
353
+ "COMPS_RenderStart": start,
354
+ "COMPS_RenderEnd": end,
355
+ }
356
+ self._tools: dict[str, Any] = {}
357
+ self._counters: dict[str, int] = {}
358
+
359
+ def GetAttrs(self) -> dict[str, Any]:
360
+ return dict(self._attrs)
361
+
362
+ def SetAttrs(self, attrs: dict[str, Any]) -> None:
363
+ self._attrs.update(attrs)
364
+
365
+ def GetToolList(self, tool_type: str | None = None) -> dict[str, Any]:
366
+ if tool_type is None:
367
+ return dict(self._tools)
368
+ return {
369
+ name: tool
370
+ for name, tool in self._tools.items()
371
+ if tool.GetAttrs()["TOOLS_RegID"] == tool_type
372
+ }
373
+
374
+ def AddTool(self, tool_type: str, x: float = 0, y: float = 0) -> Any:
375
+ self._counters[tool_type] = self._counters.get(tool_type, 0) + 1
376
+ name = f"{tool_type}{self._counters[tool_type]}"
377
+ tool = FakeTool(tool_type, name, x, y)
378
+ self._tools[name] = tool
379
+ return tool
380
+
381
+ def DeleteTool(self, tool: Any) -> None:
382
+ for key, value in list(self._tools.items()):
383
+ if value is tool:
384
+ del self._tools[key]
385
+
386
+ def Save(self, path: str | None = None) -> bool:
387
+ return True
388
+
389
+ def Close(self) -> None:
390
+ pass
391
+
392
+ def __repr__(self) -> str:
393
+ return f"<FakeComp {self._attrs['COMPS_Name']!r}>"
@@ -0,0 +1,82 @@
1
+ """Environment injection: build a mock Resolve session and install it.
2
+
3
+ ``install_fake_resolve`` registers a fake ``DaVinciResolveScript`` module in
4
+ ``sys.modules`` so any extension that does ``import DaVinciResolveScript`` runs
5
+ headless. It is idempotent — calling it again just replaces the module.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from types import ModuleType
12
+ from typing import Any
13
+
14
+ from .api import (
15
+ FakeClip,
16
+ FakeComp,
17
+ FakeFolder,
18
+ FakeFusion,
19
+ FakeMediaPool,
20
+ FakeMediaPoolItem,
21
+ FakeProject,
22
+ FakeProjectManager,
23
+ FakeResolve,
24
+ FakeTimeline,
25
+ )
26
+
27
+ FUSION_SCRIPT_MODULE = "DaVinciResolveScript"
28
+
29
+ DEFAULT_PROJECT = "Demo Project"
30
+
31
+
32
+ def build_default_env() -> tuple[FakeResolve, FakeFusion]:
33
+ """Create a realistic mock session with clips, timelines and a Fusion comp."""
34
+ items = {f"Shot {i}": FakeMediaPoolItem(f"Shot {i}") for i in range(1, 7)}
35
+
36
+ def _clip(name: str, start: int, end: int) -> FakeClip:
37
+ return FakeClip(name, start, end, items[name])
38
+
39
+ video_1 = [
40
+ _clip("Shot 1", 1001, 1040),
41
+ None,
42
+ _clip("Shot 3", 1061, 1100),
43
+ ]
44
+ video_2 = [
45
+ _clip("Shot 2", 1001, 1040),
46
+ _clip("Shot 4", 1120, 1180),
47
+ ]
48
+ audio_1 = [
49
+ _clip("Shot 5", 1001, 1080),
50
+ _clip("Shot 6", 1081, 1180),
51
+ ]
52
+
53
+ timeline_1 = FakeTimeline("Main Timeline", 1001, 1200, 24.0, [video_1, video_2], [audio_1])
54
+ timeline_2 = FakeTimeline("Alt Timeline", 2001, 3000, 24.0, [video_2], [audio_1])
55
+
56
+ root = FakeFolder("Master", list(items.values()))
57
+ media_pool = FakeMediaPool(root)
58
+ project = FakeProject(DEFAULT_PROJECT, [timeline_1, timeline_2], media_pool)
59
+ pm = FakeProjectManager({DEFAULT_PROJECT: project}, DEFAULT_PROJECT)
60
+
61
+ resolve = FakeResolve(pm)
62
+ fusion = FakeFusion(FakeComp("Comp 1", start=1001, end=1200))
63
+ return resolve, fusion
64
+
65
+
66
+ def fake_resolve_module() -> ModuleType:
67
+ """Build (but do not install) a fake DaVinciResolveScript module."""
68
+ resolve, fusion = build_default_env()
69
+ module = ModuleType(FUSION_SCRIPT_MODULE)
70
+
71
+ def _scriptapp(kind: str) -> Any:
72
+ return resolve if kind == "Resolve" else fusion
73
+
74
+ module.scriptapp = _scriptapp # type: ignore[attr-defined]
75
+ return module
76
+
77
+
78
+ def install_fake_resolve() -> ModuleType:
79
+ """Register the fake DaVinciResolveScript module (idempotent)."""
80
+ module = fake_resolve_module()
81
+ sys.modules[FUSION_SCRIPT_MODULE] = module
82
+ return module
@@ -0,0 +1,72 @@
1
+ """Module loading helpers: source packages and consolidated single files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import importlib.util
7
+ import sys
8
+ from pathlib import Path
9
+ from types import ModuleType
10
+
11
+
12
+ def purge_module(prefix: str) -> None:
13
+ """Remove a module (and its submodules) from ``sys.modules``."""
14
+ for key in [k for k in list(sys.modules) if k == prefix or k.startswith(prefix + ".")]:
15
+ del sys.modules[key]
16
+ importlib.invalidate_caches()
17
+
18
+
19
+ def _entry_file(module_name: str, package_dir: Path) -> tuple[Path, str | None]:
20
+ """Locate the on-disk entry file for ``module_name``.
21
+
22
+ Returns ``(file, package)`` where ``package`` is the package name for a
23
+ ``__init__.py`` entry (drives ``__package__``/``__path__``) or ``None`` for
24
+ a single-file module.
25
+ """
26
+ package_dir = Path(package_dir)
27
+ rel_dotted = module_name.replace(".", "/")
28
+ init = package_dir / rel_dotted / "__init__.py"
29
+ if init.is_file():
30
+ return init, module_name
31
+ single = package_dir / f"{rel_dotted}.py"
32
+ if single.is_file():
33
+ return single, None
34
+ raise ImportError(f"cannot find entrypoint for module '{module_name}' under {package_dir}")
35
+
36
+
37
+ def load_source_module(module_name: str, package_dir: Path) -> ModuleType:
38
+ """Import ``module_name`` fresh from ``package_dir``.
39
+
40
+ The entry file is compiled directly from source (bypassing the bytecode
41
+ cache) so re-installs and rebuilds always see the current code.
42
+ """
43
+ purge_module(module_name)
44
+ package_dir = Path(package_dir).resolve()
45
+ if str(package_dir) not in sys.path:
46
+ sys.path.insert(0, str(package_dir))
47
+
48
+ file_path, package = _entry_file(module_name, package_dir)
49
+ module = ModuleType(module_name)
50
+ module.__file__ = str(file_path)
51
+ module.__package__ = package or ""
52
+ if package:
53
+ module.__path__ = [str(file_path.parent)]
54
+ code = compile(file_path.read_text(encoding="utf-8"), str(file_path), "exec")
55
+ sys.modules[module_name] = module
56
+ exec(code, module.__dict__)
57
+ return module
58
+
59
+
60
+ def load_built_module(module_name: str, built_file: Path) -> ModuleType:
61
+ """Import a consolidated single-file build under ``module_name``."""
62
+ purge_module(module_name)
63
+ built_file = Path(built_file).resolve()
64
+ if not built_file.is_file():
65
+ raise FileNotFoundError(f"built package not found: {built_file}")
66
+ spec = importlib.util.spec_from_file_location(module_name, built_file)
67
+ if spec is None or spec.loader is None:
68
+ raise ImportError(f"cannot load {built_file} as a Python module")
69
+ module = importlib.util.module_from_spec(spec)
70
+ sys.modules[module_name] = module
71
+ spec.loader.exec_module(module)
72
+ return module
@@ -0,0 +1,57 @@
1
+ """Interactive REPL namespace for the sandbox."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import code
6
+ from types import ModuleType
7
+ from typing import Any
8
+
9
+ from .api import FakeProject, FakeResolve
10
+
11
+
12
+ def _collect_clips(project: FakeProject) -> list[Any]:
13
+ clips: list[Any] = []
14
+ seen: set[int] = set()
15
+ for index in range(1, project.GetTimelineCount() + 1):
16
+ timeline = project.GetTimelineByIndex(index)
17
+ for track in range(1, timeline.GetTrackCount("video") + 1):
18
+ for clip in timeline.GetItemListInTrack("video", track):
19
+ if clip is None or id(clip) in seen:
20
+ continue
21
+ seen.add(id(clip))
22
+ clips.append(clip)
23
+ return clips
24
+
25
+
26
+ def default_namespace(module: ModuleType, resolve: FakeResolve | None = None) -> dict[str, Any]:
27
+ """Build the interactive namespace: ``resolve``, ``project``, ``timeline``,
28
+ ``clips`` and the loaded module under its own name."""
29
+ if resolve is None:
30
+ import sys
31
+
32
+ from .env import FUSION_SCRIPT_MODULE
33
+
34
+ dvr = sys.modules.get(FUSION_SCRIPT_MODULE)
35
+ resolve = dvr.scriptapp("Resolve") if dvr is not None else None # type: ignore[attr-defined]
36
+ namespace: dict[str, Any] = {}
37
+ if resolve is not None:
38
+ namespace["resolve"] = resolve
39
+ project = resolve.GetProjectManager().GetCurrentProject()
40
+ if project is not None:
41
+ namespace["project"] = project
42
+ namespace["timeline"] = project.GetCurrentTimeline()
43
+ namespace["clips"] = _collect_clips(project)
44
+ namespace[module.__name__] = module
45
+ return namespace
46
+
47
+
48
+ def start_repl(module: ModuleType, resolve: FakeResolve | None = None) -> None:
49
+ """Drop into an interactive interpreter with a live mock session."""
50
+ namespace = default_namespace(module, resolve)
51
+ banner = (
52
+ "\nInteractive sandbox. Available:\n"
53
+ f" {module.__name__} - the package under test\n"
54
+ " resolve / project / timeline\n"
55
+ " clips - list of clips across the timeline video tracks\n"
56
+ )
57
+ code.interact(local=namespace, banner=banner)
@@ -0,0 +1,104 @@
1
+ """Generic smoke checks for any Resolve script package.
2
+
3
+ ``run_smoke`` walks the package's public API (or an explicit list of export
4
+ names) and calls every zero-arg callable against the injected mock Resolve
5
+ environment. Any callable that raises is reported as a failure; functions that
6
+ require arguments are skipped by design.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import inspect
12
+ from dataclasses import dataclass, field
13
+ from types import ModuleType
14
+ from typing import Any, Callable
15
+
16
+ _REQUIRED = (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
17
+
18
+
19
+ @dataclass
20
+ class SmokeCheck:
21
+ name: str
22
+ ok: bool
23
+ detail: str = "ok"
24
+
25
+
26
+ @dataclass
27
+ class SmokeResult:
28
+ checks: list[SmokeCheck] = field(default_factory=list)
29
+
30
+ @property
31
+ def ok(self) -> bool:
32
+ return all(check.ok for check in self.checks)
33
+
34
+ @property
35
+ def failed(self) -> int:
36
+ return sum(0 if check.ok else 1 for check in self.checks)
37
+
38
+ def __len__(self) -> int:
39
+ return len(self.checks)
40
+
41
+
42
+ def callable_without_args(obj: Any) -> bool:
43
+ """True if ``obj`` is a plain callable that takes no required arguments."""
44
+ if inspect.isclass(obj):
45
+ return False
46
+ if not callable(obj):
47
+ return False
48
+ try:
49
+ signature = inspect.signature(obj)
50
+ except (TypeError, ValueError):
51
+ return False
52
+ for param in signature.parameters.values():
53
+ if param.kind in _REQUIRED and param.default is inspect.Parameter.empty:
54
+ return False
55
+ return True
56
+
57
+
58
+ def discover_exports(module: ModuleType) -> dict[str, Callable[..., Any]]:
59
+ """Public zero-arg callables of a module (in ``dir()`` order)."""
60
+ exports: dict[str, Callable[..., Any]] = {}
61
+ for name in dir(module):
62
+ if name.startswith("_"):
63
+ continue
64
+ obj = getattr(module, name)
65
+ if isinstance(obj, ModuleType):
66
+ continue
67
+ if callable_without_args(obj):
68
+ exports[name] = obj
69
+ return exports
70
+
71
+
72
+ def run_smoke(module: ModuleType, exports: list[str] | None = None, verbose: bool = False) -> SmokeResult:
73
+ """Exercise ``module``'s public API against the mock environment.
74
+
75
+ When ``exports`` is given it selects named attributes; otherwise the
76
+ module's public zero-arg callables are discovered automatically.
77
+ """
78
+ names = exports if exports is not None else sorted(discover_exports(module))
79
+ result = SmokeResult()
80
+
81
+ def _check(name: str, fn: Any) -> None:
82
+ try:
83
+ fn()
84
+ except Exception as exc: # noqa: BLE001 - a smoke run must not abort
85
+ result.checks.append(SmokeCheck(name, False, f"{type(exc).__name__}: {exc}"))
86
+ else:
87
+ result.checks.append(SmokeCheck(name, True, "ok"))
88
+
89
+ for name in names:
90
+ attr = getattr(module, name, None)
91
+ if attr is None or not callable(attr):
92
+ result.checks.append(SmokeCheck(name, False, f"missing export '{name}'"))
93
+ continue
94
+ if not callable_without_args(attr):
95
+ result.checks.append(SmokeCheck(name, True, "skipped (requires arguments)"))
96
+ continue
97
+ _check(name, attr)
98
+
99
+ if verbose:
100
+ for check in result.checks:
101
+ tag = "PASS" if check.ok else "FAIL"
102
+ print(f" [{tag}] {check.name} ({check.detail})")
103
+ print(f"\n{len(result) - result.failed}/{len(result)} checks passed")
104
+ return result