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 @@
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)