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