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,126 @@
|
|
|
1
|
+
"""Scaffolder for ``resolvescript create``.
|
|
2
|
+
|
|
3
|
+
Templates are plain files with ``@KEY@`` placeholders (stdlib substitution, no
|
|
4
|
+
Jinja dependency). Every placeholder-keyed file in the template tree is copied
|
|
5
|
+
into the new project; the ``{{ name }}`` package directory is instantiated to
|
|
6
|
+
the project name.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
|
|
17
|
+
TEMPLATES_DIR = Path(__file__).parent / "templates"
|
|
18
|
+
DEFAULT_VERSION = "0.1.0"
|
|
19
|
+
|
|
20
|
+
_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ScaffoldError(Exception):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def normalize_name(name: str) -> str:
|
|
28
|
+
"""Collapse a requested name into a valid Python package identifier."""
|
|
29
|
+
slug = re.sub(r"[^A-Za-z0-9_]+", "_", name).strip("_")
|
|
30
|
+
if not _NAME_RE.match(slug):
|
|
31
|
+
raise ScaffoldError(f"'{name}' cannot be used as a project/package name (got '{slug}')")
|
|
32
|
+
return slug
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def render(text: str, values: dict[str, str]) -> str:
|
|
36
|
+
"""Substitute ``@KEY@`` placeholders, preserving leftover placeholders."""
|
|
37
|
+
|
|
38
|
+
def _sub(match: re.Match[str]) -> str:
|
|
39
|
+
key = match.group(1)
|
|
40
|
+
return values.get(key, match.group(0))
|
|
41
|
+
|
|
42
|
+
return re.sub(r"@([A-Za-z0-9_]+)@", _sub, text)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _walk_templates(root: Path) -> Iterator[Path]:
|
|
46
|
+
cache_dirs = {"__pycache__"}
|
|
47
|
+
return (
|
|
48
|
+
p
|
|
49
|
+
for p in root.rglob("*")
|
|
50
|
+
if p.is_file()
|
|
51
|
+
and p.parent.name not in cache_dirs
|
|
52
|
+
and p.suffix not in {".pyc", ".pyo", ".pyd"}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_values(name: str, **overrides: str) -> dict[str, str]:
|
|
57
|
+
values = {
|
|
58
|
+
"NAME": name,
|
|
59
|
+
"VERSION": DEFAULT_VERSION,
|
|
60
|
+
"DESCRIPTION": f"{name} — a DaVinci Resolve script built with ResolveScript",
|
|
61
|
+
"AUTHOR": "",
|
|
62
|
+
"RESOLVESCRIPT_VERSION": __version__,
|
|
63
|
+
}
|
|
64
|
+
for key, value in overrides.items():
|
|
65
|
+
if value is not None:
|
|
66
|
+
values[key.upper()] = value
|
|
67
|
+
return values
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def scaffold_project(
|
|
71
|
+
name: str,
|
|
72
|
+
*,
|
|
73
|
+
destination: Path | None = None,
|
|
74
|
+
fmt: str = "json",
|
|
75
|
+
template: str = "minimal",
|
|
76
|
+
description: str = "",
|
|
77
|
+
author: str = "",
|
|
78
|
+
) -> tuple[Path, list[str]]:
|
|
79
|
+
"""Create a new extension project.
|
|
80
|
+
|
|
81
|
+
Returns ``(project_root, written_relative_paths)``.
|
|
82
|
+
"""
|
|
83
|
+
pkg_name = normalize_name(name)
|
|
84
|
+
if template != "minimal":
|
|
85
|
+
raise ScaffoldError(f"unknown template '{template}' (available: minimal)")
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
project_dir = (destination or Path.cwd()) / name
|
|
89
|
+
except TypeError as exc:
|
|
90
|
+
raise ScaffoldError(f"invalid destination: {destination!r}") from exc
|
|
91
|
+
|
|
92
|
+
project_dir = project_dir.resolve()
|
|
93
|
+
if project_dir.exists():
|
|
94
|
+
has_entries = any(project_dir.iterdir())
|
|
95
|
+
if has_entries:
|
|
96
|
+
raise ScaffoldError(f"destination {project_dir} already exists and is not empty")
|
|
97
|
+
project_dir.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
|
|
99
|
+
template_root = TEMPLATES_DIR / "extension"
|
|
100
|
+
if not template_root.is_dir():
|
|
101
|
+
raise ScaffoldError(f"template tree missing at {template_root}")
|
|
102
|
+
|
|
103
|
+
selected_manifest = f"manifest.{fmt}.j2"
|
|
104
|
+
if fmt not in {"json", "xml"}:
|
|
105
|
+
raise ScaffoldError(f"unknown manifest format '{fmt}' (json|xml)")
|
|
106
|
+
|
|
107
|
+
values = build_values(
|
|
108
|
+
pkg_name,
|
|
109
|
+
description=description,
|
|
110
|
+
author=author,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
written: list[str] = []
|
|
114
|
+
for src in _walk_templates(template_root):
|
|
115
|
+
rel = src.relative_to(template_root)
|
|
116
|
+
if rel.name == selected_manifest:
|
|
117
|
+
rel = Path("manifest." + fmt)
|
|
118
|
+
elif rel.name.startswith("manifest.") and rel.suffix == ".j2":
|
|
119
|
+
continue # the unselected manifest variant is not emitted
|
|
120
|
+
rel_text = render(str(rel), values)
|
|
121
|
+
target = project_dir / rel_text
|
|
122
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
target.write_text(render(src.read_text(encoding="utf-8"), values), encoding="utf-8")
|
|
124
|
+
written.append(str(target.relative_to(project_dir)))
|
|
125
|
+
|
|
126
|
+
return project_dir, written
|
resolve_script/semver.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Minimal SemVer 2.0 parsing and range matching (no external deps)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
_VERSION_RE = re.compile(
|
|
9
|
+
r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
|
|
10
|
+
r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?"
|
|
11
|
+
r"(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SemVerError(ValueError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Version:
|
|
21
|
+
major: int
|
|
22
|
+
minor: int = 0
|
|
23
|
+
patch: int = 0
|
|
24
|
+
prerelease: str = ""
|
|
25
|
+
|
|
26
|
+
def __post_init__(self) -> None:
|
|
27
|
+
if self.major < 0 or self.minor < 0 or self.patch < 0:
|
|
28
|
+
raise SemVerError(f"negative version component: {self!r}")
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def parse(cls, text: str) -> Version:
|
|
32
|
+
match = _VERSION_RE.match(text.strip())
|
|
33
|
+
if not match:
|
|
34
|
+
raise SemVerError(f"'{text}' is not a valid SemVer version")
|
|
35
|
+
return cls(
|
|
36
|
+
major=int(match.group(1)),
|
|
37
|
+
minor=int(match.group(2)),
|
|
38
|
+
patch=int(match.group(3)),
|
|
39
|
+
prerelease=match.group(4) or "",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def _pre_key(value: str) -> tuple[int, ...]:
|
|
44
|
+
# numeric identifiers sort lower than alphanumeric ones
|
|
45
|
+
result: list[int] = []
|
|
46
|
+
for part in value.split("."):
|
|
47
|
+
if part.isdigit():
|
|
48
|
+
result.append(0)
|
|
49
|
+
result.append(int(part))
|
|
50
|
+
else:
|
|
51
|
+
result.append(1)
|
|
52
|
+
result.append(len(part))
|
|
53
|
+
result.append(sum(ord(c) for c in part))
|
|
54
|
+
return tuple(result)
|
|
55
|
+
|
|
56
|
+
def _release_key(self) -> tuple:
|
|
57
|
+
return (self.major, self.minor, self.patch)
|
|
58
|
+
|
|
59
|
+
def __lt__(self, other: Version) -> bool: # noqa: D105
|
|
60
|
+
a, b = self._release_key(), other._release_key()
|
|
61
|
+
if a != b:
|
|
62
|
+
return a < b
|
|
63
|
+
if self.prerelease == other.prerelease:
|
|
64
|
+
return False
|
|
65
|
+
if not self.prerelease:
|
|
66
|
+
return False # release > prerelease
|
|
67
|
+
if not other.prerelease:
|
|
68
|
+
return True
|
|
69
|
+
return Version._pre_key(self.prerelease) < Version._pre_key(other.prerelease)
|
|
70
|
+
|
|
71
|
+
def __le__(self, other: Version) -> bool: # noqa: D105
|
|
72
|
+
return self == other or self < other
|
|
73
|
+
|
|
74
|
+
def __gt__(self, other: Version) -> bool: # noqa: D105
|
|
75
|
+
return not (self <= other)
|
|
76
|
+
|
|
77
|
+
def __ge__(self, other: Version) -> bool: # noqa: D105
|
|
78
|
+
return not (self < other)
|
|
79
|
+
|
|
80
|
+
def __str__(self) -> str:
|
|
81
|
+
text = f"{self.major}.{self.minor}.{self.patch}"
|
|
82
|
+
if self.prerelease:
|
|
83
|
+
text += f"-{self.prerelease}"
|
|
84
|
+
return text
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# ranges
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
_COMPARATOR_RE = re.compile(
|
|
91
|
+
r"^\s*(>=|<=|>|<|=|~|\^)?\s*"
|
|
92
|
+
r"((0|[1-9]\d*)(?:\.(0|[1-9]\d*))?(?:\.(0|[1-9]\d*))?(?:-[0-9A-Za-z.\-]+)?)"
|
|
93
|
+
r"\s*$"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _parse_lenient(raw: str) -> Version:
|
|
98
|
+
if _VERSION_RE.match(raw):
|
|
99
|
+
return Version.parse(raw)
|
|
100
|
+
return _first_three(raw)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _comparator_matches(op: str | None, version: Version, raw: str) -> bool:
|
|
104
|
+
if op == "~":
|
|
105
|
+
# npm tilde on partials: ~1 => <2.0.0 ; ~1.0 / ~1.2.3 => <1.1.0 / <1.3.0
|
|
106
|
+
target = _first_three(raw)
|
|
107
|
+
core_parts = _core_only(raw).split(".")
|
|
108
|
+
if len(core_parts) == 1:
|
|
109
|
+
upper = Version(target.major + 1, 0, 0)
|
|
110
|
+
else:
|
|
111
|
+
upper = Version(target.major, target.minor + 1, 0)
|
|
112
|
+
return version >= target and version < upper
|
|
113
|
+
if op == "^":
|
|
114
|
+
# ^1.2.3 => >=1.2.3 <2.0.0 ; ^0.2.3 => >=0.2.3 <0.3.0 ; ^0.0.3 => >=0.0.3 <0.0.4
|
|
115
|
+
target = _first_three(raw)
|
|
116
|
+
if target.major > 0:
|
|
117
|
+
upper = Version(target.major + 1, 0, 0)
|
|
118
|
+
elif target.minor > 0:
|
|
119
|
+
upper = Version(0, target.minor + 1, 0)
|
|
120
|
+
else:
|
|
121
|
+
upper = Version(0, 0, target.patch + 1)
|
|
122
|
+
return version >= target and version < upper
|
|
123
|
+
|
|
124
|
+
core = _core_only(raw)
|
|
125
|
+
if op in (None, ""):
|
|
126
|
+
if _has_prerelease(raw):
|
|
127
|
+
return version == _parse_lenient(raw) or version == Version.parse(core)
|
|
128
|
+
return version == _parse_lenient(raw)
|
|
129
|
+
|
|
130
|
+
target = _parse_lenient(core)
|
|
131
|
+
# pre-release versions only compare against the same [major,minor,patch]
|
|
132
|
+
if version.prerelease and version._release_key() != target._release_key():
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
if op == ">=":
|
|
136
|
+
return version >= target
|
|
137
|
+
if op == "<=":
|
|
138
|
+
return version <= target
|
|
139
|
+
if op == ">":
|
|
140
|
+
return version > target
|
|
141
|
+
if op == "<":
|
|
142
|
+
return version < target
|
|
143
|
+
if op == "=":
|
|
144
|
+
return version == target
|
|
145
|
+
return version >= target # ">=x" fallback
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _first_three(raw: str) -> Version:
|
|
149
|
+
parts = raw.replace("-", ".").split(".")[:3]
|
|
150
|
+
return Version(_int(parts, 0), _int(parts, 1), _int(parts, 2))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _core_only(raw: str) -> str:
|
|
154
|
+
return raw.split("-")[0].split("+")[0]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _has_prerelease(raw: str) -> bool:
|
|
158
|
+
return "-" in raw
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _int(parts: list[str], index: int) -> int:
|
|
162
|
+
if len(parts) > index and parts[index]:
|
|
163
|
+
try:
|
|
164
|
+
return int(parts[index])
|
|
165
|
+
except ValueError:
|
|
166
|
+
return 0
|
|
167
|
+
return 0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _xy_floor(raw: str) -> Version:
|
|
171
|
+
parts = raw.replace("-", ".").split(".")
|
|
172
|
+
pieces: list[int] = []
|
|
173
|
+
for part in parts:
|
|
174
|
+
if part in ("x", "X", "*"):
|
|
175
|
+
break
|
|
176
|
+
try:
|
|
177
|
+
pieces.append(int(part))
|
|
178
|
+
except ValueError:
|
|
179
|
+
break
|
|
180
|
+
return Version(pieces[0] if pieces else 0, pieces[1] if len(pieces) > 1 else 0, pieces[2] if len(pieces) > 2 else 0)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _xy_ceil(raw: str) -> Version:
|
|
184
|
+
parts = raw.replace("-", ".").split(".")
|
|
185
|
+
major = 0
|
|
186
|
+
for part in parts:
|
|
187
|
+
if part.isdigit():
|
|
188
|
+
major = int(part)
|
|
189
|
+
else:
|
|
190
|
+
break
|
|
191
|
+
return Version(major + 1 if major else 1, 0, 0)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def matches(version: Version, range_text: str) -> bool:
|
|
195
|
+
"""True if ``version`` satisfies the npm-style ``range_text``."""
|
|
196
|
+
range_text = (range_text or "*").strip()
|
|
197
|
+
if range_text in ("", "*", "x", "X"):
|
|
198
|
+
return True
|
|
199
|
+
if _VERSION_RE.match(range_text):
|
|
200
|
+
return version == Version.parse(range_text)
|
|
201
|
+
for clause in range_text.replace(",", " ").split():
|
|
202
|
+
if clause.startswith("-"):
|
|
203
|
+
continue
|
|
204
|
+
match = _COMPARATOR_RE.match(clause)
|
|
205
|
+
if match:
|
|
206
|
+
op, raw = match.group(1), match.group(2)
|
|
207
|
+
# handle x-ranges like 1.x or 1
|
|
208
|
+
if any(c in raw for c in "xX*") or raw.count(".") < 1:
|
|
209
|
+
lo = _xy_floor(raw)
|
|
210
|
+
hi = _xy_ceil(raw)
|
|
211
|
+
return lo <= version < hi
|
|
212
|
+
if not _comparator_matches(op, version, raw):
|
|
213
|
+
return False
|
|
214
|
+
elif "-" in clause and clause.count("-") == 1:
|
|
215
|
+
lo, hi = clause.split("-")
|
|
216
|
+
if not (version >= Version.parse(_core_only(lo))):
|
|
217
|
+
return False
|
|
218
|
+
if hi.strip() and not (version <= Version.parse(_core_only(hi))):
|
|
219
|
+
return False
|
|
220
|
+
return True
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def pick_best(candidates: list[str], range_text: str | None = None) -> str | None:
|
|
224
|
+
"""Return the highest version satisfying the range (or the highest overall)."""
|
|
225
|
+
best: Version | None = None
|
|
226
|
+
best_text: str | None = None
|
|
227
|
+
for text in candidates:
|
|
228
|
+
try:
|
|
229
|
+
version = Version.parse(text)
|
|
230
|
+
except SemVerError:
|
|
231
|
+
continue
|
|
232
|
+
if range_text and not matches(version, range_text):
|
|
233
|
+
continue
|
|
234
|
+
if best is None or version > best:
|
|
235
|
+
best, best_text = version, text
|
|
236
|
+
return best_text
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Extension sources: where a package comes from.
|
|
2
|
+
|
|
3
|
+
Kind matrix (mirrors the design notes):
|
|
4
|
+
|
|
5
|
+
``path`` a directory on disk (``./dir``, ``../dir``, absolute; ``file:dir``)
|
|
6
|
+
``archive`` a tar.gz/zip downloaded from a URL (or ``file:`` archive)
|
|
7
|
+
``github`` ``owner/repo`` or ``github:owner/repo`` (codeload tarball,
|
|
8
|
+
``#semver:<range>``/``#<ref>`` selection)
|
|
9
|
+
``manifest`` a URL pointing at a manifest.json (follows ``release.url``)
|
|
10
|
+
``name`` bare name -> looked up in the known-extensions table
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .archive import ARCHIVE_SUFFIXES, is_archive_path, unpack_archive # noqa: F401
|
|
14
|
+
|
|
15
|
+
__all__ = ["ARCHIVE_SUFFIXES", "is_archive_path", "unpack_archive"]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""tar.gz / zip unpacking with a single-top-level-dir strip and path safety."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tarfile
|
|
6
|
+
import zipfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ArchiveError(RuntimeError):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
ARCHIVE_SUFFIXES = (".tgz", ".tar.gz", ".zip")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_archive_path(path: str | Path) -> bool:
|
|
18
|
+
text = str(path).lower()
|
|
19
|
+
return (
|
|
20
|
+
text.endswith(".tar.gz")
|
|
21
|
+
or text.endswith(".tgz")
|
|
22
|
+
or text.endswith(".zip")
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _safe_members(names: list[str]) -> None:
|
|
27
|
+
for name in names:
|
|
28
|
+
if name.startswith(("/", "\\")) or ".." in Path(name).parts:
|
|
29
|
+
raise ArchiveError(f"archive contains unsafe path: {name!r}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _find_root(candidates: list[str]) -> str:
|
|
33
|
+
"""Return the single root layer, or "" if files sit at the archive root."""
|
|
34
|
+
roots = sorted({parts[0] for parts in (Path(c).parts for c in candidates) if parts})
|
|
35
|
+
if len(roots) != 1:
|
|
36
|
+
return ""
|
|
37
|
+
root = roots[0]
|
|
38
|
+
for name in candidates:
|
|
39
|
+
if Path(name).parts[0] != root:
|
|
40
|
+
return ""
|
|
41
|
+
return root
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def unpack_archive(archive: Path, dest_dir: Path) -> Path:
|
|
45
|
+
"""Extract ``archive`` under ``dest_dir`` and return the package root.
|
|
46
|
+
|
|
47
|
+
The package root is the directory (archive root or the single stripped
|
|
48
|
+
top-level dir) that contains ``manifest.json``.
|
|
49
|
+
"""
|
|
50
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
try:
|
|
52
|
+
if str(archive).lower().endswith(".zip"):
|
|
53
|
+
with zipfile.ZipFile(archive) as zf:
|
|
54
|
+
names = zf.namelist()
|
|
55
|
+
_safe_members(names)
|
|
56
|
+
zf.extractall(dest_dir)
|
|
57
|
+
else:
|
|
58
|
+
with tarfile.open(archive, "r:*") as tf:
|
|
59
|
+
names = tf.getnames()
|
|
60
|
+
_safe_members(names)
|
|
61
|
+
tf.extractall(dest_dir, filter="data")
|
|
62
|
+
except (tarfile.TarError, zipfile.BadZipFile, OSError) as exc:
|
|
63
|
+
raise ArchiveError(f"failed to unpack {archive}: {exc}") from exc
|
|
64
|
+
|
|
65
|
+
if (dest_dir / "manifest.json").is_file():
|
|
66
|
+
return dest_dir
|
|
67
|
+
root = _find_root(names)
|
|
68
|
+
package_root = dest_dir / root if root else dest_dir
|
|
69
|
+
if not (package_root / "manifest.json").is_file():
|
|
70
|
+
raise ArchiveError(
|
|
71
|
+
f"{archive} does not contain a package: no manifest.json "
|
|
72
|
+
"(expected at the archive root or under a single top-level dir)"
|
|
73
|
+
)
|
|
74
|
+
return package_root
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def make_archive(package_root: Path, dest: Path) -> Path:
|
|
78
|
+
"""Create ``dest`` (tar.gz) from a package dir (used by tests / packaging)."""
|
|
79
|
+
dest = dest.with_suffix(".tar.gz") if not dest.suffix else dest
|
|
80
|
+
with tarfile.open(dest, "w:gz") as tf:
|
|
81
|
+
tf.add(package_root, arcname=package_root.name)
|
|
82
|
+
return dest
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""GitHub sources: codeload tarball URLs and SemVer tag selection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..fetch import fetch, fetch_json
|
|
8
|
+
from ..semver import Version, pick_best
|
|
9
|
+
from .archive import unpack_archive
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GitSourceError(RuntimeError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def codeload_url(owner: str, repo: str, ref: str | None = None) -> str:
|
|
17
|
+
slug = ref or "HEAD"
|
|
18
|
+
return f"https://codeload.github.com/{owner}/{repo}/tar.gz/{slug}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def default_branch(owner: str, repo: str) -> str:
|
|
22
|
+
"""Best-effort default branch (falls back to HEAD on failure)."""
|
|
23
|
+
try:
|
|
24
|
+
data = fetch_json(f"https://api.github.com/repos/{owner}/{repo}")
|
|
25
|
+
except Exception:
|
|
26
|
+
return "HEAD"
|
|
27
|
+
if isinstance(data, dict):
|
|
28
|
+
default = data.get("default_branch")
|
|
29
|
+
if isinstance(default, str) and default:
|
|
30
|
+
return default
|
|
31
|
+
return "HEAD"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def list_tags(owner: str, repo: str) -> list[str]:
|
|
35
|
+
"""Return release tag names (e.g. ``v1.2.3``) from the GitHub API."""
|
|
36
|
+
url = f"https://api.github.com/repos/{owner}/{repo}/tags?per_page=100"
|
|
37
|
+
data = fetch_json(url)
|
|
38
|
+
if not isinstance(data, list):
|
|
39
|
+
raise GitSourceError(f"unexpected GitHub tags response for {owner}/{repo}")
|
|
40
|
+
return [str(item.get("name")) for item in data if isinstance(item, dict)]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def resolve_tag(owner: str, repo: str, range_text: str | None, ref: str | None):
|
|
44
|
+
"""Pick the ref to download.
|
|
45
|
+
|
|
46
|
+
An explicit ``ref`` wins. Otherwise, with ``#semver:<range>``, the highest
|
|
47
|
+
tag satisfying the range; with no range, ``HEAD``.
|
|
48
|
+
"""
|
|
49
|
+
if ref:
|
|
50
|
+
return ref
|
|
51
|
+
if not range_text:
|
|
52
|
+
return "HEAD"
|
|
53
|
+
tags = list_tags(owner, repo)
|
|
54
|
+
candidates = [t[1:] if t.startswith("v") else t for t in tags]
|
|
55
|
+
best = pick_best(candidates, range_text)
|
|
56
|
+
if best is None:
|
|
57
|
+
raise GitSourceError(
|
|
58
|
+
f"no tag of {owner}/{repo} satisfies '{range_text}' "
|
|
59
|
+
f"(tags: {', '.join(tags) or 'none'})"
|
|
60
|
+
)
|
|
61
|
+
if f"v{best}" in tags:
|
|
62
|
+
return f"v{best}"
|
|
63
|
+
return best
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def download_github(
|
|
67
|
+
owner: str,
|
|
68
|
+
repo: str,
|
|
69
|
+
*,
|
|
70
|
+
ref: str | None = None,
|
|
71
|
+
range_text: str | None = None,
|
|
72
|
+
cache_dir: Path,
|
|
73
|
+
) -> tuple[Path, str]:
|
|
74
|
+
"""Download the codeload tarball and unpack it.
|
|
75
|
+
|
|
76
|
+
Returns ``(package_root, integrity_sha256)``.
|
|
77
|
+
"""
|
|
78
|
+
selected = resolve_tag(owner, repo, range_text, ref)
|
|
79
|
+
if selected and selected != "HEAD" and selected.startswith("v"):
|
|
80
|
+
# keep the v-prefixed tag if that is what the repo calls it
|
|
81
|
+
pass
|
|
82
|
+
url = codeload_url(owner, repo, None if selected == "HEAD" else selected)
|
|
83
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
archive = cache_dir / f"{owner}-{repo}-{selected or 'head'}.tgz"
|
|
85
|
+
from ..fetch import FetchError, sha256_file
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
if not archive.is_file():
|
|
89
|
+
fetch(url=url, dest=archive)
|
|
90
|
+
except FetchError as exc:
|
|
91
|
+
raise GitSourceError(str(exc)) from exc
|
|
92
|
+
package_dir = cache_dir / f"{owner}-{repo}-{selected or 'head'}"
|
|
93
|
+
if not (package_dir / "manifest.json").is_file():
|
|
94
|
+
package_dir = unpack_archive(archive, cache_dir / "unpacked" / f"{owner}-{repo}-{selected}")
|
|
95
|
+
return package_dir, sha256_file(archive)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def tags_have_version(tags: list[str]) -> bool:
|
|
99
|
+
return any(_is_version_tag(t) for t in tags)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _is_version_tag(tag: str) -> bool:
|
|
103
|
+
try:
|
|
104
|
+
Version.parse(tag[1:] if tag.startswith("v") else tag)
|
|
105
|
+
return True
|
|
106
|
+
except Exception:
|
|
107
|
+
return False
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Known-extension table and standard naming conventions (Tauri-style)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
# The canonical conventions (used to find a package from a bare name):
|
|
6
|
+
# * dashes/underscores are treated alike when matching a bare name;
|
|
7
|
+
# * a bare name resolves to github:owner/<resolvescript-<name>> first,
|
|
8
|
+
# then github:owner/<name>;
|
|
9
|
+
# * registered extensions live here with their canonical source.
|
|
10
|
+
CONVENTIONS = (
|
|
11
|
+
# "resolvescript-<name>"
|
|
12
|
+
"resolvescript",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_TABLE: dict[str, dict[str, str]] = {
|
|
16
|
+
# name: canonical source + short description
|
|
17
|
+
"hello": {"source": "github:OseMine/resolvescript-hello", "desc": "Hello-world demo extension"},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def known_names() -> list[str]:
|
|
22
|
+
return sorted(_TABLE)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def lookup(name: str) -> dict | None:
|
|
26
|
+
"""Find a known extension by name (case/dash/underscore-insensitive)."""
|
|
27
|
+
needle = name.lower().replace("-", "_").replace(" ", "_")
|
|
28
|
+
for key, entry in _TABLE.items():
|
|
29
|
+
if key.lower().replace("-", "_") == needle:
|
|
30
|
+
return {"name": key, **entry}
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def search(query: str) -> list[dict]:
|
|
35
|
+
q = query.lower()
|
|
36
|
+
return [
|
|
37
|
+
{"name": name, **entry}
|
|
38
|
+
for name, entry in _TABLE.items()
|
|
39
|
+
if q in name.lower() or q in entry["desc"].lower() or q in entry["source"].lower()
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def canonical_source(name: str) -> str | None:
|
|
44
|
+
found = lookup(name)
|
|
45
|
+
if found:
|
|
46
|
+
return found["source"]
|
|
47
|
+
return None
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""GitHub release-asset URLs from ``manifest.release``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ReleaseError(RuntimeError):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ReleaseSpec:
|
|
14
|
+
url: str = ""
|
|
15
|
+
owner: str | None = None
|
|
16
|
+
repo: str | None = None
|
|
17
|
+
asset: str | None = None
|
|
18
|
+
tag: str | None = None
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def from_data(cls, data: dict) -> ReleaseSpec:
|
|
22
|
+
owner = data.get("owner")
|
|
23
|
+
repo = data.get("repo")
|
|
24
|
+
return cls(
|
|
25
|
+
url=str(data.get("url", "")) or None,
|
|
26
|
+
owner=str(owner) if owner else None,
|
|
27
|
+
repo=str(repo) if repo else None,
|
|
28
|
+
asset=(data.get("asset") and str(data["asset"])) or None,
|
|
29
|
+
tag=(data.get("tag") and str(data["tag"])) or None,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def asset_download_url(spec: ReleaseSpec, manifest_name: str, version: str) -> str:
|
|
34
|
+
"""Resolve ``spec`` to a direct-download URL.
|
|
35
|
+
|
|
36
|
+
``manifest.release.url`` wins when present. Otherwise build the GitHub
|
|
37
|
+
``releases/latest/download/<asset>`` URL from owner/repo; the asset name
|
|
38
|
+
defaults to ``<name>-<version>.tgz``.
|
|
39
|
+
"""
|
|
40
|
+
if spec.url:
|
|
41
|
+
return spec.url
|
|
42
|
+
if not spec.owner or not spec.repo:
|
|
43
|
+
raise ReleaseError(
|
|
44
|
+
"manifest.release needs 'url' or both 'owner' and 'repo'"
|
|
45
|
+
)
|
|
46
|
+
asset = spec.asset or f"{manifest_name}-{version}.tgz"
|
|
47
|
+
if spec.tag:
|
|
48
|
+
return (
|
|
49
|
+
f"https://github.com/{spec.owner}/{spec.repo}"
|
|
50
|
+
f"/releases/download/{spec.tag}/{asset}"
|
|
51
|
+
)
|
|
52
|
+
return (
|
|
53
|
+
f"https://github.com/{spec.owner}/{spec.repo}"
|
|
54
|
+
f"/releases/latest/download/{asset}"
|
|
55
|
+
)
|