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/fetch.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""HTTP(S) download + SHA-256 integrity verification (stdlib only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import socket
|
|
7
|
+
import tempfile
|
|
8
|
+
import urllib.request
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FetchError(RuntimeError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Fetched:
|
|
19
|
+
path: Path
|
|
20
|
+
sha256: str
|
|
21
|
+
size: int
|
|
22
|
+
url: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def sha256_file(path: Path) -> str:
|
|
26
|
+
digest = hashlib.sha256()
|
|
27
|
+
with path.open("rb") as handle:
|
|
28
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
29
|
+
digest.update(chunk)
|
|
30
|
+
return digest.hexdigest()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def allow_remote() -> bool:
|
|
34
|
+
"""Whether remote downloads are allowed (default on; off via env)."""
|
|
35
|
+
import os
|
|
36
|
+
|
|
37
|
+
return os.environ.get("RESOLVESCRIPT_ALLOW_NETWORK", "1").lower() not in (
|
|
38
|
+
"0",
|
|
39
|
+
"false",
|
|
40
|
+
"no",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def fetch(
|
|
45
|
+
url: str,
|
|
46
|
+
*,
|
|
47
|
+
dest: Path,
|
|
48
|
+
expected_sha256: str | None = None,
|
|
49
|
+
timeout: float = 45.0,
|
|
50
|
+
) -> Fetched:
|
|
51
|
+
"""Download ``url`` to a fresh ``dest``; verify integrity when provided.
|
|
52
|
+
|
|
53
|
+
Raises :class:`FetchError` on HTTP errors, or if the downloaded bytes do
|
|
54
|
+
not match ``expected_sha256`` (the file is then removed).
|
|
55
|
+
"""
|
|
56
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
if dest.exists():
|
|
58
|
+
dest.unlink()
|
|
59
|
+
if not allow_remote():
|
|
60
|
+
raise FetchError("network downloads are disabled (RESOLVESCRIPT_ALLOW_NETWORK=0)")
|
|
61
|
+
previous = socket.getdefaulttimeout()
|
|
62
|
+
socket.setdefaulttimeout(timeout)
|
|
63
|
+
try:
|
|
64
|
+
try:
|
|
65
|
+
with urllib.request.urlopen(url, timeout=timeout) as response:
|
|
66
|
+
data = response.read()
|
|
67
|
+
except Exception as exc: # URLError, HTTPError, timeout…
|
|
68
|
+
raise FetchError(f"failed to download {url}: {exc}") from exc
|
|
69
|
+
finally:
|
|
70
|
+
socket.setdefaulttimeout(previous)
|
|
71
|
+
size = len(data)
|
|
72
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
73
|
+
if expected_sha256 and digest != expected_sha256:
|
|
74
|
+
raise FetchError(
|
|
75
|
+
f"integrity check failed for {url}: expected sha256 "
|
|
76
|
+
f"{expected_sha256}, got {digest}"
|
|
77
|
+
)
|
|
78
|
+
dest.write_bytes(data)
|
|
79
|
+
return Fetched(path=dest, sha256=digest, size=size, url=url)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def fetch_json(
|
|
83
|
+
url: str, *, timeout: float = 45.0
|
|
84
|
+
) -> object:
|
|
85
|
+
"""Download a JSON document (used for the GitHub tags API in tests)."""
|
|
86
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
87
|
+
result = fetch(url, dest=Path(tmp) / "payload", timeout=timeout)
|
|
88
|
+
import json
|
|
89
|
+
|
|
90
|
+
return json.loads(result.path.read_text("utf-8"))
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Installer core: per-OS script roots, atomic install, registry, uninstall."""
|
|
2
|
+
|
|
3
|
+
from .discovery import default_scripts_root, resolve_scripts_root, target_dir
|
|
4
|
+
from .installer import (
|
|
5
|
+
InstalledFile,
|
|
6
|
+
InstallError,
|
|
7
|
+
InstallOptions,
|
|
8
|
+
InstallResult,
|
|
9
|
+
discover_entrypoint,
|
|
10
|
+
install_package,
|
|
11
|
+
install_project,
|
|
12
|
+
select_files,
|
|
13
|
+
uninstall_package,
|
|
14
|
+
)
|
|
15
|
+
from .registry import (
|
|
16
|
+
REGISTRY_REL,
|
|
17
|
+
SCHEMA_VERSION,
|
|
18
|
+
RegistryError,
|
|
19
|
+
add_or_update_entry,
|
|
20
|
+
get_extension,
|
|
21
|
+
read_registry,
|
|
22
|
+
registry_path,
|
|
23
|
+
remove_entry,
|
|
24
|
+
write_registry,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"REGISTRY_REL",
|
|
29
|
+
"SCHEMA_VERSION",
|
|
30
|
+
"InstallError",
|
|
31
|
+
"InstallOptions",
|
|
32
|
+
"InstallResult",
|
|
33
|
+
"InstalledFile",
|
|
34
|
+
"RegistryError",
|
|
35
|
+
"add_or_update_entry",
|
|
36
|
+
"default_scripts_root",
|
|
37
|
+
"discover_entrypoint",
|
|
38
|
+
"get_extension",
|
|
39
|
+
"install_package",
|
|
40
|
+
"install_project",
|
|
41
|
+
"read_registry",
|
|
42
|
+
"registry_path",
|
|
43
|
+
"remove_entry",
|
|
44
|
+
"resolve_scripts_root",
|
|
45
|
+
"select_files",
|
|
46
|
+
"target_dir",
|
|
47
|
+
"uninstall_package",
|
|
48
|
+
"write_registry",
|
|
49
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Per-OS DaVinci Resolve Scripts-root discovery.
|
|
2
|
+
|
|
3
|
+
The Scripts root is the parent of the well-known target folders
|
|
4
|
+
(``Comp``, ``Utility``, ``Tool``, …) that appear in Resolve's Workspace menus.
|
|
5
|
+
The standard layout is ``…/Fusion/Scripts`` on every OS.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import platform
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from ..config import ENV_SCRIPTS_ROOT
|
|
15
|
+
from ..manifest.model import TARGET_SUGGESTIONS, ManifestError, Target
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def default_scripts_root() -> Path:
|
|
19
|
+
"""Return the OS-default DaVinci Resolve Scripts root (no override)."""
|
|
20
|
+
system = platform.system()
|
|
21
|
+
home = Path.home()
|
|
22
|
+
if system == "Windows":
|
|
23
|
+
appdata = os.environ.get("APPDATA") or str(home)
|
|
24
|
+
return Path(appdata) / "Blackmagic Design" / "DaVinci Resolve" / "Fusion" / "Scripts"
|
|
25
|
+
if system == "Darwin":
|
|
26
|
+
return (
|
|
27
|
+
home
|
|
28
|
+
/ "Library"
|
|
29
|
+
/ "Application Support"
|
|
30
|
+
/ "Blackmagic Design"
|
|
31
|
+
/ "DaVinci Resolve"
|
|
32
|
+
/ "Fusion"
|
|
33
|
+
/ "Scripts"
|
|
34
|
+
)
|
|
35
|
+
# Linux and anything else
|
|
36
|
+
return home / ".local" / "share" / "DaVinci Resolve" / "Fusion" / "Scripts"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve_scripts_root(override: str | Path | None = None) -> Path:
|
|
40
|
+
"""Effective Scripts root: explicit override, then env var, then OS default."""
|
|
41
|
+
if override:
|
|
42
|
+
return Path(override).expanduser()
|
|
43
|
+
env_value = os.environ.get(ENV_SCRIPTS_ROOT, "")
|
|
44
|
+
if env_value.strip():
|
|
45
|
+
return Path(env_value).expanduser()
|
|
46
|
+
return default_scripts_root()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def target_dir(scripts_root: Path, target: str) -> Path:
|
|
50
|
+
"""Validate a target name and return its folder under the Scripts root."""
|
|
51
|
+
if target == Target.ROOT.value:
|
|
52
|
+
return Path(scripts_root)
|
|
53
|
+
if target not in Target.valid_names():
|
|
54
|
+
suggestion = TARGET_SUGGESTIONS.get(target.lower())
|
|
55
|
+
hint = f" (did you mean '{suggestion}'?)" if suggestion else ""
|
|
56
|
+
raise ManifestError(f"unknown script target '{target}'{hint}")
|
|
57
|
+
return Path(scripts_root) / target
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""Atomic install of a Resolve script / extension into a Scripts root.
|
|
2
|
+
|
|
3
|
+
Design:
|
|
4
|
+
|
|
5
|
+
- Files are selected from the package tree via ``install.include`` /
|
|
6
|
+
``install.exclude`` patterns (default: everything except caches).
|
|
7
|
+
- A staging directory is built inside the destination's parent (same volume),
|
|
8
|
+
the staged entry file is ``py_compile``-checked, and only then the tree is
|
|
9
|
+
renamed into place. Any failure discards the stage — no partial installs.
|
|
10
|
+
- The registry is consulted before writing: if another extension already owns
|
|
11
|
+
a destination file, the install is refused unless ``force`` is set.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import py_compile
|
|
18
|
+
import re
|
|
19
|
+
import shutil
|
|
20
|
+
import tempfile
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from ..manifest.model import Manifest, ManifestError
|
|
26
|
+
from .registry import add_or_update_entry, get_extension, installed_at_now, read_registry
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class InstallError(Exception):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class InstallOptions:
|
|
35
|
+
"""Tunables for an install run."""
|
|
36
|
+
|
|
37
|
+
scripts_root: Path
|
|
38
|
+
targets: tuple[str, ...] = ()
|
|
39
|
+
force: bool = False
|
|
40
|
+
dry_run: bool = False
|
|
41
|
+
source: str = "local"
|
|
42
|
+
resolved: str = ""
|
|
43
|
+
integrity: str = "" # sha256 of the downloaded artifact (source verify)
|
|
44
|
+
|
|
45
|
+
def __post_init__(self) -> None:
|
|
46
|
+
self.scripts_root = Path(self.scripts_root)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class InstalledFile:
|
|
51
|
+
rel: str # path of the file relative to the extension's install container
|
|
52
|
+
sha256: str
|
|
53
|
+
size: int
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class InstallResult:
|
|
58
|
+
key: str
|
|
59
|
+
name: str
|
|
60
|
+
version: str
|
|
61
|
+
targets: tuple[str, ...]
|
|
62
|
+
files: list[InstalledFile]
|
|
63
|
+
installed_at: str
|
|
64
|
+
dry_run: bool = False
|
|
65
|
+
container: Path | None = None
|
|
66
|
+
|
|
67
|
+
def describe(self) -> list[str]:
|
|
68
|
+
lines = [
|
|
69
|
+
f"Installed {self.name} {self.version} -> {self.container}"
|
|
70
|
+
if self.container
|
|
71
|
+
else f"Would install {self.name} {self.version}",
|
|
72
|
+
f" targets: {', '.join(self.targets)}",
|
|
73
|
+
]
|
|
74
|
+
for file in self.files:
|
|
75
|
+
lines.append(f" {file.rel} ({file.size} bytes)")
|
|
76
|
+
return lines
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _glob_regex(pattern: str) -> re.Pattern[str]:
|
|
80
|
+
"""Convert a ``**``-aware glob pattern to a full-path regex."""
|
|
81
|
+
if not pattern:
|
|
82
|
+
return re.compile(r"$^")
|
|
83
|
+
if pattern == "**":
|
|
84
|
+
return re.compile(r"^.+$")
|
|
85
|
+
if pattern.endswith("/**"):
|
|
86
|
+
prefix = pattern[:-3]
|
|
87
|
+
inner = _glob_regex(prefix).pattern
|
|
88
|
+
# strip the ^ and $ anchors from inner and append the recursive tail
|
|
89
|
+
core = inner[1:-1] if inner.startswith("^") and inner.endswith("$") else inner
|
|
90
|
+
return re.compile(f"^{core}(?:/.*)?$")
|
|
91
|
+
parts = pattern.split("/")
|
|
92
|
+
rx: list[str] = []
|
|
93
|
+
for part in parts:
|
|
94
|
+
if part == "**":
|
|
95
|
+
rx.append("(?:[^/]+/)*")
|
|
96
|
+
else:
|
|
97
|
+
rx.append(part.replace("*", "[^/]*").replace("?", "[^/]"))
|
|
98
|
+
return re.compile("^" + "".join(rx) + "$")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _matches_any(patterns: list[str], rel: str) -> bool:
|
|
102
|
+
return any(_glob_regex(p).match(rel) for p in patterns)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def select_files(package_dir: Path, include: list[str] | None = None, exclude: list[str] | None = None) -> list[Path]:
|
|
106
|
+
"""Return install files (relative to ``package_dir``) selected by include/exclude."""
|
|
107
|
+
include = include or []
|
|
108
|
+
exclude = exclude or []
|
|
109
|
+
result: list[Path] = []
|
|
110
|
+
for file in sorted(package_dir.rglob("*")):
|
|
111
|
+
if not file.is_file():
|
|
112
|
+
continue
|
|
113
|
+
rel = file.relative_to(package_dir)
|
|
114
|
+
if any(part in ("__pycache__", ".git", ".resolvescript") for part in rel.parts):
|
|
115
|
+
continue
|
|
116
|
+
rel_str = rel.as_posix()
|
|
117
|
+
if include and not _matches_any(include, rel_str):
|
|
118
|
+
continue
|
|
119
|
+
if exclude and _matches_any(exclude, rel_str):
|
|
120
|
+
continue
|
|
121
|
+
result.append(rel)
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _sha256(path: Path) -> str:
|
|
126
|
+
digest = hashlib.sha256()
|
|
127
|
+
with path.open("rb") as handle:
|
|
128
|
+
for chunk in iter(lambda: handle.read(65536), b""):
|
|
129
|
+
digest.update(chunk)
|
|
130
|
+
return digest.hexdigest()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def discover_entrypoint(package_dir: Path, manifest: Manifest) -> Path | None:
|
|
134
|
+
"""Locate the runnable entry file, if any.
|
|
135
|
+
|
|
136
|
+
Priority: ``manifest.entrypoint``, a root ``<name>.py`` script, or the
|
|
137
|
+
consolidated ``dist/<consolidate.output>`` build.
|
|
138
|
+
"""
|
|
139
|
+
if manifest.entrypoint:
|
|
140
|
+
candidate = package_dir / manifest.entrypoint
|
|
141
|
+
if not candidate.is_file():
|
|
142
|
+
raise InstallError(f"manifest entrypoint not found: {candidate}")
|
|
143
|
+
return candidate
|
|
144
|
+
direct = package_dir / f"{manifest.name}.py"
|
|
145
|
+
if direct.is_file():
|
|
146
|
+
return direct
|
|
147
|
+
if manifest.consolidate.output:
|
|
148
|
+
candidate = package_dir / "dist" / manifest.consolidate.output
|
|
149
|
+
if candidate.is_file():
|
|
150
|
+
return candidate
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _compile_entry(staged_entry: Path) -> None:
|
|
155
|
+
"""Syntax-check the staged entry file; raises InstallError on failure.
|
|
156
|
+
|
|
157
|
+
The bytecode is written outside the staging tree so it never ships.
|
|
158
|
+
"""
|
|
159
|
+
try:
|
|
160
|
+
with tempfile.TemporaryDirectory(prefix="resolvescript-pycheck-") as tmp:
|
|
161
|
+
py_compile.compile(
|
|
162
|
+
str(staged_entry),
|
|
163
|
+
cfile=str(Path(tmp) / "check.pyc"),
|
|
164
|
+
doraise=True,
|
|
165
|
+
)
|
|
166
|
+
except py_compile.PyCompileError as exc:
|
|
167
|
+
raise InstallError(f"entry file is not valid Python: {exc}") from exc
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _occupied_map(registry: dict[str, Any], scripts_root: Path):
|
|
171
|
+
"""Map ``(target, relative-path)`` → owning extension name."""
|
|
172
|
+
|
|
173
|
+
occupied: dict[tuple[str, str], str] = {}
|
|
174
|
+
for ext_name, entry in registry.get("extensions", {}).items():
|
|
175
|
+
owner = entry.get("name") or ext_name
|
|
176
|
+
for target in entry.get("targets", []):
|
|
177
|
+
for rel in entry.get("files", []):
|
|
178
|
+
occupied[(target, str(rel))] = owner
|
|
179
|
+
return occupied
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def install_package(
|
|
183
|
+
package_dir: Path | str,
|
|
184
|
+
manifest: Manifest,
|
|
185
|
+
options: InstallOptions,
|
|
186
|
+
) -> InstallResult:
|
|
187
|
+
"""Install a materialized package dir (manifest + files) into a Scripts root."""
|
|
188
|
+
from .discovery import target_dir
|
|
189
|
+
|
|
190
|
+
package_dir = Path(package_dir).resolve()
|
|
191
|
+
scripts_root = Path(options.scripts_root).resolve()
|
|
192
|
+
targets = tuple(options.targets) or tuple(manifest.targets) or ("Comp",)
|
|
193
|
+
as_directory = manifest.install.as_directory
|
|
194
|
+
|
|
195
|
+
rel_files = select_files(package_dir, manifest.install.include, manifest.install.exclude)
|
|
196
|
+
entrypoint = discover_entrypoint(package_dir, manifest)
|
|
197
|
+
entry_rel = entrypoint.relative_to(package_dir).as_posix() if entrypoint is not None else None
|
|
198
|
+
if entry_rel is not None and not any(r.as_posix() == entry_rel for r in rel_files):
|
|
199
|
+
rel_files.append(Path(entry_rel))
|
|
200
|
+
|
|
201
|
+
if not as_directory and entry_rel is None:
|
|
202
|
+
raise InstallError(
|
|
203
|
+
f"cannot install {manifest.name} as a single file: no entrypoint "
|
|
204
|
+
"(set manifest 'entrypoint' or provide a <name>.py)"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# containers[target] = folder that will hold the installed files
|
|
208
|
+
containers: dict[str, Path] = {}
|
|
209
|
+
for target in targets:
|
|
210
|
+
base = target_dir(scripts_root, target)
|
|
211
|
+
containers[target] = base / manifest.name if as_directory else base
|
|
212
|
+
|
|
213
|
+
# dest layout: for directory installs preserve relative structure; for
|
|
214
|
+
# single-file installs install just the entrypoint under its basename
|
|
215
|
+
layout: dict[str, str] = {r.as_posix(): r.as_posix() for r in rel_files}
|
|
216
|
+
if not as_directory:
|
|
217
|
+
assert entry_rel is not None
|
|
218
|
+
layout = {entry_rel: Path(entry_rel).name}
|
|
219
|
+
|
|
220
|
+
dest_entries: list[tuple[Path, Path, str]] = [] # (source, dest, target)
|
|
221
|
+
for target in targets:
|
|
222
|
+
container = containers[target]
|
|
223
|
+
target_base_rel = target_dir(scripts_root, target)
|
|
224
|
+
for rel, dest_rel in layout.items():
|
|
225
|
+
src = package_dir / rel
|
|
226
|
+
dest = container / dest_rel
|
|
227
|
+
dest_entries.append((src, dest, target))
|
|
228
|
+
assert dest.is_relative_to(target_base_rel), (dest, target_base_rel)
|
|
229
|
+
|
|
230
|
+
registry = read_registry(scripts_root)
|
|
231
|
+
occupied = _occupied_map(registry, scripts_root)
|
|
232
|
+
if not options.force:
|
|
233
|
+
for _src, dest, target in dest_entries:
|
|
234
|
+
rel = dest.relative_to(target_dir(scripts_root, target)).as_posix()
|
|
235
|
+
owner = occupied.get((target, rel))
|
|
236
|
+
if owner is not None and owner != manifest.name:
|
|
237
|
+
raise InstallError(
|
|
238
|
+
f"install would overwrite '{rel}' in '{target}' which is owned by "
|
|
239
|
+
f"'{owner}' (use --force to overwrite)"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
if options.dry_run:
|
|
243
|
+
seen: set[tuple[str, str]] = set()
|
|
244
|
+
files: list[InstalledFile] = []
|
|
245
|
+
for src, dest, target in dest_entries:
|
|
246
|
+
rel = dest.relative_to(target_dir(scripts_root, target)).as_posix()
|
|
247
|
+
if (target, rel) in seen:
|
|
248
|
+
continue
|
|
249
|
+
seen.add((target, rel))
|
|
250
|
+
files.append(InstalledFile(rel, sha256="", size=src.stat().st_size))
|
|
251
|
+
return InstallResult(
|
|
252
|
+
key=manifest.id or manifest.name,
|
|
253
|
+
name=manifest.name,
|
|
254
|
+
version=manifest.version,
|
|
255
|
+
targets=targets,
|
|
256
|
+
files=files,
|
|
257
|
+
installed_at=installed_at_now(),
|
|
258
|
+
dry_run=True,
|
|
259
|
+
container=containers[targets[0]],
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
installed_files: list[InstalledFile] = []
|
|
263
|
+
for target in targets:
|
|
264
|
+
container = containers[target]
|
|
265
|
+
container.parent.mkdir(parents=True, exist_ok=True)
|
|
266
|
+
stage = Path(
|
|
267
|
+
tempfile.mkdtemp(prefix=".resolvescript-stage-", dir=str(container.parent))
|
|
268
|
+
)
|
|
269
|
+
try:
|
|
270
|
+
for src, dest, entry_target in dest_entries:
|
|
271
|
+
if entry_target != target:
|
|
272
|
+
continue
|
|
273
|
+
staged = stage / dest.relative_to(container)
|
|
274
|
+
staged.parent.mkdir(parents=True, exist_ok=True)
|
|
275
|
+
shutil.copy2(src, staged)
|
|
276
|
+
installed_files.append(
|
|
277
|
+
InstalledFile(
|
|
278
|
+
rel=dest.relative_to(container).as_posix(),
|
|
279
|
+
sha256=_sha256(src),
|
|
280
|
+
size=src.stat().st_size,
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
if entry_rel is not None:
|
|
284
|
+
staged_entry = stage / (
|
|
285
|
+
Path(entry_rel).name if not as_directory else Path(entry_rel)
|
|
286
|
+
)
|
|
287
|
+
_compile_entry(staged_entry)
|
|
288
|
+
|
|
289
|
+
if container.exists():
|
|
290
|
+
shutil.rmtree(container)
|
|
291
|
+
stage.replace(container)
|
|
292
|
+
except InstallError:
|
|
293
|
+
shutil.rmtree(stage, ignore_errors=True)
|
|
294
|
+
raise
|
|
295
|
+
except OSError as exc:
|
|
296
|
+
shutil.rmtree(stage, ignore_errors=True)
|
|
297
|
+
raise InstallError(f"failed to install into {container}: {exc}") from exc
|
|
298
|
+
|
|
299
|
+
entry_sha = _sha256(entrypoint) if entrypoint is not None else ""
|
|
300
|
+
result = InstallResult(
|
|
301
|
+
key=manifest.id or manifest.name,
|
|
302
|
+
name=manifest.name,
|
|
303
|
+
version=manifest.version,
|
|
304
|
+
targets=targets,
|
|
305
|
+
files=installed_files,
|
|
306
|
+
installed_at=installed_at_now(),
|
|
307
|
+
container=containers[targets[0]],
|
|
308
|
+
)
|
|
309
|
+
entry = {
|
|
310
|
+
"id": manifest.id or manifest.name,
|
|
311
|
+
"name": manifest.name,
|
|
312
|
+
"version": manifest.version,
|
|
313
|
+
"kind": manifest.kind,
|
|
314
|
+
"as_directory": as_directory,
|
|
315
|
+
"source": options.source
|
|
316
|
+
if options.source != "local"
|
|
317
|
+
else (manifest.release.github_spec or manifest.release.url or "local"),
|
|
318
|
+
"resolved": options.resolved,
|
|
319
|
+
"integrity": options.integrity or entry_sha,
|
|
320
|
+
"files": sorted(
|
|
321
|
+
{f"{manifest.name}/{f.rel}" if as_directory else f.rel for f in installed_files}
|
|
322
|
+
),
|
|
323
|
+
"targets": list(targets),
|
|
324
|
+
"compat": manifest.compat.as_dict(),
|
|
325
|
+
"installed_at": result.installed_at,
|
|
326
|
+
}
|
|
327
|
+
if manifest.release.owner or manifest.release.repo or manifest.release.url:
|
|
328
|
+
entry["release"] = manifest.release.as_dict()
|
|
329
|
+
add_or_update_entry(scripts_root, result.key, entry)
|
|
330
|
+
return result
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def install_project(
|
|
334
|
+
root: Path | str,
|
|
335
|
+
options: InstallOptions,
|
|
336
|
+
manifest: Manifest | None = None,
|
|
337
|
+
) -> InstallResult:
|
|
338
|
+
"""Author flow: install the project in ``root`` (``resolvescript install``)."""
|
|
339
|
+
root = Path(root).resolve()
|
|
340
|
+
if manifest is None:
|
|
341
|
+
from ..manifest.json_reader import load_manifest
|
|
342
|
+
|
|
343
|
+
manifest_path = root / "manifest.json"
|
|
344
|
+
if not manifest_path.is_file():
|
|
345
|
+
from ..manifest.xml_reader import load_manifest as load_xml
|
|
346
|
+
|
|
347
|
+
manifest_path = root / "manifest.xml"
|
|
348
|
+
if not manifest_path.is_file():
|
|
349
|
+
raise ManifestError(
|
|
350
|
+
f"no manifest.json or manifest.xml in {root} (run 'resolvescript create')"
|
|
351
|
+
)
|
|
352
|
+
manifest = load_xml(manifest_path)
|
|
353
|
+
else:
|
|
354
|
+
manifest = load_manifest(manifest_path)
|
|
355
|
+
return install_package(root, manifest, options)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def uninstall_package(name: str, options: InstallOptions) -> list[str]:
|
|
359
|
+
"""Remove an extension's installed files via the registry; returns removed paths."""
|
|
360
|
+
from .discovery import target_dir
|
|
361
|
+
|
|
362
|
+
scripts_root = Path(options.scripts_root).resolve()
|
|
363
|
+
registry = read_registry(scripts_root)
|
|
364
|
+
entry = get_extension(registry, name)
|
|
365
|
+
if entry is None:
|
|
366
|
+
raise InstallError(f"'{name}' is not installed in {scripts_root}")
|
|
367
|
+
|
|
368
|
+
removed: list[str] = []
|
|
369
|
+
as_directory = bool(entry.get("as_directory", True))
|
|
370
|
+
key = entry.get("id") or name
|
|
371
|
+
for target in entry.get("targets", []):
|
|
372
|
+
base = target_dir(scripts_root, target)
|
|
373
|
+
if as_directory:
|
|
374
|
+
container = base / (entry.get("name") or key)
|
|
375
|
+
if container.is_dir():
|
|
376
|
+
shutil.rmtree(container)
|
|
377
|
+
removed.append(str(container))
|
|
378
|
+
else:
|
|
379
|
+
for rel in entry.get("files", []):
|
|
380
|
+
path = base / str(rel)
|
|
381
|
+
if path.is_file() or path.is_symlink() and not path.exists():
|
|
382
|
+
path.unlink()
|
|
383
|
+
removed.append(str(path))
|
|
384
|
+
# clean up now-empty directories up to the Scripts root
|
|
385
|
+
current = container.parent if as_directory else base
|
|
386
|
+
while current != scripts_root and current.is_dir() and not any(current.iterdir()):
|
|
387
|
+
current.rmdir()
|
|
388
|
+
current = current.parent
|
|
389
|
+
|
|
390
|
+
remove_entry_from_registry(scripts_root, key)
|
|
391
|
+
return removed or [f"registry entry '{name}'"]
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def remove_entry_from_registry(scripts_root: Path, key: str) -> None:
|
|
395
|
+
from .registry import remove_entry
|
|
396
|
+
|
|
397
|
+
remove_entry(scripts_root, key) or remove_entry(scripts_root, key.split(":")[-1])
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Install registry: ``Scripts/.resolvescript/install.json``.
|
|
2
|
+
|
|
3
|
+
Records every installed extension so ``manage list``, ``remove`` and conflict
|
|
4
|
+
detection know exactly which files belong to whom. Written atomically.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import copy
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import tempfile
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
REGISTRY_REL = Path(".resolvescript") / "install.json"
|
|
18
|
+
SCHEMA_VERSION = 1
|
|
19
|
+
|
|
20
|
+
EMPTY_REGISTRY: dict[str, Any] = {"schema_version": SCHEMA_VERSION, "extensions": {}}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RegistryError(Exception):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def registry_path(scripts_root: Path | str) -> Path:
|
|
28
|
+
return Path(scripts_root) / REGISTRY_REL
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def read_registry(scripts_root: Path | str) -> dict[str, Any]:
|
|
32
|
+
"""Load the registry; a missing or empty file yields an empty registry."""
|
|
33
|
+
path = registry_path(scripts_root)
|
|
34
|
+
if not path.is_file():
|
|
35
|
+
return copy.deepcopy(EMPTY_REGISTRY)
|
|
36
|
+
try:
|
|
37
|
+
raw: Any = json.loads(path.read_text(encoding="utf-8"))
|
|
38
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
39
|
+
raise RegistryError(f"registry is corrupt at {path}: {exc}") from exc
|
|
40
|
+
if not isinstance(raw, dict):
|
|
41
|
+
raise RegistryError(f"registry at {path} is not a JSON object")
|
|
42
|
+
raw.setdefault("schema_version", SCHEMA_VERSION)
|
|
43
|
+
raw.setdefault("extensions", {})
|
|
44
|
+
if not isinstance(raw["extensions"], dict):
|
|
45
|
+
raise RegistryError(f"registry at {path} has a non-object 'extensions'")
|
|
46
|
+
return raw
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def write_registry(scripts_root: Path | str, registry: dict[str, Any]) -> None:
|
|
50
|
+
"""Persist the registry atomically (temp file + rename)."""
|
|
51
|
+
root = Path(scripts_root)
|
|
52
|
+
path = registry_path(root)
|
|
53
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
payload = json.dumps(registry, indent=2, sort_keys=True) + "\n"
|
|
55
|
+
fd, tmp = tempfile.mkstemp(prefix=".install-", suffix=".json", dir=path.parent)
|
|
56
|
+
try:
|
|
57
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
58
|
+
handle.write(payload)
|
|
59
|
+
Path(tmp).replace(path)
|
|
60
|
+
except Exception:
|
|
61
|
+
Path(tmp).unlink(missing_ok=True)
|
|
62
|
+
raise
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_extension(registry: dict[str, Any], key: str) -> dict[str, Any] | None:
|
|
66
|
+
extensions = registry.get("extensions", {})
|
|
67
|
+
for ext_key, entry in extensions.items():
|
|
68
|
+
if ext_key == key or entry.get("id") == key or entry.get("name") == key:
|
|
69
|
+
return entry
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def extension_key(registry: dict[str, Any], name: str, manifest_id: str | None) -> str:
|
|
74
|
+
existing = get_extension(registry, name)
|
|
75
|
+
if existing is not None and manifest_id and existing.get("id") != manifest_id:
|
|
76
|
+
return name
|
|
77
|
+
return manifest_id or name
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def add_or_update_entry(
|
|
81
|
+
scripts_root: Path | str,
|
|
82
|
+
key: str,
|
|
83
|
+
entry: dict[str, Any],
|
|
84
|
+
) -> dict[str, Any]:
|
|
85
|
+
registry = read_registry(scripts_root)
|
|
86
|
+
registry["extensions"][key] = entry
|
|
87
|
+
write_registry(scripts_root, registry)
|
|
88
|
+
return registry
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def remove_entry(scripts_root: Path | str, key: str) -> dict[str, Any] | None:
|
|
92
|
+
"""Delete an extension from the registry; returns the removed entry."""
|
|
93
|
+
registry = read_registry(scripts_root)
|
|
94
|
+
entry = registry["extensions"].pop(key, None)
|
|
95
|
+
if entry is None:
|
|
96
|
+
for ext_key, candidate in list(registry["extensions"].items()):
|
|
97
|
+
if candidate.get("id") == key or candidate.get("name") == key:
|
|
98
|
+
entry = registry["extensions"].pop(ext_key)
|
|
99
|
+
break
|
|
100
|
+
if entry is not None:
|
|
101
|
+
write_registry(scripts_root, registry)
|
|
102
|
+
return entry
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def installed_at_now() -> str:
|
|
106
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|