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.
- resolve_script/__init__.py +3 -0
- resolve_script/analyze.py +277 -0
- resolve_script/cli.py +748 -0
- resolve_script/config.py +62 -0
- resolve_script/consolidate.py +604 -0
- resolve_script/fetch.py +90 -0
- resolve_script/install/__init__.py +49 -0
- resolve_script/install/discovery.py +57 -0
- resolve_script/install/installer.py +397 -0
- resolve_script/install/registry.py +106 -0
- resolve_script/manifest/__init__.py +1 -0
- resolve_script/manifest/json_reader.py +40 -0
- resolve_script/manifest/model.py +316 -0
- resolve_script/manifest/validation.py +81 -0
- resolve_script/manifest/xml_reader.py +162 -0
- resolve_script/package.py +103 -0
- resolve_script/resolver.py +204 -0
- resolve_script/sandbox/__init__.py +38 -0
- resolve_script/sandbox/api.py +393 -0
- resolve_script/sandbox/env.py +82 -0
- resolve_script/sandbox/loader.py +72 -0
- resolve_script/sandbox/repl.py +57 -0
- resolve_script/sandbox/smoke.py +104 -0
- resolve_script/scaffold.py +126 -0
- resolve_script/semver.py +236 -0
- resolve_script/sources/__init__.py +15 -0
- resolve_script/sources/archive.py +82 -0
- resolve_script/sources/git.py +107 -0
- resolve_script/sources/known.py +47 -0
- resolve_script/sources/release.py +55 -0
- resolve_script/spec.py +137 -0
- resolve_script/templates/extension/@NAME@/__init__.py +7 -0
- resolve_script/templates/extension/@NAME@/menu.py +12 -0
- resolve_script/templates/extension/@NAME@.py +13 -0
- resolve_script/templates/extension/README.md +20 -0
- resolve_script/templates/extension/conftest.py +13 -0
- resolve_script/templates/extension/manifest.json.j2 +23 -0
- resolve_script/templates/extension/manifest.xml.j2 +24 -0
- resolve_script/templates/extension/tests/test_smoke.py +26 -0
- resolve_script/templates/inapp/register.py +28 -0
- resolve_script/testing/__init__.py +6 -0
- resolve_script/testing/fixtures.py +47 -0
- resolve_script/workspace.py +66 -0
- resolvescript-0.1.2.dist-info/METADATA +146 -0
- resolvescript-0.1.2.dist-info/RECORD +49 -0
- resolvescript-0.1.2.dist-info/WHEEL +5 -0
- resolvescript-0.1.2.dist-info/entry_points.txt +2 -0
- resolvescript-0.1.2.dist-info/licenses/LICENSE +21 -0
- resolvescript-0.1.2.dist-info/top_level.txt +1 -0
resolve_script/config.py
ADDED
|
@@ -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
|
+
)
|