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,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
+ ]