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 @@
|
|
|
1
|
+
"""Manifest model, readers (JSON/XML) and validation."""
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""JSON manifest reader: load, normalize and report errors with line hints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .model import Manifest, ManifestError, manifest_from_dict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_manifest(path: str | Path) -> Manifest:
|
|
12
|
+
"""Read and normalize a ``manifest.json`` file."""
|
|
13
|
+
file_path = Path(path)
|
|
14
|
+
try:
|
|
15
|
+
text = file_path.read_text(encoding="utf-8")
|
|
16
|
+
except OSError as exc:
|
|
17
|
+
raise ManifestError(f"cannot read manifest: {exc}", path=str(file_path)) from exc
|
|
18
|
+
return loads(text, source=str(file_path))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def loads(text: str, source: str = "<manifest.json>") -> Manifest:
|
|
22
|
+
"""Parse manifest text into a :class:`Manifest` with error context."""
|
|
23
|
+
try:
|
|
24
|
+
raw = json.loads(text)
|
|
25
|
+
except json.JSONDecodeError as exc:
|
|
26
|
+
lines = text.splitlines()
|
|
27
|
+
line_text = lines[exc.lineno - 1] if 0 < exc.lineno <= len(lines) else None
|
|
28
|
+
raise ManifestError(
|
|
29
|
+
f"invalid JSON: {exc.msg}",
|
|
30
|
+
path=source,
|
|
31
|
+
line=exc.lineno,
|
|
32
|
+
column=exc.colno,
|
|
33
|
+
line_text=line_text,
|
|
34
|
+
) from exc
|
|
35
|
+
return manifest_from_dict(raw, source=source)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def dumps(manifest: Manifest, indent: int = 2) -> str:
|
|
39
|
+
"""Serialize a manifest back to JSON text (round-trip helper)."""
|
|
40
|
+
return json.dumps(manifest.to_dict(), ensure_ascii=False, indent=indent) + "\n"
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Manifest data model shared by the JSON and XML readers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ManifestError(Exception):
|
|
11
|
+
"""A manifest could not be parsed or normalized.
|
|
12
|
+
|
|
13
|
+
Carries enough context to render ``path:line:column`` hints.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
message: str,
|
|
19
|
+
path: str | None = None,
|
|
20
|
+
line: int | None = None,
|
|
21
|
+
column: int | None = None,
|
|
22
|
+
line_text: str | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.message = message
|
|
26
|
+
self.path = path
|
|
27
|
+
self.line = line
|
|
28
|
+
self.column = column
|
|
29
|
+
self.line_text = line_text
|
|
30
|
+
|
|
31
|
+
def __str__(self) -> str:
|
|
32
|
+
parts = []
|
|
33
|
+
if self.path:
|
|
34
|
+
loc = self.path
|
|
35
|
+
if self.line is not None:
|
|
36
|
+
loc += f":{self.line}"
|
|
37
|
+
if self.column is not None:
|
|
38
|
+
loc += f":{self.column}"
|
|
39
|
+
parts.append(loc)
|
|
40
|
+
parts.append(self.message)
|
|
41
|
+
rendered = ": ".join(parts)
|
|
42
|
+
if self.line_text:
|
|
43
|
+
rendered += f"\n {self.line_text.strip()}"
|
|
44
|
+
return rendered
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Target(str, Enum):
|
|
48
|
+
"""Valid Resolve Scripts subfolders under the per-OS scripts root."""
|
|
49
|
+
|
|
50
|
+
COMP = "Comp"
|
|
51
|
+
UTILITY = "Utility"
|
|
52
|
+
TOOL = "Tool"
|
|
53
|
+
RENDER = "Render"
|
|
54
|
+
DELIVER = "Deliver"
|
|
55
|
+
EDIT = "Edit"
|
|
56
|
+
WORKFLOW_INTEGRATIONS = "WorkflowIntegrations"
|
|
57
|
+
FUSION = "Fusion"
|
|
58
|
+
ROOT = "root"
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def valid_names(cls) -> set[str]:
|
|
62
|
+
return {t.value for t in cls}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Common misspellings / near-misses → recommended fix (M6 uses this too).
|
|
66
|
+
TARGET_SUGGESTIONS: dict[str, str] = {
|
|
67
|
+
"comp": "Comp",
|
|
68
|
+
"utility": "Utility",
|
|
69
|
+
"tool": "Tool",
|
|
70
|
+
"render": "Render",
|
|
71
|
+
"deliver": "Deliver",
|
|
72
|
+
"edit": "Edit",
|
|
73
|
+
"scripts": "root",
|
|
74
|
+
"fusion": "Fusion",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class Compat:
|
|
80
|
+
"""Expo-style compatibility axis (min Resolve / min Python versions)."""
|
|
81
|
+
|
|
82
|
+
resolve: str | None = None
|
|
83
|
+
python: str | None = None
|
|
84
|
+
|
|
85
|
+
def as_dict(self) -> dict[str, str]:
|
|
86
|
+
result: dict[str, str] = {}
|
|
87
|
+
if self.resolve:
|
|
88
|
+
result["resolve"] = self.resolve
|
|
89
|
+
if self.python:
|
|
90
|
+
result["python"] = self.python
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class Release:
|
|
96
|
+
"""Published-artifact source for ``add`` / ``install <spec>``."""
|
|
97
|
+
|
|
98
|
+
owner: str = ""
|
|
99
|
+
repo: str = ""
|
|
100
|
+
url: str = ""
|
|
101
|
+
|
|
102
|
+
def as_dict(self) -> dict[str, str]:
|
|
103
|
+
result: dict[str, str] = {}
|
|
104
|
+
if self.owner:
|
|
105
|
+
result["owner"] = self.owner
|
|
106
|
+
if self.repo:
|
|
107
|
+
result["repo"] = self.repo
|
|
108
|
+
if self.url:
|
|
109
|
+
result["url"] = self.url
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def github_spec(self) -> str | None:
|
|
114
|
+
if self.owner and self.repo:
|
|
115
|
+
return f"github:{self.owner}/{self.repo}"
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class ConsolidateConfig:
|
|
121
|
+
"""Single-file build options (M3)."""
|
|
122
|
+
|
|
123
|
+
enabled: bool = True
|
|
124
|
+
output: str | None = None
|
|
125
|
+
entry: str | None = None
|
|
126
|
+
exclude: list[str] = field(default_factory=list)
|
|
127
|
+
no_comment: list[str] = field(default_factory=list)
|
|
128
|
+
|
|
129
|
+
def as_dict(self) -> dict[str, Any]:
|
|
130
|
+
return {
|
|
131
|
+
"enabled": self.enabled,
|
|
132
|
+
"output": self.output,
|
|
133
|
+
"entry": self.entry,
|
|
134
|
+
"exclude": list(self.exclude),
|
|
135
|
+
"no_comment": list(self.no_comment),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class InstallConfig:
|
|
141
|
+
"""Copy rules and install destination."""
|
|
142
|
+
|
|
143
|
+
as_directory: bool = True
|
|
144
|
+
include: list[str] = field(default_factory=list)
|
|
145
|
+
exclude: list[str] = field(default_factory=list)
|
|
146
|
+
to: str = "resolve" # "resolve" (Fusion Scripts) | "framework" (plugin dir)
|
|
147
|
+
|
|
148
|
+
def as_dict(self) -> dict[str, Any]:
|
|
149
|
+
return {
|
|
150
|
+
"as_directory": self.as_directory,
|
|
151
|
+
"include": list(self.include),
|
|
152
|
+
"exclude": list(self.exclude),
|
|
153
|
+
"to": self.to,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass
|
|
158
|
+
class Manifest:
|
|
159
|
+
"""A Resolve script manifest (or, with ``kind="extension"``, a plugin)."""
|
|
160
|
+
|
|
161
|
+
name: str
|
|
162
|
+
version: str
|
|
163
|
+
author: str = ""
|
|
164
|
+
description: str = ""
|
|
165
|
+
python: str = ""
|
|
166
|
+
package_dir: str = ""
|
|
167
|
+
entrypoint: str | None = None
|
|
168
|
+
id: str | None = None
|
|
169
|
+
compat: Compat = field(default_factory=Compat)
|
|
170
|
+
release: Release = field(default_factory=Release)
|
|
171
|
+
targets: list[str] = field(default_factory=list)
|
|
172
|
+
scripts_root: str = ""
|
|
173
|
+
consolidate: ConsolidateConfig = field(default_factory=ConsolidateConfig)
|
|
174
|
+
dependencies: list[str] = field(default_factory=list)
|
|
175
|
+
install: InstallConfig = field(default_factory=InstallConfig)
|
|
176
|
+
kind: str = "script" # "script" | "extension" (plugin)
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def is_plugin(self) -> bool:
|
|
180
|
+
return self.kind == "extension"
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def default_package_dir(self) -> str:
|
|
184
|
+
"""Effective package directory (defaults to the extension root)."""
|
|
185
|
+
return self.package_dir or self.name
|
|
186
|
+
|
|
187
|
+
def to_dict(self) -> dict[str, Any]:
|
|
188
|
+
result: dict[str, Any] = {
|
|
189
|
+
"name": self.name,
|
|
190
|
+
"version": self.version,
|
|
191
|
+
}
|
|
192
|
+
for key in ("author", "description", "python", "package_dir", "entrypoint"):
|
|
193
|
+
value = getattr(self, key)
|
|
194
|
+
if value:
|
|
195
|
+
result[key] = value
|
|
196
|
+
if self.id:
|
|
197
|
+
result["id"] = self.id
|
|
198
|
+
if self.compat.as_dict():
|
|
199
|
+
result["compat"] = self.compat.as_dict()
|
|
200
|
+
if self.release.as_dict():
|
|
201
|
+
result["release"] = self.release.as_dict()
|
|
202
|
+
if self.targets:
|
|
203
|
+
result["targets"] = list(self.targets)
|
|
204
|
+
if self.scripts_root:
|
|
205
|
+
result["scripts_root"] = self.scripts_root
|
|
206
|
+
if self.consolidate.enabled or self.consolidate.output or self.consolidate.entry:
|
|
207
|
+
result["consolidate"] = self.consolidate.as_dict()
|
|
208
|
+
if self.dependencies:
|
|
209
|
+
result["dependencies"] = list(self.dependencies)
|
|
210
|
+
if self.install.as_directory or self.install.include or self.install.exclude or self.install.to != "resolve":
|
|
211
|
+
result["install"] = self.install.as_dict()
|
|
212
|
+
if self.kind != "script":
|
|
213
|
+
result["kind"] = self.kind
|
|
214
|
+
return result
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _as_str(value: Any, key: str, source: str) -> str | None:
|
|
218
|
+
if value is None:
|
|
219
|
+
return None
|
|
220
|
+
if not isinstance(value, str):
|
|
221
|
+
raise ManifestError(f"field '{key}' must be a string, got {type(value).__name__}", path=source)
|
|
222
|
+
return value
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _as_str_list(value: Any, key: str, source: str) -> list[str]:
|
|
226
|
+
if value is None:
|
|
227
|
+
return []
|
|
228
|
+
if isinstance(value, str):
|
|
229
|
+
return [value]
|
|
230
|
+
if not isinstance(value, list):
|
|
231
|
+
raise ManifestError(f"field '{key}' must be a list of strings", path=source)
|
|
232
|
+
for item in value:
|
|
233
|
+
if not isinstance(item, str):
|
|
234
|
+
raise ManifestError(f"field '{key}' must contain only strings, got {type(item).__name__}", path=source)
|
|
235
|
+
return list(value)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _as_bool(value: Any, key: str, source: str, default: bool) -> bool:
|
|
239
|
+
if value is None:
|
|
240
|
+
return default
|
|
241
|
+
if isinstance(value, bool):
|
|
242
|
+
return value
|
|
243
|
+
if isinstance(value, str):
|
|
244
|
+
normalized = value.strip().lower()
|
|
245
|
+
if normalized in {"true", "1", "yes", "on"}:
|
|
246
|
+
return True
|
|
247
|
+
if normalized in {"false", "0", "no", "off"}:
|
|
248
|
+
return False
|
|
249
|
+
raise ManifestError(f"field '{key}' must be a boolean, got {value!r}", path=source)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _as_dict(value: Any, key: str, source: str) -> dict[str, Any]:
|
|
253
|
+
if value is None:
|
|
254
|
+
return {}
|
|
255
|
+
if not isinstance(value, dict):
|
|
256
|
+
raise ManifestError(f"field '{key}' must be an object", path=source)
|
|
257
|
+
return value
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def manifest_from_dict(raw: dict[str, Any], source: str = "<manifest>") -> Manifest:
|
|
261
|
+
"""Normalize a raw manifest dict (from JSON or XML) into a :class:`Manifest`."""
|
|
262
|
+
if not isinstance(raw, dict):
|
|
263
|
+
raise ManifestError(f"manifest must be a JSON object, got {type(raw).__name__}", path=source)
|
|
264
|
+
|
|
265
|
+
name = _as_str(raw.get("name"), "name", source)
|
|
266
|
+
version = _as_str(raw.get("version"), "version", source)
|
|
267
|
+
if not name:
|
|
268
|
+
raise ManifestError("missing required field 'name'", path=source)
|
|
269
|
+
if not version:
|
|
270
|
+
raise ManifestError("missing required field 'version'", path=source)
|
|
271
|
+
|
|
272
|
+
compat_raw = _as_dict(raw.get("compat"), "compat", source)
|
|
273
|
+
release_raw = _as_dict(raw.get("release"), "release", source)
|
|
274
|
+
cons_raw = _as_dict(raw.get("consolidate"), "consolidate", source)
|
|
275
|
+
inst_raw = _as_dict(raw.get("install"), "install", source)
|
|
276
|
+
|
|
277
|
+
install_to = _as_str(inst_raw.get("to"), "install.to", source) or "resolve"
|
|
278
|
+
if install_to not in {"resolve", "framework"}:
|
|
279
|
+
raise ManifestError(f"install.to must be 'resolve' or 'framework', got {install_to!r}", path=source)
|
|
280
|
+
|
|
281
|
+
return Manifest(
|
|
282
|
+
name=name,
|
|
283
|
+
version=version,
|
|
284
|
+
kind=_as_str(raw.get("kind"), "kind", source) or "script",
|
|
285
|
+
author=_as_str(raw.get("author"), "author", source) or "",
|
|
286
|
+
description=_as_str(raw.get("description"), "description", source) or "",
|
|
287
|
+
python=_as_str(raw.get("python"), "python", source) or "",
|
|
288
|
+
package_dir=_as_str(raw.get("package_dir"), "package_dir", source) or "",
|
|
289
|
+
entrypoint=_as_str(raw.get("entrypoint"), "entrypoint", source),
|
|
290
|
+
id=_as_str(raw.get("id"), "id", source),
|
|
291
|
+
compat=Compat(
|
|
292
|
+
resolve=_as_str(compat_raw.get("resolve"), "compat.resolve", source),
|
|
293
|
+
python=_as_str(compat_raw.get("python"), "compat.python", source),
|
|
294
|
+
),
|
|
295
|
+
release=Release(
|
|
296
|
+
owner=_as_str(release_raw.get("owner"), "release.owner", source) or "",
|
|
297
|
+
repo=_as_str(release_raw.get("repo"), "release.repo", source) or "",
|
|
298
|
+
url=_as_str(release_raw.get("url"), "release.url", source) or "",
|
|
299
|
+
),
|
|
300
|
+
targets=_as_str_list(raw.get("targets"), "targets", source),
|
|
301
|
+
scripts_root=_as_str(raw.get("scripts_root"), "scripts_root", source) or "",
|
|
302
|
+
consolidate=ConsolidateConfig(
|
|
303
|
+
enabled=_as_bool(cons_raw.get("enabled"), "consolidate.enabled", source, True),
|
|
304
|
+
output=_as_str(cons_raw.get("output"), "consolidate.output", source),
|
|
305
|
+
entry=_as_str(cons_raw.get("entry"), "consolidate.entry", source),
|
|
306
|
+
exclude=_as_str_list(cons_raw.get("exclude"), "consolidate.exclude", source),
|
|
307
|
+
no_comment=_as_str_list(cons_raw.get("no_comment"), "consolidate.no_comment", source),
|
|
308
|
+
),
|
|
309
|
+
dependencies=_as_str_list(raw.get("dependencies"), "dependencies", source),
|
|
310
|
+
install=InstallConfig(
|
|
311
|
+
as_directory=_as_bool(inst_raw.get("as_directory"), "install.as_directory", source, True),
|
|
312
|
+
include=_as_str_list(inst_raw.get("include"), "install.include", source),
|
|
313
|
+
exclude=_as_str_list(inst_raw.get("exclude"), "install.exclude", source),
|
|
314
|
+
to=install_to,
|
|
315
|
+
),
|
|
316
|
+
)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Semantic validation of a parsed manifest.
|
|
2
|
+
|
|
3
|
+
The readers guarantee *shape* (correct types, required fields). This module
|
|
4
|
+
checks *meaning*: version format, target whitelist, install-path sanity, and
|
|
5
|
+
plugin gating. Returns a list of human-readable errors; an empty list means ok.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import PurePosixPath
|
|
12
|
+
|
|
13
|
+
from .model import TARGET_SUGGESTIONS, Manifest, Target
|
|
14
|
+
|
|
15
|
+
_SEMVER = re.compile(
|
|
16
|
+
r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
|
|
17
|
+
r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?"
|
|
18
|
+
r"(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_REQUIRED = ("name", "version")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_valid_semver(version: str) -> bool:
|
|
25
|
+
return bool(_SEMVER.match(version.strip()))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def validate_target(target: str) -> str | None:
|
|
29
|
+
"""Return an error string for an unknown target name, or ``None`` if ok."""
|
|
30
|
+
if target in Target.valid_names():
|
|
31
|
+
return None
|
|
32
|
+
suggestion = TARGET_SUGGESTIONS.get(target.lower())
|
|
33
|
+
hint = f" (did you mean '{suggestion}'?)" if suggestion else ""
|
|
34
|
+
return f"unknown target '{target}'{hint}; valid: {', '.join(sorted(Target.valid_names()))}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _path_is_sane(output: str | None, field_name: str) -> list[str]:
|
|
38
|
+
if not output:
|
|
39
|
+
return []
|
|
40
|
+
path = PurePosixPath(output.replace("\\", "/"))
|
|
41
|
+
if path.is_absolute() or any(part in {".", ".."} for part in path.parts):
|
|
42
|
+
return [f"{field_name} must be a relative path with no '.'/'..' segments (got '{output}')"]
|
|
43
|
+
return []
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def validate_manifest(manifest: Manifest) -> list[str]:
|
|
47
|
+
"""Return a list of validation errors for :class:`Manifest`."""
|
|
48
|
+
errors: list[str] = []
|
|
49
|
+
|
|
50
|
+
for field_name in _REQUIRED:
|
|
51
|
+
if not getattr(manifest, field_name):
|
|
52
|
+
errors.append(f"missing required field '{field_name}'")
|
|
53
|
+
|
|
54
|
+
if manifest.version and not is_valid_semver(manifest.version):
|
|
55
|
+
errors.append(f"invalid version '{manifest.version}' (expected strict semver, e.g. 1.2.3, 1.2.3-rc1)")
|
|
56
|
+
|
|
57
|
+
if manifest.targets:
|
|
58
|
+
for target in manifest.targets:
|
|
59
|
+
error = validate_target(target)
|
|
60
|
+
if error:
|
|
61
|
+
errors.append(error)
|
|
62
|
+
elif manifest.kind == "script":
|
|
63
|
+
errors.append("no 'targets' declared (e.g. Comp/Utility) for a Resolve script")
|
|
64
|
+
|
|
65
|
+
errors.extend(_path_is_sane(manifest.consolidate.output, "consolidate.output"))
|
|
66
|
+
errors.extend(_path_is_sane(manifest.entrypoint, "entrypoint"))
|
|
67
|
+
|
|
68
|
+
if manifest.consolidate.enabled and not manifest.consolidate.entry:
|
|
69
|
+
errors.append("consolidate.enabled is true but 'consolidate.entry' is not set (package entry module)")
|
|
70
|
+
|
|
71
|
+
if manifest.kind == "extension" and manifest.release.github_spec is None and not manifest.release.url:
|
|
72
|
+
errors.append("plugin manifests need a source: set 'release.owner/repo' or 'release.url'")
|
|
73
|
+
|
|
74
|
+
return errors
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def validate_manifest_or_throw(manifest: Manifest) -> None:
|
|
78
|
+
"""Raise ``ValueError`` on the first validation error (for callers)."""
|
|
79
|
+
errors = validate_manifest(manifest)
|
|
80
|
+
if errors:
|
|
81
|
+
raise ValueError(errors[0])
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""XML manifest reader: ElementTree → same dict → same :class:`Manifest`.
|
|
2
|
+
|
|
3
|
+
The XML shape mirrors the JSON shape 1:1 so both readers normalize through the
|
|
4
|
+
same :func:`~resolve_script.manifest.model.manifest_from_dict` and can never
|
|
5
|
+
drift (parity guaranteed by construction).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import xml.etree.ElementTree as ET
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .model import Manifest, ManifestError, manifest_from_dict
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _text(el: ET.Element | None) -> str | None:
|
|
18
|
+
if el is None or el.text is None:
|
|
19
|
+
return None
|
|
20
|
+
stripped = el.text.strip()
|
|
21
|
+
return stripped or None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _attr(el: ET.Element | None, name: str) -> str | None:
|
|
25
|
+
if el is None:
|
|
26
|
+
return None
|
|
27
|
+
value = el.get(name)
|
|
28
|
+
return value.strip() if value is not None and value.strip() else None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _child(root: ET.Element, tag: str) -> str | None:
|
|
32
|
+
return _text(root.find(tag))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _child_list(root: ET.Element, wrapper: str, item: str) -> list[str]:
|
|
36
|
+
result: list[str] = []
|
|
37
|
+
container = root.find(wrapper)
|
|
38
|
+
if container is not None:
|
|
39
|
+
for el in container.findall(item):
|
|
40
|
+
value = _text(el)
|
|
41
|
+
if value is not None:
|
|
42
|
+
result.append(value)
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _to_plain(root: ET.Element, source: str) -> dict[str, Any]:
|
|
47
|
+
"""Convert an XML manifest tree into the JSON-equivalent dict shape."""
|
|
48
|
+
raw: dict[str, Any] = {}
|
|
49
|
+
for key in (
|
|
50
|
+
"name",
|
|
51
|
+
"version",
|
|
52
|
+
"kind",
|
|
53
|
+
"author",
|
|
54
|
+
"description",
|
|
55
|
+
"python",
|
|
56
|
+
"package_dir",
|
|
57
|
+
"entrypoint",
|
|
58
|
+
"id",
|
|
59
|
+
"scripts_root",
|
|
60
|
+
):
|
|
61
|
+
value = _child(root, key)
|
|
62
|
+
if value is not None:
|
|
63
|
+
raw[key] = value
|
|
64
|
+
|
|
65
|
+
targets = _child_list(root, "targets", "target")
|
|
66
|
+
if targets:
|
|
67
|
+
raw["targets"] = targets
|
|
68
|
+
|
|
69
|
+
compat = root.find("compat")
|
|
70
|
+
if compat is not None:
|
|
71
|
+
raw["compat"] = {k: _text(compat.find(k)) for k in ("resolve", "python") if _text(compat.find(k)) is not None}
|
|
72
|
+
|
|
73
|
+
release = root.find("release")
|
|
74
|
+
if release is not None:
|
|
75
|
+
raw["release"] = {
|
|
76
|
+
k: v
|
|
77
|
+
for k in ("owner", "repo", "url")
|
|
78
|
+
if (v := _text(release.find(k))) is not None
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
consolidate = root.find("consolidate")
|
|
82
|
+
if consolidate is not None:
|
|
83
|
+
cons: dict[str, Any] = {}
|
|
84
|
+
enabled = _attr(consolidate, "enabled")
|
|
85
|
+
output = _attr(consolidate, "output")
|
|
86
|
+
if enabled is not None:
|
|
87
|
+
cons["enabled"] = enabled
|
|
88
|
+
if output is not None:
|
|
89
|
+
cons["output"] = output
|
|
90
|
+
entry = _child(consolidate, "entry")
|
|
91
|
+
if entry is not None:
|
|
92
|
+
cons["entry"] = entry
|
|
93
|
+
exclude = _text_list(consolidate, "exclude")
|
|
94
|
+
no_comment = _text_list(consolidate, "no_comment")
|
|
95
|
+
if exclude:
|
|
96
|
+
cons["exclude"] = exclude
|
|
97
|
+
if no_comment:
|
|
98
|
+
cons["no_comment"] = no_comment
|
|
99
|
+
raw["consolidate"] = cons
|
|
100
|
+
|
|
101
|
+
deps = _child_list(root, "dependencies", "dependency")
|
|
102
|
+
if deps:
|
|
103
|
+
raw["dependencies"] = deps
|
|
104
|
+
|
|
105
|
+
install = root.find("install")
|
|
106
|
+
if install is not None:
|
|
107
|
+
inst: dict[str, Any] = {}
|
|
108
|
+
as_directory = _attr(install, "as_directory")
|
|
109
|
+
to = _attr(install, "to")
|
|
110
|
+
if as_directory is not None:
|
|
111
|
+
inst["as_directory"] = as_directory
|
|
112
|
+
if to is not None:
|
|
113
|
+
inst["to"] = to
|
|
114
|
+
include = _text_list(install, "include")
|
|
115
|
+
exclude = _text_list(install, "exclude")
|
|
116
|
+
if include:
|
|
117
|
+
inst["include"] = include
|
|
118
|
+
if exclude:
|
|
119
|
+
inst["exclude"] = exclude
|
|
120
|
+
raw["install"] = inst
|
|
121
|
+
|
|
122
|
+
return raw
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _text_list(container: ET.Element, tag: str) -> list[str]:
|
|
126
|
+
result: list[str] = []
|
|
127
|
+
for el in container.findall(tag):
|
|
128
|
+
value = _text(el)
|
|
129
|
+
if value is not None:
|
|
130
|
+
result.append(value)
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def load_manifest(path: str | Path) -> Manifest:
|
|
135
|
+
"""Read and normalize a ``manifest.xml`` file."""
|
|
136
|
+
file_path = Path(path)
|
|
137
|
+
try:
|
|
138
|
+
text = file_path.read_text(encoding="utf-8")
|
|
139
|
+
except OSError as exc:
|
|
140
|
+
raise ManifestError(f"cannot read manifest: {exc}", path=str(file_path)) from exc
|
|
141
|
+
return loads(text, source=str(file_path))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def loads(text: str, source: str = "<manifest.xml>") -> Manifest:
|
|
145
|
+
"""Parse manifest XML text into a :class:`Manifest` with error context."""
|
|
146
|
+
try:
|
|
147
|
+
root = ET.fromstring(text)
|
|
148
|
+
except ET.ParseError as exc:
|
|
149
|
+
line, column = getattr(exc, "position", (None, None))
|
|
150
|
+
lines = text.splitlines()
|
|
151
|
+
line_text = lines[line - 1] if line and 0 < line <= len(lines) else None
|
|
152
|
+
raise ManifestError(
|
|
153
|
+
f"invalid XML: {exc}",
|
|
154
|
+
path=source,
|
|
155
|
+
line=line,
|
|
156
|
+
column=column,
|
|
157
|
+
line_text=line_text,
|
|
158
|
+
) from exc
|
|
159
|
+
if root.tag != "manifest":
|
|
160
|
+
raise ManifestError(f"expected a <manifest> root element, got <{root.tag}>", path=source)
|
|
161
|
+
raw = _to_plain(root, source)
|
|
162
|
+
return manifest_from_dict(raw, source=source)
|