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,103 @@
|
|
|
1
|
+
"""Packaging an extension into a release artifact (M7).
|
|
2
|
+
|
|
3
|
+
Produces a ``<name>-<version>.tgz`` in the exact shape ``add``/``install <spec>``
|
|
4
|
+
consumes: single top-level layer with ``manifest.json``, entry ``<name>.py`` and
|
|
5
|
+
the ``<name>/`` package directory (no wrapper folder). A ``SHA256SUMS.txt`` is
|
|
6
|
+
written alongside the archive.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import tarfile
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .install.installer import select_files
|
|
17
|
+
from .manifest.model import Manifest
|
|
18
|
+
from .manifest.validation import validate_manifest
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PackageError(RuntimeError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class PackageResult:
|
|
27
|
+
archive: Path
|
|
28
|
+
sha256: str
|
|
29
|
+
files: list[str] = field(default_factory=list)
|
|
30
|
+
checksum_file: Path | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _sha256(path: Path) -> str:
|
|
34
|
+
digest = hashlib.sha256()
|
|
35
|
+
with path.open("rb") as handle:
|
|
36
|
+
for chunk in iter(lambda: handle.read(65536), b""):
|
|
37
|
+
digest.update(chunk)
|
|
38
|
+
return digest.hexdigest()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _entry_file(root: Path, manifest: Manifest) -> Path | None:
|
|
42
|
+
if manifest.entrypoint:
|
|
43
|
+
candidate = root / manifest.entrypoint
|
|
44
|
+
if candidate.is_file():
|
|
45
|
+
return candidate
|
|
46
|
+
direct = root / f"{manifest.name}.py"
|
|
47
|
+
return direct if direct.is_file() else None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def package_project(root: Path | str, dist_dir: Path | None = None) -> PackageResult:
|
|
51
|
+
root = Path(root).resolve()
|
|
52
|
+
manifest_path = root / "manifest.json"
|
|
53
|
+
if not manifest_path.is_file():
|
|
54
|
+
manifest_path = root / "manifest.xml"
|
|
55
|
+
if not manifest_path.is_file():
|
|
56
|
+
raise PackageError(
|
|
57
|
+
f"no manifest.json or manifest.xml in {root} (run 'resolvescript create')"
|
|
58
|
+
)
|
|
59
|
+
if manifest_path.suffix == ".xml":
|
|
60
|
+
from .manifest.xml_reader import load_manifest
|
|
61
|
+
|
|
62
|
+
manifest = load_manifest(manifest_path)
|
|
63
|
+
else:
|
|
64
|
+
from .manifest.json_reader import load_manifest
|
|
65
|
+
|
|
66
|
+
manifest = load_manifest(manifest_path)
|
|
67
|
+
errors = validate_manifest(manifest)
|
|
68
|
+
if errors:
|
|
69
|
+
raise PackageError(errors[0])
|
|
70
|
+
|
|
71
|
+
dist_dir = (dist_dir or root / "dist").resolve()
|
|
72
|
+
dist_dir.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
|
|
74
|
+
include = list(manifest.install.include) if manifest.install.include else ["*.py", "manifest.json"]
|
|
75
|
+
rel_files = select_files(root, include, manifest.install.exclude)
|
|
76
|
+
|
|
77
|
+
entry = _entry_file(root, manifest)
|
|
78
|
+
if entry is not None:
|
|
79
|
+
entry_rel = entry.relative_to(root).as_posix()
|
|
80
|
+
if not any(r.as_posix() == entry_rel for r in rel_files):
|
|
81
|
+
rel_files.append(entry.relative_to(root))
|
|
82
|
+
|
|
83
|
+
manifest_rel = manifest_path.relative_to(root).as_posix()
|
|
84
|
+
if not any(r.as_posix() == manifest_rel for r in rel_files):
|
|
85
|
+
rel_files.append(manifest_path.relative_to(root))
|
|
86
|
+
|
|
87
|
+
archive_name = f"{manifest.name}-{manifest.version}.tar.gz"
|
|
88
|
+
archive = dist_dir / archive_name
|
|
89
|
+
with tarfile.open(archive, "w:gz") as tf:
|
|
90
|
+
for rel in sorted(set(rel_files), key=lambda p: p.as_posix()):
|
|
91
|
+
arcname = rel.as_posix()
|
|
92
|
+
tf.add(root / rel, arcname=arcname)
|
|
93
|
+
|
|
94
|
+
sha = _sha256(archive)
|
|
95
|
+
checksum_file = dist_dir / "SHA256SUMS.txt"
|
|
96
|
+
checksum_file.write_text(f"{sha} {archive_name}\n", encoding="utf-8")
|
|
97
|
+
|
|
98
|
+
return PackageResult(
|
|
99
|
+
archive=archive,
|
|
100
|
+
sha256=sha,
|
|
101
|
+
files=sorted({r.as_posix() for r in rel_files}),
|
|
102
|
+
checksum_file=checksum_file,
|
|
103
|
+
)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Specifier resolution and materialization pipeline.
|
|
2
|
+
|
|
3
|
+
Dispatch order (design §6): path -> file-archive -> archive -> manifest ->
|
|
4
|
+
github (incl. ``name`` looked up in the known table).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import tempfile
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .fetch import fetch, sha256_file
|
|
15
|
+
from .manifest.json_reader import load_manifest
|
|
16
|
+
from .semver import SemVerError, Version
|
|
17
|
+
from .sources import known, unpack_archive
|
|
18
|
+
from .sources.git import download_github
|
|
19
|
+
from .sources.release import ReleaseSpec, asset_download_url
|
|
20
|
+
from .spec import Spec, SpecError, parse_specifier
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ResolveError(RuntimeError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Resolved:
|
|
29
|
+
name: str
|
|
30
|
+
version: str
|
|
31
|
+
kind: str
|
|
32
|
+
package_dir: Path
|
|
33
|
+
source: str
|
|
34
|
+
integrity: str
|
|
35
|
+
manifest: object
|
|
36
|
+
|
|
37
|
+
def describe(self) -> str:
|
|
38
|
+
if self.integrity:
|
|
39
|
+
return f"resolved {self.name} {self.version} ({self.kind}, {self.integrity[:12]})"
|
|
40
|
+
return f"resolved {self.name} {self.version} ({self.kind})"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _manifest(directory: Path):
|
|
44
|
+
manifest_path = directory / "manifest.json"
|
|
45
|
+
if not manifest_path.is_file():
|
|
46
|
+
raise ResolveError(f"{directory} is not an extension: missing manifest.json")
|
|
47
|
+
return load_manifest(manifest_path)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _require_package_root(package_dir: Path) -> Path:
|
|
51
|
+
if not (package_dir / "manifest.json").is_file():
|
|
52
|
+
raise ResolveError(f"{package_dir} has no manifest.json")
|
|
53
|
+
return package_dir
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def resolve_spec(
|
|
57
|
+
spec_text: str,
|
|
58
|
+
*,
|
|
59
|
+
cwd: Path | None = None,
|
|
60
|
+
work_dir: Path,
|
|
61
|
+
) -> Resolved:
|
|
62
|
+
"""Resolve a specifier to a concrete, unpacked package directory."""
|
|
63
|
+
cwd = cwd or Path.cwd()
|
|
64
|
+
spec = parse_specifier(spec_text, cwd=cwd)
|
|
65
|
+
|
|
66
|
+
if spec.kind == "name":
|
|
67
|
+
entry = known.lookup(spec.source)
|
|
68
|
+
if not entry:
|
|
69
|
+
raise ResolveError(
|
|
70
|
+
f"unknown extension {spec.source!r} - use a github:owner/repo, "
|
|
71
|
+
"a URL, or an archive/path"
|
|
72
|
+
)
|
|
73
|
+
spec = parse_specifier(entry["source"], cwd=cwd)
|
|
74
|
+
|
|
75
|
+
# -- path source -------------------------------------------------------
|
|
76
|
+
if spec.kind == "path":
|
|
77
|
+
package_dir = _require_package_root(spec.location)
|
|
78
|
+
manifest = _manifest(package_dir)
|
|
79
|
+
return Resolved(
|
|
80
|
+
name=manifest.name,
|
|
81
|
+
version=manifest.version,
|
|
82
|
+
kind="path",
|
|
83
|
+
package_dir=package_dir,
|
|
84
|
+
source=spec.source,
|
|
85
|
+
integrity="",
|
|
86
|
+
manifest=manifest,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# -- local archive -----------------------------------------------------
|
|
90
|
+
if spec.kind == "file-archive":
|
|
91
|
+
unzip_dir = work_dir / "unpacked"
|
|
92
|
+
package_dir = unpack_archive(spec.location, unzip_dir)
|
|
93
|
+
package_dir = _require_package_root(package_dir)
|
|
94
|
+
manifest = _manifest(package_dir)
|
|
95
|
+
return Resolved(
|
|
96
|
+
name=manifest.name,
|
|
97
|
+
version=manifest.version,
|
|
98
|
+
kind="archive",
|
|
99
|
+
package_dir=package_dir,
|
|
100
|
+
source=spec.source,
|
|
101
|
+
integrity=sha256_file(spec.location),
|
|
102
|
+
manifest=manifest,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# -- remote archive / manifest URL / github ---------------------------
|
|
106
|
+
if spec.kind == "github":
|
|
107
|
+
if not spec.owner or not spec.repo:
|
|
108
|
+
raise ResolveError(f"invalid github spec: {spec.source}")
|
|
109
|
+
package_dir, integrity = download_github(
|
|
110
|
+
spec.owner,
|
|
111
|
+
spec.repo,
|
|
112
|
+
ref=spec.ref,
|
|
113
|
+
range_text=spec.range_text,
|
|
114
|
+
cache_dir=work_dir / "cache",
|
|
115
|
+
)
|
|
116
|
+
package_dir = _require_package_root(package_dir)
|
|
117
|
+
manifest = _manifest(package_dir)
|
|
118
|
+
return Resolved(
|
|
119
|
+
name=manifest.name,
|
|
120
|
+
version=manifest.version,
|
|
121
|
+
kind="github",
|
|
122
|
+
package_dir=package_dir,
|
|
123
|
+
source=spec.source,
|
|
124
|
+
integrity=integrity,
|
|
125
|
+
manifest=manifest,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if spec.kind in ("archive", "manifest"):
|
|
129
|
+
url = _asset_url(spec)
|
|
130
|
+
archive = work_dir / "cache" / _slug(url)
|
|
131
|
+
if not archive.is_file():
|
|
132
|
+
fetch(url=url, dest=archive)
|
|
133
|
+
package_dir = unpack_archive(archive, work_dir / "unpacked")
|
|
134
|
+
package_dir = _require_package_root(package_dir)
|
|
135
|
+
manifest = _manifest(package_dir)
|
|
136
|
+
return Resolved(
|
|
137
|
+
name=manifest.name,
|
|
138
|
+
version=manifest.version,
|
|
139
|
+
kind="manifest" if spec.kind == "manifest" else "archive",
|
|
140
|
+
package_dir=package_dir,
|
|
141
|
+
source=spec.source,
|
|
142
|
+
integrity=sha256_file(archive),
|
|
143
|
+
manifest=manifest,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
raise ResolveError(f"cannot resolve specifier {spec_text!r}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _slug(url: str) -> str:
|
|
150
|
+
import hashlib
|
|
151
|
+
|
|
152
|
+
if "/" in url:
|
|
153
|
+
tail = url.rsplit("/", 1)[-1]
|
|
154
|
+
if tail:
|
|
155
|
+
return tail
|
|
156
|
+
return hashlib.sha256(url.encode()).hexdigest()[:16] + ".tgz"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _asset_url(spec: Spec) -> str:
|
|
160
|
+
if spec.kind == "archive":
|
|
161
|
+
return spec.url
|
|
162
|
+
# manifest URL: fetch the manifest, then follow release info
|
|
163
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
164
|
+
payload = Path(tmp) / "manifest.json"
|
|
165
|
+
try:
|
|
166
|
+
fetch(url=spec.url, dest=payload)
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
raise ResolveError(f"failed to fetch manifest {spec.url}: {exc}") from exc
|
|
169
|
+
try:
|
|
170
|
+
data = json.loads(payload.read_text("utf-8"))
|
|
171
|
+
except json.JSONDecodeError as exc:
|
|
172
|
+
raise ResolveError(f"{spec.url} is not valid JSON: {exc}") from exc
|
|
173
|
+
if not isinstance(data, dict):
|
|
174
|
+
raise ResolveError(f"{spec.url} is not an object")
|
|
175
|
+
name = data.get("name") or "extension"
|
|
176
|
+
version = data.get("version") or "0.1.0"
|
|
177
|
+
release = data.get("release")
|
|
178
|
+
if not isinstance(release, dict):
|
|
179
|
+
raise ResolveError(f"manifest at {spec.url} has no 'release' entry")
|
|
180
|
+
try:
|
|
181
|
+
return asset_download_url(ReleaseSpec.from_data(release), str(name), str(version))
|
|
182
|
+
except Exception as exc:
|
|
183
|
+
raise ResolveError(str(exc)) from exc
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def lockfile_satisfies(entry: dict | None, spec_text: str, *, cwd: Path | None = None) -> bool:
|
|
187
|
+
"""§6.8 lockfile-wins: is the recorded entry good for the requested spec?"""
|
|
188
|
+
if not entry:
|
|
189
|
+
return False
|
|
190
|
+
try:
|
|
191
|
+
spec = parse_specifier(spec_text, cwd=cwd or Path.cwd())
|
|
192
|
+
except SpecError:
|
|
193
|
+
return False
|
|
194
|
+
if entry.get("source") != spec.source:
|
|
195
|
+
return False
|
|
196
|
+
if not spec.range_text:
|
|
197
|
+
return True
|
|
198
|
+
try:
|
|
199
|
+
version = Version.parse(str(entry["version"]))
|
|
200
|
+
except (SemVerError, KeyError, ValueError):
|
|
201
|
+
return False
|
|
202
|
+
from .semver import matches
|
|
203
|
+
|
|
204
|
+
return matches(version, spec.range_text)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Mock Resolve API, environment injection, smoke checks and the REPL.
|
|
2
|
+
|
|
3
|
+
Typical usage from a test or script::
|
|
4
|
+
|
|
5
|
+
from resolve_script.sandbox.env import install_fake_resolve
|
|
6
|
+
from resolve_script.sandbox.smoke import run_smoke
|
|
7
|
+
|
|
8
|
+
install_fake_resolve()
|
|
9
|
+
result = run_smoke(my_module)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .env import (
|
|
13
|
+
DEFAULT_PROJECT,
|
|
14
|
+
FUSION_SCRIPT_MODULE,
|
|
15
|
+
build_default_env,
|
|
16
|
+
fake_resolve_module,
|
|
17
|
+
install_fake_resolve,
|
|
18
|
+
)
|
|
19
|
+
from .loader import load_built_module, load_source_module, purge_module
|
|
20
|
+
from .repl import default_namespace, start_repl
|
|
21
|
+
from .smoke import SmokeCheck, SmokeResult, discover_exports, run_smoke
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"DEFAULT_PROJECT",
|
|
25
|
+
"FUSION_SCRIPT_MODULE",
|
|
26
|
+
"SmokeCheck",
|
|
27
|
+
"SmokeResult",
|
|
28
|
+
"build_default_env",
|
|
29
|
+
"default_namespace",
|
|
30
|
+
"discover_exports",
|
|
31
|
+
"fake_resolve_module",
|
|
32
|
+
"install_fake_resolve",
|
|
33
|
+
"load_built_module",
|
|
34
|
+
"load_source_module",
|
|
35
|
+
"purge_module",
|
|
36
|
+
"run_smoke",
|
|
37
|
+
"start_repl",
|
|
38
|
+
]
|