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
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Static analyzer (M6): syntax, unused imports, manifest + API coverage.
|
|
2
|
+
|
|
3
|
+
``analyze_project()`` scans an extension directory and reports:
|
|
4
|
+
|
|
5
|
+
- MANIFEST validation errors (reuses ``manifest/validation.py``)
|
|
6
|
+
- SYNTAX compile failures per .py file
|
|
7
|
+
- UNUSED_IMPORT imports bound but never referenced afterwards
|
|
8
|
+
- API_UNMOCKED calls to methods missing from the mock sandbox
|
|
9
|
+
- ATTR_KEY ``GetAttrs()``-style keys that the mock does not define
|
|
10
|
+
- TARGET target-name problems (already covered by MANIFEST)
|
|
11
|
+
|
|
12
|
+
The API surfaces are introspected from the mock itself (``sandbox/api.py``), so
|
|
13
|
+
the checker stays in sync with what ``dev``/``test`` actually provide.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import ast
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from .manifest.validation import validate_manifest
|
|
25
|
+
from .sandbox.api import (
|
|
26
|
+
FakeClip,
|
|
27
|
+
FakeComp,
|
|
28
|
+
FakeFolder,
|
|
29
|
+
FakeFusion,
|
|
30
|
+
FakeKey,
|
|
31
|
+
FakeMediaPool,
|
|
32
|
+
FakeMediaPoolItem,
|
|
33
|
+
FakeProject,
|
|
34
|
+
FakeProjectManager,
|
|
35
|
+
FakeResolve,
|
|
36
|
+
FakeSpline,
|
|
37
|
+
FakeStroke,
|
|
38
|
+
FakeTimeline,
|
|
39
|
+
FakeTool,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Conventional names practitioners use for Resolve API root objects.
|
|
43
|
+
KNOWN_ROOTS = {
|
|
44
|
+
"resolve",
|
|
45
|
+
"project",
|
|
46
|
+
"projectManager",
|
|
47
|
+
"timeline",
|
|
48
|
+
"clip",
|
|
49
|
+
"mediaPool",
|
|
50
|
+
"mediaPoolItem",
|
|
51
|
+
"mediapoolitem",
|
|
52
|
+
"folder",
|
|
53
|
+
"fusion",
|
|
54
|
+
"comp",
|
|
55
|
+
"tool",
|
|
56
|
+
"stroke",
|
|
57
|
+
"spline",
|
|
58
|
+
"key",
|
|
59
|
+
"currentComp",
|
|
60
|
+
"currentTool",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_SKIP_PARTS = {"__pycache__", ".venv", "venv", "build", "dist", ".git", "node_modules"}
|
|
64
|
+
_ATTR_KEY_RE = re.compile(r"^(TOOLS|COMPS|SPLINES|STROKES|INPOINT|OUTPOINT|EDIT|FRAME)_[A-Z0-9_]+$")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class Issue:
|
|
69
|
+
code: str
|
|
70
|
+
severity: str # "error" | "warning" | "info"
|
|
71
|
+
file: str
|
|
72
|
+
line: int
|
|
73
|
+
message: str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class Analysis:
|
|
78
|
+
issues: list[Issue] = field(default_factory=list)
|
|
79
|
+
api_used: set[str] = field(default_factory=set)
|
|
80
|
+
api_mocked: set[str] = field(default_factory=set)
|
|
81
|
+
attr_keys_used: set[str] = field(default_factory=set)
|
|
82
|
+
attr_keys_mocked: set[str] = field(default_factory=set)
|
|
83
|
+
|
|
84
|
+
def api_report(self) -> list[str]:
|
|
85
|
+
lines = [
|
|
86
|
+
f"API coverage: {len(self.api_used & self.api_mocked)} of "
|
|
87
|
+
f"{len(self.api_mocked)} mocked methods used",
|
|
88
|
+
]
|
|
89
|
+
missing = sorted(self.api_used - self.api_mocked)
|
|
90
|
+
unused = sorted(self.api_mocked - self.api_used)
|
|
91
|
+
if missing:
|
|
92
|
+
lines.append(f" called but not mocked: {', '.join(missing)}")
|
|
93
|
+
if unused:
|
|
94
|
+
lines.append(f" mocked but unused: {', '.join(unused)}")
|
|
95
|
+
attr_missing = sorted(self.attr_keys_used - self.attr_keys_mocked)
|
|
96
|
+
if attr_missing:
|
|
97
|
+
lines.append(f" attr keys not in mock: {', '.join(attr_missing)}")
|
|
98
|
+
return lines
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _mocked_api_surface() -> set[str]:
|
|
102
|
+
classes = (
|
|
103
|
+
FakeResolve,
|
|
104
|
+
FakeProjectManager,
|
|
105
|
+
FakeProject,
|
|
106
|
+
FakeTimeline,
|
|
107
|
+
FakeClip,
|
|
108
|
+
FakeMediaPoolItem,
|
|
109
|
+
FakeFolder,
|
|
110
|
+
FakeMediaPool,
|
|
111
|
+
FakeKey,
|
|
112
|
+
FakeSpline,
|
|
113
|
+
FakeStroke,
|
|
114
|
+
FakeTool,
|
|
115
|
+
FakeComp,
|
|
116
|
+
FakeFusion,
|
|
117
|
+
)
|
|
118
|
+
return {
|
|
119
|
+
name
|
|
120
|
+
for cls in classes
|
|
121
|
+
for name in dir(cls)
|
|
122
|
+
if not name.startswith("_") and callable(getattr(cls, name, None))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _mock_attr_keys() -> set[str]:
|
|
127
|
+
"""Extract keys from ``self._attrs = {...}`` in sandbox/api.py (stays in sync)."""
|
|
128
|
+
source = Path(__file__).resolve().parent / "sandbox" / "api.py"
|
|
129
|
+
tree = ast.parse(source.read_text("utf-8"))
|
|
130
|
+
keys: set[str] = set()
|
|
131
|
+
for node in ast.walk(tree):
|
|
132
|
+
if not isinstance(node, ast.Assign):
|
|
133
|
+
continue
|
|
134
|
+
for target in node.targets:
|
|
135
|
+
if not (isinstance(target, ast.Attribute) and target.attr == "_attrs"):
|
|
136
|
+
continue
|
|
137
|
+
if not (isinstance(target.value, ast.Name) and target.value.id == "self"):
|
|
138
|
+
continue
|
|
139
|
+
if isinstance(node.value, ast.Dict):
|
|
140
|
+
keys.update(
|
|
141
|
+
key.value
|
|
142
|
+
for key in node.value.keys
|
|
143
|
+
if isinstance(key, ast.Constant) and isinstance(key.value, str)
|
|
144
|
+
)
|
|
145
|
+
return keys
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _is_skip(rel: Path) -> bool:
|
|
149
|
+
return any(part in _SKIP_PARTS for part in rel.parts)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _unused_imports(tree: ast.AST, rel: Path, analysis: Analysis) -> None:
|
|
153
|
+
names_used = {
|
|
154
|
+
node.id
|
|
155
|
+
for node in ast.walk(tree)
|
|
156
|
+
if isinstance(node, ast.Name)
|
|
157
|
+
}
|
|
158
|
+
names_used.update(
|
|
159
|
+
node.attr
|
|
160
|
+
for node in ast.walk(tree)
|
|
161
|
+
if isinstance(node, ast.Attribute)
|
|
162
|
+
if isinstance(node.value, ast.Name)
|
|
163
|
+
)
|
|
164
|
+
for node in ast.walk(tree):
|
|
165
|
+
aliases: list[ast.alias] = []
|
|
166
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
167
|
+
aliases = node.names
|
|
168
|
+
for alias in aliases:
|
|
169
|
+
name = alias.asname or (alias.name.split(".")[0])
|
|
170
|
+
if name == "*":
|
|
171
|
+
continue
|
|
172
|
+
if name in {"__version__"}:
|
|
173
|
+
continue
|
|
174
|
+
count = sum(1 for token in names_used if token == name)
|
|
175
|
+
if count <= 1: # only the binding itself
|
|
176
|
+
analysis.issues.append(
|
|
177
|
+
Issue(
|
|
178
|
+
"UNUSED_IMPORT",
|
|
179
|
+
"warning",
|
|
180
|
+
rel.as_posix(),
|
|
181
|
+
node.lineno,
|
|
182
|
+
f"imported name '{name}' is never used",
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _collect_api(tree: ast.AST, analysis: Analysis) -> None:
|
|
188
|
+
for node in ast.walk(tree):
|
|
189
|
+
if not isinstance(node, ast.Call):
|
|
190
|
+
continue
|
|
191
|
+
attr = node.func
|
|
192
|
+
if not isinstance(attr, ast.Attribute):
|
|
193
|
+
continue
|
|
194
|
+
base = attr.value
|
|
195
|
+
if isinstance(base, ast.Attribute):
|
|
196
|
+
while isinstance(base, ast.Attribute):
|
|
197
|
+
base = base.value
|
|
198
|
+
if isinstance(base, ast.Name) and base.id in KNOWN_ROOTS:
|
|
199
|
+
analysis.api_used.add(attr.attr)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _collect_attr_keys(tree: ast.AST, analysis: Analysis) -> None:
|
|
203
|
+
seen_constants = {
|
|
204
|
+
node.value
|
|
205
|
+
for node in ast.walk(tree)
|
|
206
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str)
|
|
207
|
+
}
|
|
208
|
+
for text in seen_constants:
|
|
209
|
+
if _ATTR_KEY_RE.match(text) or text in {"TOOLS_RegID", "COMPS_Name"}:
|
|
210
|
+
analysis.attr_keys_used.add(text)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def analyze_project(root: Path, manifest=None) -> Analysis:
|
|
214
|
+
root = Path(root).resolve()
|
|
215
|
+
analysis = Analysis()
|
|
216
|
+
analysis.api_mocked = _mocked_api_surface()
|
|
217
|
+
analysis.attr_keys_mocked = _mock_attr_keys()
|
|
218
|
+
|
|
219
|
+
if manifest is not None:
|
|
220
|
+
for message in validate_manifest(manifest):
|
|
221
|
+
analysis.issues.append(
|
|
222
|
+
Issue("MANIFEST", "error", "manifest.json", 0, message)
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
for py in sorted(root.rglob("*.py")):
|
|
226
|
+
try:
|
|
227
|
+
rel = py.relative_to(root)
|
|
228
|
+
except ValueError:
|
|
229
|
+
rel = Path(py.name)
|
|
230
|
+
if _is_skip(rel):
|
|
231
|
+
continue
|
|
232
|
+
try:
|
|
233
|
+
tree = ast.parse(py.read_text("utf-8"), filename=str(py))
|
|
234
|
+
except SyntaxError as exc:
|
|
235
|
+
analysis.issues.append(
|
|
236
|
+
Issue(
|
|
237
|
+
"SYNTAX",
|
|
238
|
+
"error",
|
|
239
|
+
rel.as_posix(),
|
|
240
|
+
exc.lineno or 0,
|
|
241
|
+
f"syntax error: {exc.msg}",
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
continue
|
|
245
|
+
except UnicodeDecodeError:
|
|
246
|
+
continue
|
|
247
|
+
_unused_imports(tree, rel, analysis)
|
|
248
|
+
_collect_api(tree, analysis)
|
|
249
|
+
_collect_attr_keys(tree, analysis)
|
|
250
|
+
|
|
251
|
+
for name in sorted(analysis.api_used - analysis.api_mocked):
|
|
252
|
+
analysis.issues.append(
|
|
253
|
+
Issue(
|
|
254
|
+
"API_UNMOCKED",
|
|
255
|
+
"info",
|
|
256
|
+
"",
|
|
257
|
+
0,
|
|
258
|
+
f"'{name}()' is called but not provided by the mock sandbox",
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
for key in sorted(analysis.attr_keys_used - analysis.attr_keys_mocked):
|
|
262
|
+
analysis.issues.append(
|
|
263
|
+
Issue(
|
|
264
|
+
"ATTR_KEY",
|
|
265
|
+
"info",
|
|
266
|
+
"",
|
|
267
|
+
0,
|
|
268
|
+
f"attr key '{key}' is read but not defined by the mock sandbox",
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
return analysis
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def issues_to_json(analysis: Analysis) -> str:
|
|
275
|
+
return json.dumps(
|
|
276
|
+
[issue.__dict__ for issue in analysis.issues], indent=2, default=str
|
|
277
|
+
)
|