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,62 @@
1
+ """Configuration: environment overrides and user-level path discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import platform
7
+ from pathlib import Path
8
+
9
+ ENV_SCRIPTS_ROOT = "RESOLVESCRIPT_SCRIPTS_ROOT"
10
+ ENV_ALLOW_REMOTE = "RESOLVESCRIPT_ALLOW_REMOTE"
11
+
12
+ PROG = "resolvescript"
13
+
14
+
15
+ def resolve_env(name: str) -> str | None:
16
+ """Return a non-empty env var value, if set."""
17
+ value = os.environ.get(name, "")
18
+ return value.strip() if value.strip() else None
19
+
20
+
21
+ def user_config_dir() -> Path:
22
+ """Return the CLI's own config directory (not Resolve's)."""
23
+ if platform.system() == "Windows":
24
+ root = os.environ.get("APPDATA") or str(Path.home())
25
+ return Path(root) / PROG
26
+ xdg = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
27
+ return Path(xdg) / PROG
28
+
29
+
30
+ def plugins_dir() -> Path:
31
+ """Directory where framework extensions (plugins) are installed."""
32
+ return user_config_dir() / "plugins"
33
+
34
+
35
+ def scripts_root_override() -> Path | None:
36
+ """Return the user-set Resolve Scripts root override, if any."""
37
+ value = resolve_env(ENV_SCRIPTS_ROOT)
38
+ return Path(value).expanduser() if value else None
39
+
40
+
41
+ def allow_remote(default: bool = True) -> bool:
42
+ """Supply-chain policy for URL/git installs (npm 12 allow-remote analog)."""
43
+ value = resolve_env(ENV_ALLOW_REMOTE)
44
+ if value is None:
45
+ return default
46
+ return value.lower() in {"1", "true", "yes", "on"}
47
+
48
+
49
+ def normalize_path(value: str) -> Path:
50
+ """Expand user/home markers and resolve to an absolute path."""
51
+ path = Path(value).expanduser()
52
+ return path if path.is_absolute() else Path.cwd() / path
53
+
54
+
55
+ def is_editable_install() -> bool:
56
+ """True when the CLI runs from a source checkout (dev mode)."""
57
+ return "src" in str(Path(__file__).parent) and Path(__file__).parent.parent.name == "src"
58
+
59
+
60
+ def python_spec() -> str:
61
+ """Human-readable interpreter info (for diagnostics)."""
62
+ return f"{platform.python_implementation()} {platform.python_version()}"
@@ -0,0 +1,604 @@
1
+ """Single-file consolidator (M3).
2
+
3
+ Generic port of Rotoscope ``scripts/build.py``: collect a multi-file Python
4
+ package, strip internal/relative imports, order modules by dependency, hoist
5
+ ``from __future__ import`` statements, and emit one importable ``.py`` file
6
+ that DaVinci Resolve can consume (or that ``resolvescript add`` can install).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ast
12
+ import re
13
+ import textwrap
14
+ from dataclasses import dataclass
15
+ from fnmatch import fnmatch
16
+ from pathlib import Path
17
+
18
+ STDLIB_MODULES = frozenset(
19
+ {
20
+ "abc", "argparse", "array", "ast", "asyncio", "base64", "bisect",
21
+ "builtins", "bz2", "collections", "concurrent", "configparser",
22
+ "contextlib", "contextvars", "copy", "csv", "ctypes", "dataclasses",
23
+ "datetime", "decimal", "dbm", "dis", "email", "enum", "errno",
24
+ "fnmatch", "fractions", "functools", "gc", "getopt", "glob", "gzip",
25
+ "hashlib", "heapq", "hmac", "html", "http", "importlib", "inspect",
26
+ "io", "ipaddress", "itertools", "json", "keyword", "logging", "lzma",
27
+ "marshal", "math", "mimetypes", "multiprocessing", "netrc", "numbers",
28
+ "operator", "optparse", "os", "pathlib", "pickle", "platform",
29
+ "plistlib", "pprint", "queue", "random", "re", "reprlib", "select",
30
+ "selectors", "secrets", "shelve", "shutil", "signal", "site", "socket",
31
+ "sqlite3", "ssl", "stat", "statistics", "string", "struct", "subprocess",
32
+ "sys", "tarfile", "tempfile", "textwrap", "threading", "time", "token",
33
+ "tokenize", "traceback", "types", "typing", "unicodedata", "urllib",
34
+ "uuid", "warnings", "weakref", "xml", "zipfile", "zlib",
35
+ }
36
+ )
37
+
38
+
39
+ class ConsolidateError(Exception):
40
+ """A package could not be consolidated."""
41
+
42
+
43
+ @dataclass
44
+ class BuildConfig:
45
+ """Parameters for a consolidation run."""
46
+
47
+ package_root: Path
48
+ output: Path
49
+ package_name: str = ""
50
+ entry: str | None = None
51
+ exclude: tuple[str, ...] = ()
52
+ no_comment: tuple[str, ...] = ()
53
+ noqa: bool = True
54
+
55
+ def __post_init__(self) -> None:
56
+ self.package_root = Path(self.package_root)
57
+ self.output = Path(self.output)
58
+ if not self.package_name:
59
+ self.package_name = self.package_root.name
60
+
61
+
62
+ @dataclass
63
+ class ConsolidateResult:
64
+ """Outcome of a consolidation run."""
65
+
66
+ output: Path
67
+ modules: tuple[str, ...]
68
+ cycles: tuple[str, ...]
69
+ size: int
70
+
71
+
72
+ def module_dotted_name(file_path: Path, package_root: Path, package_name: str) -> str:
73
+ """Return the dotted module name for a file under the package root."""
74
+ rel = file_path.relative_to(package_root)
75
+ parts = list(rel.with_suffix("").parts)
76
+ if parts and parts[-1] == "__init__":
77
+ parts = parts[:-1]
78
+ return ".".join([package_name, *parts])
79
+
80
+
81
+ def _matches_pattern(pattern: str, rel_str: str) -> bool:
82
+ """Match an exclude/no_comment pattern against a posix relative path."""
83
+ if any(ch in pattern for ch in "*?["):
84
+ return fnmatch(rel_str, pattern)
85
+ return rel_str == pattern or rel_str.startswith(pattern.rstrip("/") + "/")
86
+
87
+
88
+ def collect_python_files(root: Path, exclude: tuple[str, ...] = ()) -> list[Path]:
89
+ """Collect package ``.py`` files, sorted for a stable build."""
90
+ files = []
91
+ for py_file in root.rglob("*.py"):
92
+ rel = py_file.relative_to(root)
93
+ if "__pycache__" in rel.parts:
94
+ continue
95
+ rel_str = rel.as_posix()
96
+ if any(_matches_pattern(pattern, rel_str) for pattern in exclude):
97
+ continue
98
+ files.append(py_file)
99
+ return sorted(files, key=lambda f: (len(f.relative_to(root).parts), str(f)))
100
+
101
+
102
+ def extract_imports(file_path: Path) -> set[str]:
103
+ """Return the top-level module names imported by a file.
104
+
105
+ Used to hoist standard-library imports to the top of the output.
106
+ """
107
+ imports: set[str] = set()
108
+ try:
109
+ tree = ast.parse(file_path.read_text(encoding="utf-8"))
110
+ except (SyntaxError, UnicodeDecodeError):
111
+ return imports
112
+ for node in ast.walk(tree):
113
+ if isinstance(node, ast.Import):
114
+ for alias in node.names:
115
+ imports.add(alias.name.split(".")[0])
116
+ elif isinstance(node, ast.ImportFrom) and node.module:
117
+ imports.add(node.module.split(".")[0])
118
+ return imports
119
+
120
+
121
+ def _import_candidates(node: ast.AST, mod_name: str) -> list[str]:
122
+ """Candidate module names an import node may reference (longest first)."""
123
+ candidates: list[str] = []
124
+ if isinstance(node, ast.Import):
125
+ for alias in node.names:
126
+ if alias.name not in candidates:
127
+ candidates.append(alias.name)
128
+ return candidates
129
+
130
+ base_parts = mod_name.split(".")
131
+ if isinstance(node, ast.ImportFrom) and node.level:
132
+ for _ in range(node.level):
133
+ if base_parts:
134
+ base_parts.pop()
135
+ if node.module:
136
+ parent = ".".join(base_parts)
137
+ candidates.append(f"{parent}.{node.module}" if parent else node.module)
138
+ else:
139
+ parent = ".".join(base_parts)
140
+ for alias in node.names:
141
+ candidates.append(f"{parent}.{alias.name}" if parent else alias.name)
142
+ elif isinstance(node, ast.ImportFrom):
143
+ candidates.append(node.module or "")
144
+ base = node.module or ""
145
+ for alias in node.names:
146
+ candidates.append(f"{base}.{alias.name}" if base else alias.name)
147
+ return candidates
148
+
149
+
150
+ def local_file_dependencies(
151
+ file_path: Path,
152
+ package_root: Path,
153
+ package_name: str,
154
+ known_modules: set[str],
155
+ ) -> set[str]:
156
+ """Return the known modules a file depends on (for ordering)."""
157
+ try:
158
+ tree = ast.parse(file_path.read_text(encoding="utf-8"))
159
+ except (SyntaxError, UnicodeDecodeError) as exc:
160
+ raise ConsolidateError(f"cannot parse {file_path}: {exc}") from exc
161
+ mod_name = module_dotted_name(file_path, package_root, package_name)
162
+ deps: set[str] = set()
163
+ for node in ast.walk(tree):
164
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
165
+ for candidate in _import_candidates(node, mod_name):
166
+ if candidate != mod_name and candidate in known_modules:
167
+ deps.add(candidate)
168
+ return deps
169
+
170
+
171
+ def order_modules(names_to_deps: dict[str, set[str]]) -> tuple[list[str], tuple[str, ...]]:
172
+ """Order modules so dependencies come first; cycles fall back to sorted."""
173
+ remaining = set(names_to_deps)
174
+ ordered: list[str] = []
175
+ resolved: set[str] = set()
176
+ cycles: set[str] = set()
177
+ while remaining:
178
+ batch = [
179
+ mod
180
+ for mod in sorted(remaining)
181
+ if (names_to_deps[mod] - {mod}) <= resolved
182
+ ]
183
+ if batch:
184
+ ordered.extend(batch)
185
+ resolved.update(batch)
186
+ remaining.difference_update(batch)
187
+ continue
188
+ batch = sorted(remaining)
189
+ ordered.extend(batch)
190
+ cycles.update(batch)
191
+ remaining.clear()
192
+ return ordered, tuple(sorted(cycles))
193
+
194
+
195
+ def _statement_open(joined: str) -> bool:
196
+ """True if the joined statement still needs continuation lines."""
197
+ if joined.count("(") > joined.count(")"):
198
+ return True
199
+ if joined.count("[") > joined.count("]"):
200
+ return True
201
+ return bool(joined.rstrip().endswith(("\\", ",")))
202
+
203
+
204
+ def _is_internal_import_line(line: str, prefix: str) -> bool:
205
+ """True if a line begins an internal (relative or package) import."""
206
+ stripped = line.strip()
207
+ if stripped.startswith("from .") or stripped.startswith("import ."):
208
+ return True
209
+ return (
210
+ stripped
211
+ in {
212
+ f"from {prefix}",
213
+ f"import {prefix}",
214
+ }
215
+ or stripped.startswith(f"from {prefix}.")
216
+ or stripped.startswith(f"from {prefix} ")
217
+ or stripped.startswith(f"import {prefix}.")
218
+ or stripped.startswith(f"import {prefix} ")
219
+ )
220
+
221
+
222
+ def _leading_ws(line: str) -> str:
223
+ return line[: len(line) - len(line.lstrip())]
224
+
225
+
226
+ def _comment_lines(buf: list[str]) -> list[str]:
227
+ """Neutralize an internal-import statement (safe inside empty blocks)."""
228
+ indent = _leading_ws(buf[0])
229
+ if indent:
230
+ return [f"{indent}pass # {buf[0].strip()}"]
231
+ return ["# " + line for line in buf]
232
+
233
+
234
+ def _rewrite_or_comment(
235
+ buf: list[str],
236
+ known: set[str],
237
+ mod_name: str,
238
+ prefix: str,
239
+ ) -> tuple[list[str], set[str]]:
240
+ """Decide how to neutralize an internal import.
241
+
242
+ Returns ``(inline_lines, hoisted_binds)``. Member-level imports (``from
243
+ pkg.core import VALUE``) are commented out — the members already live in
244
+ the shared namespace from the inlined module. Module-binding imports
245
+ (``from . import core``, ``import pkg.util``) are commented out AND a
246
+ namespace binding is hoisted to the top of the output so ``core.attr()``
247
+ style access keeps working. When ``known`` is not given, everything is
248
+ commented (Rotoscope-compatible default).
249
+ """
250
+ if not known:
251
+ return _comment_lines(buf), set()
252
+
253
+ joined = "\n".join(buf).lstrip("\n")
254
+ parsed = ast.parse(textwrap.dedent(joined)) if joined[:1] in (" ", "\t") else ast.parse(joined)
255
+ node = parsed.body[0] if parsed.body else None
256
+
257
+ if node is None:
258
+ return _comment_lines(buf), set()
259
+
260
+ if isinstance(node, ast.Import):
261
+ binds: set[str] = set()
262
+ for alias in node.names:
263
+ dotted = alias.name
264
+ if dotted != prefix and not dotted.startswith(prefix + "."):
265
+ continue
266
+ if alias.asname:
267
+ binds.add(f"{alias.asname} = _rs_shared")
268
+ else:
269
+ top, _, rest = dotted.partition(".")
270
+ binds.add(f"{top} = _rs_shared")
271
+ if rest:
272
+ binds.add(f"{dotted} = _rs_shared")
273
+ return _comment_lines(buf), binds
274
+
275
+ if isinstance(node, ast.ImportFrom):
276
+ if node.module is None and node.level:
277
+ binds = {
278
+ f"{alias.asname or alias.name} = _rs_shared" for alias in node.names
279
+ }
280
+ return _comment_lines(buf), binds
281
+
282
+ module = node.module
283
+ if node.level:
284
+ parts = mod_name.split(".")
285
+ for _ in range(node.level):
286
+ if parts:
287
+ parts.pop()
288
+ module = ".".join([*parts, node.module]) if parts and node.module else None
289
+ if not module or (module != prefix and not module.startswith(prefix + ".")):
290
+ return _comment_lines(buf), set()
291
+ binds = {
292
+ f"{alias.asname or alias.name} = _rs_shared"
293
+ for alias in node.names
294
+ if f"{module}.{alias.name}" in known
295
+ }
296
+ return _comment_lines(buf), binds
297
+
298
+ return _comment_lines(buf), set()
299
+
300
+
301
+ def _strip_internal_imports_ex(
302
+ content: str,
303
+ prefix: str,
304
+ known: set[str] | None = None,
305
+ mod_name: str = "",
306
+ ) -> tuple[str, set[str]]:
307
+ """Comment/rewrite internal imports, returning hoisted namespace binds."""
308
+ lines = content.splitlines()
309
+ out: list[str] = []
310
+ binds: set[str] = set()
311
+ buf: list[str] = []
312
+ in_block = False
313
+
314
+ for line in lines:
315
+ if not in_block and _is_internal_import_line(line, prefix):
316
+ buf = [line]
317
+ in_block = True
318
+ elif in_block:
319
+ buf.append(line)
320
+
321
+ if in_block:
322
+ if _statement_open("\n".join(buf)):
323
+ continue
324
+ inline, statement_binds = _rewrite_or_comment(buf, known or set(), mod_name, prefix)
325
+ out.extend(inline)
326
+ binds.update(statement_binds)
327
+ buf = []
328
+ in_block = False
329
+ else:
330
+ out.append(line)
331
+
332
+ if buf:
333
+ inline, statement_binds = _rewrite_or_comment(buf, known or set(), mod_name, prefix)
334
+ out.extend(inline)
335
+ binds.update(statement_binds)
336
+ return "\n".join(out), binds
337
+
338
+
339
+ def strip_internal_imports(
340
+ content: str,
341
+ prefix: str,
342
+ known: set[str] | None = None,
343
+ mod_name: str = "",
344
+ ) -> str:
345
+ """Rewrite internal imports so the flat output stays importable."""
346
+ text, _ = _strip_internal_imports_ex(content, prefix, known, mod_name)
347
+ return text
348
+
349
+
350
+ def read_module_content(
351
+ file_path: Path,
352
+ package_root: Path,
353
+ prefix: str,
354
+ no_comment: bool = False,
355
+ known: set[str] | None = None,
356
+ mod_name: str = "",
357
+ ) -> tuple[str, list[str], set[str]]:
358
+ """Read and preprocess a module for consolidation.
359
+
360
+ Returns ``(content, future_imports, binds)``: the ``__future__`` imports
361
+ are extracted for re-emission at the top of the file, and ``binds`` are the
362
+ namespace-binding lines that must be hoisted before any module body runs.
363
+ """
364
+ content = file_path.read_text(encoding="utf-8")
365
+ future_imports = re.findall(r"^from __future__ import .+$", content, flags=re.MULTILINE)
366
+ content = re.sub(r"^#!.*\n", "", content)
367
+ content = re.sub(r"^# -\*- coding:.*-\*-\n?", "", content)
368
+ content = re.sub(r"^from __future__ import .+$\n?", "", content, flags=re.MULTILINE)
369
+ if no_comment or not prefix:
370
+ return content, future_imports, set()
371
+ content, binds = _strip_internal_imports_ex(content, prefix, known, mod_name)
372
+ return content, future_imports, binds
373
+
374
+
375
+ def _is_noop(stmt: ast.stmt) -> bool:
376
+ if isinstance(stmt, ast.Pass):
377
+ return True
378
+ return bool(isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant))
379
+
380
+
381
+ def _is_main_test(node: ast.AST) -> bool:
382
+ """True for ``__name__ == "__main__"`` (in either order)."""
383
+ if not isinstance(node, ast.Compare):
384
+ return False
385
+ if len(node.ops) != 1 or len(node.comparators) != 1:
386
+ return False
387
+ left, op, right = node.left, node.ops[0], node.comparators[0]
388
+ main_name = isinstance(left, ast.Name) and left.id == "__name__"
389
+ string_right = isinstance(right, ast.Constant) and right.value == "__main__"
390
+ string_left = isinstance(left, ast.Constant) and left.value == "__main__"
391
+ name_right = isinstance(right, ast.Name) and right.id == "__name__"
392
+ return (main_name and string_right or string_left and name_right) and isinstance(op, ast.Eq)
393
+
394
+
395
+ def _find_main_blocks(tree: ast.Module) -> list[ast.If]:
396
+ """Top-level ``if __name__ == "__main__":`` blocks that would break a library."""
397
+ blocks: list[ast.If] = []
398
+ for stmt in tree.body:
399
+ if isinstance(stmt, ast.If) and _is_main_test(stmt.test) and any(
400
+ not _is_noop(s) for s in stmt.body
401
+ ):
402
+ blocks.append(stmt)
403
+ return blocks
404
+
405
+
406
+ def _entry_candidates(
407
+ entry: str, package_root: Path, package_name: str
408
+ ) -> list[str]:
409
+ """Dotted-name candidates for a manifest ``entry`` (name or path form).
410
+
411
+ Path-form entries (e.g. ``pkg/__init__.py``) may be written relative to
412
+ either the package root or the project root; emit both candidates and let
413
+ the caller pick the one that exists.
414
+ """
415
+ normalized = entry.replace("\\", "/")
416
+ if "/" not in normalized:
417
+ base = normalized[:-3] if normalized.endswith(".py") else normalized
418
+ return [base if base.startswith(package_name) else f"{package_name}.{base}"]
419
+ path = normalized[:-3] if normalized.endswith(".py") else normalized
420
+ candidates: list[str] = []
421
+ for base in (package_root, package_root.parent):
422
+ try:
423
+ candidates.append(module_dotted_name(base / path, package_root, package_name))
424
+ except ValueError:
425
+ continue
426
+ return candidates
427
+
428
+
429
+ def _module_is_no_comment(
430
+ file_path: Path,
431
+ package_root: Path,
432
+ package_name: str,
433
+ no_comment: tuple[str, ...],
434
+ ) -> bool:
435
+ dotted = module_dotted_name(file_path, package_root, package_name)
436
+ rel = file_path.relative_to(package_root).as_posix()
437
+ rel_stem = rel[:-3] if rel.endswith(".py") else rel
438
+ return any(dotted == item or rel == item or rel_stem == item for item in no_comment)
439
+
440
+
441
+ def consolidate(config: BuildConfig) -> ConsolidateResult:
442
+ """Consolidate ``config.package_root`` into ``config.output``."""
443
+ files = collect_python_files(config.package_root, config.exclude)
444
+ if not files:
445
+ raise ConsolidateError(
446
+ f"no Python files found under {config.package_root} "
447
+ f"(checked exclude patterns: {', '.join(config.exclude) or 'none'})"
448
+ )
449
+
450
+ known = {
451
+ module_dotted_name(f, config.package_root, config.package_name): f
452
+ for f in files
453
+ }
454
+ if config.entry:
455
+ entry = next(
456
+ (
457
+ candidate
458
+ for candidate in _entry_candidates(
459
+ config.entry, config.package_root, config.package_name
460
+ )
461
+ if candidate in known
462
+ ),
463
+ None,
464
+ )
465
+ if entry is None:
466
+ raise ConsolidateError(f"consolidate.entry '{config.entry}' is not a module in the package")
467
+ config.entry = entry
468
+
469
+ for file_path in files:
470
+ try:
471
+ tree = ast.parse(file_path.read_text(encoding="utf-8"))
472
+ except (SyntaxError, UnicodeDecodeError) as exc:
473
+ raise ConsolidateError(f"cannot parse {file_path}: {exc}") from exc
474
+ if _find_main_blocks(tree):
475
+ raise ConsolidateError(
476
+ f"module {file_path} has a standalone 'if __name__ == \"__main__\"' "
477
+ "block; the consolidated output must be an importable library"
478
+ )
479
+
480
+ names_to_deps = {
481
+ module_dotted_name(f, config.package_root, config.package_name): local_file_dependencies(
482
+ f, config.package_root, config.package_name, set(known)
483
+ )
484
+ for f in files
485
+ }
486
+ ordered, cycles = order_modules(names_to_deps)
487
+
488
+ all_imports: set[str] = set()
489
+ all_future: set[str] = set()
490
+ for file_path in files:
491
+ all_imports.update(extract_imports(file_path))
492
+ for future in re.findall(
493
+ r"^from __future__ import .+$",
494
+ file_path.read_text(encoding="utf-8"),
495
+ flags=re.MULTILINE,
496
+ ):
497
+ all_future.add(future)
498
+
499
+ output_lines: list[str] = []
500
+ output_lines.append('"""')
501
+ output_lines.append(f"{config.package_name} - Consolidated single-file module for DaVinci Resolve.")
502
+ output_lines.append("")
503
+ output_lines.append("This file is auto-generated by 'resolvescript build'/'consolidate'.")
504
+ output_lines.append(
505
+ "Do not edit directly - edit the source modules under "
506
+ f"{config.package_root.as_posix()} instead."
507
+ )
508
+ output_lines.append('"""')
509
+ output_lines.append("")
510
+
511
+ if all_future:
512
+ for future in sorted(all_future):
513
+ output_lines.append(future)
514
+ output_lines.append("")
515
+
516
+ if config.noqa:
517
+ output_lines.append("# ruff: noqa: E402,F401,F403,F405")
518
+ output_lines.append("# flake8: noqa: E402,F401,F403,F405")
519
+ output_lines.append("")
520
+
521
+ root = config.package_root
522
+ bindings: set[str] = set()
523
+ processed: list[tuple[str, str]] = []
524
+ for mod in ordered:
525
+ file_path = known[mod]
526
+ no_comment = _module_is_no_comment(
527
+ file_path, root, config.package_name, config.no_comment
528
+ )
529
+ content, _, binds = read_module_content(
530
+ file_path,
531
+ root,
532
+ config.package_name,
533
+ no_comment,
534
+ known=set(known),
535
+ mod_name=mod,
536
+ )
537
+ bindings.update(binds)
538
+ processed.append((mod, content))
539
+
540
+ if bindings:
541
+ output_lines.append("import sys as _rs_sys")
542
+ output_lines.append("_rs_shared = _rs_sys.modules[__name__]")
543
+ output_lines.extend(sorted(bindings))
544
+ output_lines.append("")
545
+
546
+ stdlib_imports = sorted(all_imports & STDLIB_MODULES)
547
+ if stdlib_imports:
548
+ output_lines.append("# Standard library imports")
549
+ output_lines.extend(f"import {name}" for name in stdlib_imports)
550
+ output_lines.append("")
551
+
552
+ for mod, content in processed:
553
+ output_lines.append(f"# ==== Module: {mod} ====")
554
+ output_lines.append(content)
555
+ output_lines.append("")
556
+
557
+ text = "\n".join(output_lines).rstrip() + "\n"
558
+ config.output.parent.mkdir(parents=True, exist_ok=True)
559
+ config.output.write_text(text, encoding="utf-8")
560
+
561
+ try:
562
+ compile(text, str(config.output), "exec")
563
+ except SyntaxError as exc:
564
+ raise ConsolidateError(f"consolidated output failed syntax check: {exc}") from exc
565
+
566
+ return ConsolidateResult(
567
+ output=config.output,
568
+ modules=tuple(ordered),
569
+ cycles=cycles,
570
+ size=config.output.stat().st_size,
571
+ )
572
+
573
+
574
+ def config_from_manifest(
575
+ project_root: Path,
576
+ manifest: object,
577
+ output_override: Path | None = None,
578
+ ) -> BuildConfig:
579
+ """Build a :class:`BuildConfig` from a manifest's ``consolidate`` section.
580
+
581
+ The ``manifest`` argument only needs the ``name``, ``package_dir`` and
582
+ ``consolidate`` attributes, so no manifest module import is required here.
583
+ """
584
+ consolidate_cfg = manifest.consolidate
585
+ package_root = project_root / manifest.default_package_dir
586
+ if not package_root.is_dir():
587
+ package_root = project_root
588
+ output = output_override or project_root / "dist" / (consolidate_cfg.output or f"{manifest.name}.py")
589
+ return BuildConfig(
590
+ package_root=package_root,
591
+ output=output,
592
+ entry=consolidate_cfg.entry,
593
+ exclude=tuple(consolidate_cfg.exclude),
594
+ no_comment=tuple(consolidate_cfg.no_comment),
595
+ )
596
+
597
+
598
+ def summarize(result: ConsolidateResult, config: BuildConfig) -> str:
599
+ """Human-readable one-liner summary for CLI output."""
600
+ note = " (circular deps emitted in sorted order)" if result.cycles else ""
601
+ return (
602
+ f"Consolidated {len(result.modules)} module(s) into {result.output} "
603
+ f"({result.size} bytes){note}"
604
+ )