esp-gsp-tools 0.1.0__tar.gz

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.
@@ -0,0 +1,3 @@
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.5
2
+ Name: esp-gsp-tools
3
+ Version: 0.1.0
4
+ Summary: Resolve, verify, cache, and run pinned esp-gsp-tools releases
5
+ Author: Espressif Systems
6
+ License: Apache-2.0
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3 :: Only
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Requires-Python: <3.15,>=3.10
15
+ Requires-Dist: cryptography>=44.0.0
16
+ Description-Content-Type: text/markdown
17
+
18
+ # esp-gsp-tools
19
+
20
+ `esp-gsp-tools` resolves, verifies, caches, and runs a pinned release of one
21
+ of the supported products:
22
+
23
+ | Product name | Product |
24
+ | --- | --- |
25
+ | `gspc` | GSPC |
26
+ | `sim` | simulator |
27
+
28
+ Product names are case-insensitive.
29
+
30
+ ## Run a product
31
+
32
+ ```sh
33
+ python -m gsp.execute --version 0.2.4 gspc pack scene.json -o bundle.gspb
34
+ python -m gsp.execute --no-auto-cache sim --help
35
+ ```
36
+
37
+ All arguments after the product name are passed to that product unchanged.
38
+ `--version` overrides `GSP_VERSION`. A missing cached version is downloaded
39
+ automatically unless `--no-auto-cache` or
40
+ `GSP_TOOLS_NO_AUTO_DOWNLOAD=1` is used.
41
+
42
+ The direct executable variables are `GSPC_EXECUTABLE` and
43
+ `GSP_SIM_EXECUTABLE`; either takes precedence over the cache and version.
44
+
45
+ ## Manage the cache
46
+
47
+ The manager requires a product for every version-specific operation. Both
48
+ `VERSION PRODUCT` and `PRODUCT VERSION` are accepted:
49
+
50
+ ```sh
51
+ python -m gsp.manager install 0.2.4 gspc
52
+ python -m gsp.manager fetch sim 0.2.4
53
+ python -m gsp.manager reinstall 0.2.4 GSPC
54
+ python -m gsp.manager path 0.2.4 sim
55
+ python -m gsp.manager remove 0.2.4 gspc
56
+ python -m gsp.manager list sim
57
+ ```
58
+
59
+ The cache defaults to `~/.espressif/esp-gsp-tools` and can be changed with
60
+ `GSP_TOOLS_CACHE_DIR` or `--cache-dir`. `GSP_TOOLS_BASE_URL` overrides the
61
+ single release root URL. The manager appends the product directory (`gspc` or
62
+ `sim`) before the version path. Release archives are authenticated with the
63
+ configured Ed25519 public keys as described by the manifest.
64
+
65
+ If a product name is not supported, the command explains that the installed
66
+ `esp-gsp-tools` version may be outdated and suggests:
67
+
68
+ ```sh
69
+ python -m pip install -U esp-gsp-tools
70
+ ```
@@ -0,0 +1,53 @@
1
+ # esp-gsp-tools
2
+
3
+ `esp-gsp-tools` resolves, verifies, caches, and runs a pinned release of one
4
+ of the supported products:
5
+
6
+ | Product name | Product |
7
+ | --- | --- |
8
+ | `gspc` | GSPC |
9
+ | `sim` | simulator |
10
+
11
+ Product names are case-insensitive.
12
+
13
+ ## Run a product
14
+
15
+ ```sh
16
+ python -m gsp.execute --version 0.2.4 gspc pack scene.json -o bundle.gspb
17
+ python -m gsp.execute --no-auto-cache sim --help
18
+ ```
19
+
20
+ All arguments after the product name are passed to that product unchanged.
21
+ `--version` overrides `GSP_VERSION`. A missing cached version is downloaded
22
+ automatically unless `--no-auto-cache` or
23
+ `GSP_TOOLS_NO_AUTO_DOWNLOAD=1` is used.
24
+
25
+ The direct executable variables are `GSPC_EXECUTABLE` and
26
+ `GSP_SIM_EXECUTABLE`; either takes precedence over the cache and version.
27
+
28
+ ## Manage the cache
29
+
30
+ The manager requires a product for every version-specific operation. Both
31
+ `VERSION PRODUCT` and `PRODUCT VERSION` are accepted:
32
+
33
+ ```sh
34
+ python -m gsp.manager install 0.2.4 gspc
35
+ python -m gsp.manager fetch sim 0.2.4
36
+ python -m gsp.manager reinstall 0.2.4 GSPC
37
+ python -m gsp.manager path 0.2.4 sim
38
+ python -m gsp.manager remove 0.2.4 gspc
39
+ python -m gsp.manager list sim
40
+ ```
41
+
42
+ The cache defaults to `~/.espressif/esp-gsp-tools` and can be changed with
43
+ `GSP_TOOLS_CACHE_DIR` or `--cache-dir`. `GSP_TOOLS_BASE_URL` overrides the
44
+ single release root URL. The manager appends the product directory (`gspc` or
45
+ `sim`) before the version path. Release archives are authenticated with the
46
+ configured Ed25519 public keys as described by the manifest.
47
+
48
+ If a product name is not supported, the command explains that the installed
49
+ `esp-gsp-tools` version may be outdated and suggests:
50
+
51
+ ```sh
52
+ python -m pip install -U esp-gsp-tools
53
+ ```
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.26"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "esp-gsp-tools"
7
+ version = "0.1.0"
8
+ description = "Resolve, verify, cache, and run pinned esp-gsp-tools releases"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10,<3.15"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "Espressif Systems" }]
13
+ dependencies = ["cryptography>=44.0.0"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3 :: Only",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Programming Language :: Python :: 3.14",
22
+ ]
23
+
24
+ [project.scripts]
25
+ esp-gsp-tools = "gsp.manager:main"
26
+
27
+ [dependency-groups]
28
+ dev = ["pytest>=8.3"]
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/gsp"]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
35
+ addopts = "-q"
@@ -0,0 +1,3 @@
1
+ """Pinned, signed esp-gsp-tools release resolver."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,9 @@
1
+ import sys
2
+
3
+
4
+ if __name__ == "__main__":
5
+ sys.stderr.write(
6
+ "Use `python -m gsp.execute PRODUCT` to run a product or "
7
+ "`python -m gsp.manager` to manage its local cache.\n"
8
+ )
9
+ raise SystemExit(2)
@@ -0,0 +1,7 @@
1
+ class GspManagerError(RuntimeError):
2
+ """A recoverable user-facing manager error."""
3
+
4
+
5
+ # Keep the old import available for callers that used the initial package
6
+ # while the public module is migrated from ``gspc`` to ``gsp``.
7
+ GspcManagerError = GspManagerError
@@ -0,0 +1,94 @@
1
+ """Resolve and execute one product from the esp-gsp-tools distribution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from .errors import GspManagerError
12
+ from .manager import GspManager, load_public_keys
13
+ from .products import get_product
14
+
15
+
16
+ _TRUE_VALUES = {"1", "true", "yes", "on"}
17
+
18
+
19
+ def _automatic_download_disabled() -> bool:
20
+ value = os.environ.get("GSP_TOOLS_NO_AUTO_DOWNLOAD", "")
21
+ return value.strip().lower() in _TRUE_VALUES
22
+
23
+
24
+ def executable_from_environment(
25
+ product: str,
26
+ *,
27
+ version: str | None = None,
28
+ no_auto_cache: bool = False,
29
+ ) -> Path:
30
+ selected = get_product(product)
31
+ direct_path = os.environ.get(selected.executable_environment)
32
+ if direct_path:
33
+ executable = Path(direct_path)
34
+ if not executable.is_file():
35
+ raise GspManagerError(
36
+ f"{selected.executable_environment} does not point to a file: {executable}"
37
+ )
38
+ return executable
39
+
40
+ requested_version = (version or os.environ.get("GSP_VERSION", "")).strip()
41
+ if not requested_version:
42
+ raise GspManagerError(
43
+ "set --version, GSP_VERSION, or the product executable environment variable"
44
+ )
45
+ manager = GspManager(product=selected.key, public_keys=load_public_keys(None, []))
46
+ executable = manager.cached(requested_version)
47
+ if executable is not None:
48
+ return executable
49
+ if no_auto_cache or _automatic_download_disabled():
50
+ raise GspManagerError(
51
+ f"{selected.display_name} {requested_version} is not cached and automatic download is disabled; "
52
+ f"run `python -m gsp.manager install {requested_version} {selected.key}` first"
53
+ )
54
+ return manager.resolve(requested_version)
55
+
56
+
57
+ def _parser() -> argparse.ArgumentParser:
58
+ parser = argparse.ArgumentParser(
59
+ prog="python -m gsp.execute",
60
+ description="Run a selected esp-gsp-tools product.",
61
+ )
62
+ parser.add_argument("--version", help="exact product version; defaults to GSP_VERSION")
63
+ parser.add_argument(
64
+ "--no-auto-cache",
65
+ action="store_true",
66
+ help="do not download a missing version automatically",
67
+ )
68
+ parser.add_argument("product", metavar="PRODUCT")
69
+ parser.add_argument("arguments", nargs=argparse.REMAINDER, metavar="ARGS")
70
+ return parser
71
+
72
+
73
+ def main(argv: list[str] | None = None) -> int:
74
+ parser = _parser()
75
+ options = parser.parse_args(sys.argv[1:] if argv is None else argv)
76
+ executable: Path | None = None
77
+ try:
78
+ selected = get_product(options.product)
79
+ executable = executable_from_environment(
80
+ selected.key,
81
+ version=options.version,
82
+ no_auto_cache=options.no_auto_cache,
83
+ )
84
+ return subprocess.run([str(executable), *options.arguments], check=False).returncode
85
+ except GspManagerError as exc:
86
+ sys.stderr.write(f"gsp.execute: error: {exc}\n")
87
+ return 2
88
+ except OSError as exc:
89
+ sys.stderr.write(f"gsp.execute: error: cannot run {executable}: {exc}\n")
90
+ return 2
91
+
92
+
93
+ if __name__ == "__main__":
94
+ raise SystemExit(main())
@@ -0,0 +1,16 @@
1
+ """Trusted GSPC release signing keys.
2
+
3
+ Populate this mapping when the release signing key is provisioned. Keeping
4
+ the registry explicit makes key IDs auditable and permits overlapping keys
5
+ during a rotation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Final
11
+
12
+ BUILTIN_PUBLIC_KEYS: Final[dict[str, str]] = {
13
+ "gspc-release": "uSVHnMpv03VPGL5FMQm2USELARKeCPRdGsezyqQlQ74=",
14
+ "gsp-sim-release": "CbVrcYYSPjDt6J7N5v4h80SCcizC8ZW+d6D2Q8Y43iU=",
15
+ "fa": "S2WEykJ/6KvoIcFGQ9tpa4TkzJrVwwPIddoqko9GNDM="
16
+ }
@@ -0,0 +1,361 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import base64
5
+ import json
6
+ import os
7
+ import shutil
8
+ import stat
9
+ import subprocess
10
+ import tarfile
11
+ import tempfile
12
+ import time
13
+ import zipfile
14
+ from contextlib import contextmanager
15
+ from pathlib import Path
16
+ from typing import Iterator, Mapping
17
+ from urllib.parse import urlparse
18
+ from urllib.request import Request, urlopen
19
+
20
+ from .errors import GspManagerError, GspcManagerError
21
+ from .keys import BUILTIN_PUBLIC_KEYS
22
+ from .manifest import Artifact, parse_manifest
23
+ from .platforms import HostPlatform, executable_name
24
+ from .products import Product, get_product
25
+ from .verify import verify_ed25519
26
+
27
+ DEFAULT_CACHE_DIR = Path.home() / ".espressif" / "esp-gsp-tools"
28
+ DEFAULT_BASE_URL = "https://dl.espressif.com/AE/gsp/"
29
+
30
+
31
+ def default_cache_dir() -> Path:
32
+ return Path(os.environ.get("GSP_TOOLS_CACHE_DIR") or os.environ.get("GSPC_CACHE_DIR", DEFAULT_CACHE_DIR))
33
+
34
+
35
+ def default_base_url() -> str:
36
+ return (os.environ.get("GSP_TOOLS_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
37
+
38
+
39
+ class GspManager:
40
+ def __init__(self, product: str = "gspc", *, base_url: str | None = None, cache_dir: Path | None = None, public_keys: Mapping[str, str] | None = None) -> None:
41
+ self.product = get_product(product)
42
+ self.base_url = (base_url or default_base_url()).rstrip("/")
43
+ if urlparse(self.base_url).scheme != "https":
44
+ raise GspManagerError("release base URL must use HTTPS")
45
+ self.cache_dir = cache_dir or default_cache_dir()
46
+ self.public_keys = dict(BUILTIN_PUBLIC_KEYS)
47
+ if public_keys:
48
+ self.public_keys.update(public_keys)
49
+
50
+ def resolve(self, version: str, *, reinstall: bool = False) -> Path:
51
+ _validate_version(version)
52
+ host = HostPlatform.detect()
53
+ destination = self._destination(version, host)
54
+ expected_binary = destination / "bin" / executable_name(self.product.binary, host)
55
+ with _cache_lock(destination.with_name(destination.name + ".lock")):
56
+ if reinstall and destination.exists():
57
+ shutil.rmtree(destination)
58
+ metadata_path = destination / "metadata.json"
59
+ if expected_binary.is_file() and metadata_path.is_file():
60
+ self._validate_installed(expected_binary, version)
61
+ return expected_binary
62
+ if destination.exists():
63
+ shutil.rmtree(destination)
64
+ artifact, manifest_url = self._fetch_artifact(version, host)
65
+ installed = self._install(destination, artifact, version, host)
66
+ metadata_path = installed.parent.parent / "metadata.json"
67
+ metadata_path.write_text(
68
+ json.dumps({"version": version, "manifest_url": manifest_url, "artifact": artifact.raw}, indent=2) + "\n",
69
+ encoding="utf-8",
70
+ )
71
+ return installed
72
+
73
+ def cached(self, version: str) -> Path | None:
74
+ """Return a verified cached compiler for this host, without networking."""
75
+ _validate_version(version)
76
+ host = HostPlatform.detect()
77
+ destination = self._destination(version, host)
78
+ binary = destination / "bin" / executable_name(self.product.binary, host)
79
+ if not binary.is_file() or not (destination / "metadata.json").is_file():
80
+ return None
81
+ self._validate_installed(binary, version)
82
+ return binary
83
+
84
+ def remove(self, version: str) -> bool:
85
+ """Remove exactly one cached version for the current host."""
86
+ _validate_version(version)
87
+ host = HostPlatform.detect()
88
+ destination = self._destination(version, host)
89
+ with _cache_lock(destination.with_name(destination.name + ".lock")):
90
+ if not destination.exists():
91
+ return False
92
+ shutil.rmtree(destination)
93
+ try:
94
+ destination.parent.rmdir()
95
+ except OSError:
96
+ pass
97
+ return True
98
+
99
+ def list_cached(self) -> list[tuple[str, str, str, Path]]:
100
+ """Return (product, version, platform, executable) records installed."""
101
+ if not self.cache_dir.is_dir():
102
+ return []
103
+ records: list[tuple[str, str, str, Path]] = []
104
+ for metadata in sorted((self.cache_dir / self.product.key).glob("v*/*/metadata.json")):
105
+ try:
106
+ version = json.loads(metadata.read_text(encoding="utf-8"))["version"]
107
+ except (OSError, ValueError, KeyError, TypeError):
108
+ continue
109
+ binary_dir = metadata.parent / "bin"
110
+ binaries = list(binary_dir.glob(f"{self.product.binary}*")) if binary_dir.is_dir() else []
111
+ if len(binaries) == 1 and isinstance(version, str):
112
+ records.append((self.product.key, version, metadata.parent.name, binaries[0]))
113
+ return records
114
+
115
+ def _destination(self, version: str, host: HostPlatform) -> Path:
116
+ return self.cache_dir / self.product.key / f"v{version}" / f"{host.os}-{host.arch}"
117
+
118
+ def _fetch_artifact(self, version: str, host: HostPlatform) -> tuple[Artifact, str]:
119
+ manifest_url = f"{self.base_url}/{self.product.key}/v{version}/manifest.json"
120
+ artifact = parse_manifest(
121
+ _download(manifest_url), requested_version=version, manifest_url=manifest_url, host=host
122
+ )
123
+ if artifact.key_id not in self.public_keys:
124
+ raise GspcManagerError(
125
+ f"artifact uses unknown signing key {artifact.key_id!r}; "
126
+ "configure that public key before installing"
127
+ )
128
+ return artifact, manifest_url
129
+
130
+ def _install(self, destination: Path, artifact: Artifact, version: str, host: HostPlatform) -> Path:
131
+ archive = _download(artifact.archive_url)
132
+ signature = _download(artifact.signature_url)
133
+ verify_ed25519(archive, signature, self.public_keys[artifact.key_id], encoding=artifact.signature_encoding)
134
+ destination.parent.mkdir(parents=True, exist_ok=True)
135
+ temporary = Path(tempfile.mkdtemp(prefix=f".{destination.name}-", dir=destination.parent))
136
+ try:
137
+ extracted = temporary / "extracted"
138
+ extracted.mkdir()
139
+ _safe_extract(archive, artifact.archive_format, extracted)
140
+ binary_source = _safe_child(extracted, executable_name(artifact.binary, host))
141
+ if not binary_source.is_file():
142
+ raise GspcManagerError(f"release archive does not contain declared binary {artifact.binary!r}")
143
+ # The manifest controls the path inside the archive, but the cache
144
+ # exposes a stable compiler name to CMake and future cache reads.
145
+ binary_destination = temporary / "bin" / executable_name(self.product.binary, host)
146
+ binary_destination.parent.mkdir()
147
+ shutil.copy2(binary_source, binary_destination)
148
+ if host.os != "windows":
149
+ binary_destination.chmod(binary_destination.stat().st_mode | stat.S_IXUSR)
150
+ self._validate_installed(binary_destination, version)
151
+ os.replace(temporary, destination)
152
+ return destination / "bin" / binary_destination.name
153
+ except Exception:
154
+ shutil.rmtree(temporary, ignore_errors=True)
155
+ raise
156
+
157
+ @staticmethod
158
+ def _validate_installed(binary: Path, expected_version: str) -> None:
159
+ try:
160
+ result = subprocess.run(
161
+ [str(binary), "--version"], check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
162
+ )
163
+ except (OSError, subprocess.CalledProcessError) as exc:
164
+ raise GspcManagerError(f"cannot execute verified GSPC at {binary}: {exc}") from exc
165
+ tokens = result.stdout.split()
166
+ if expected_version not in tokens:
167
+ raise GspcManagerError(
168
+ f"verified GSPC reports {result.stdout.strip()!r}, expected version {expected_version!r}"
169
+ )
170
+
171
+
172
+ # Compatibility alias for code using the initial single-product API.
173
+ GspcManager = GspManager
174
+
175
+
176
+ def load_public_keys(path: Path | None, overrides: list[str]) -> dict[str, str]:
177
+ keys: dict[str, str] = {}
178
+ configured_path = path or (Path(os.environ["GSPC_PUBLIC_KEYS_FILE"]) if "GSPC_PUBLIC_KEYS_FILE" in os.environ else None)
179
+ if configured_path:
180
+ try:
181
+ parsed = json.loads(configured_path.read_text(encoding="utf-8"))
182
+ except (OSError, json.JSONDecodeError) as exc:
183
+ raise GspcManagerError(f"cannot load public keys from {configured_path}: {exc}") from exc
184
+ if not isinstance(parsed, dict) or not all(isinstance(key, str) and isinstance(value, str) for key, value in parsed.items()):
185
+ raise GspcManagerError("public-key JSON must be an object mapping key IDs to Base64 keys")
186
+ keys.update(parsed)
187
+ for item in overrides:
188
+ key_id, separator, encoded = item.partition("=")
189
+ if not separator or not key_id or not encoded:
190
+ raise GspcManagerError("--public-key must have the form KEY_ID=BASE64_KEY")
191
+ try:
192
+ if len(base64.b64decode(encoded, validate=True)) != 32:
193
+ raise ValueError
194
+ except ValueError as exc:
195
+ raise GspcManagerError(f"public key {key_id!r} is not a Base64 Ed25519 public key") from exc
196
+ keys[key_id] = encoded
197
+ return keys
198
+
199
+
200
+ def _validate_version(version: str) -> None:
201
+ if not version or version != version.strip() or "/" in version or "\\" in version:
202
+ raise GspcManagerError(f"invalid GSPC version: {version!r}")
203
+
204
+
205
+ def _download(url: str) -> bytes:
206
+ try:
207
+ request = Request(url, headers={"User-Agent": "gspc/0.1"})
208
+ with urlopen(request, timeout=30) as response:
209
+ return response.read()
210
+ except OSError as exc:
211
+ raise GspcManagerError(f"failed to download {url}: {exc}") from exc
212
+
213
+
214
+ def _safe_child(root: Path, relative: str) -> Path:
215
+ path = (root / relative).resolve()
216
+ resolved_root = root.resolve()
217
+ if path != resolved_root and resolved_root not in path.parents:
218
+ raise GspcManagerError(f"unsafe archive path: {relative!r}")
219
+ return path
220
+
221
+
222
+ def _safe_extract(data: bytes, archive_format: str, destination: Path) -> None:
223
+ if archive_format == "tar.gz":
224
+ with tarfile.open(fileobj=_BytesReader(data), mode="r:gz") as archive:
225
+ for member in archive.getmembers():
226
+ if member.isdir():
227
+ _safe_child(destination, member.name).mkdir(parents=True, exist_ok=True)
228
+ continue
229
+ if member.issym() or member.islnk() or not member.isfile():
230
+ raise GspcManagerError(f"unsupported member in GSPC archive: {member.name}")
231
+ target = _safe_child(destination, member.name)
232
+ target.parent.mkdir(parents=True, exist_ok=True)
233
+ source = archive.extractfile(member)
234
+ if source is None:
235
+ raise GspcManagerError(f"cannot extract GSPC archive member: {member.name}")
236
+ with source, target.open("wb") as output:
237
+ shutil.copyfileobj(source, output)
238
+ else:
239
+ with zipfile.ZipFile(_BytesReader(data)) as archive:
240
+ for member in archive.infolist():
241
+ if member.is_dir():
242
+ continue
243
+ target = _safe_child(destination, member.filename)
244
+ target.parent.mkdir(parents=True, exist_ok=True)
245
+ with archive.open(member), target.open("wb") as output:
246
+ shutil.copyfileobj(member, output)
247
+
248
+
249
+ class _BytesReader:
250
+ """Small seekable adapter keeping archive handling independent of files."""
251
+
252
+ def __init__(self, data: bytes) -> None:
253
+ import io
254
+
255
+ self._buffer = io.BytesIO(data)
256
+
257
+ def __getattr__(self, name: str):
258
+ return getattr(self._buffer, name)
259
+
260
+
261
+ @contextmanager
262
+ def _cache_lock(lock_path: Path) -> Iterator[None]:
263
+ deadline = time.monotonic() + 60
264
+ while True:
265
+ try:
266
+ lock_path.mkdir(parents=True)
267
+ break
268
+ except FileExistsError:
269
+ if time.monotonic() >= deadline:
270
+ raise GspcManagerError(f"timed out waiting for GSPC cache lock {lock_path}")
271
+ time.sleep(0.1)
272
+ try:
273
+ yield
274
+ finally:
275
+ lock_path.rmdir()
276
+
277
+
278
+ def _parser() -> argparse.ArgumentParser:
279
+ parser = argparse.ArgumentParser(
280
+ prog="python -m gsp.manager",
281
+ description="Manage the local cache of signed, pinned esp-gsp-tools releases.",
282
+ )
283
+ parser.add_argument("--base-url", help="release server base URL")
284
+ parser.add_argument("--cache-dir", type=Path, help="directory for verified releases")
285
+ parser.add_argument("--public-keys-file", type=Path, help="JSON mapping of key IDs to Base64 Ed25519 public keys")
286
+ parser.add_argument("--public-key", action="append", default=[], metavar="ID=BASE64", help="additional trusted Ed25519 public key; may be repeated")
287
+ commands = parser.add_subparsers(dest="command", required=True)
288
+ for name, help_text in (
289
+ ("install", "install VERSION if it is not already cached"),
290
+ ("fetch", "download, verify, and cache VERSION for offline use"),
291
+ ("reinstall", "discard and install VERSION again"),
292
+ ("path", "print the verified cached executable path for VERSION"),
293
+ ("remove", "remove the cached VERSION for this host"),
294
+ ):
295
+ command = commands.add_parser(name, help=help_text)
296
+ command.add_argument("first", metavar="VERSION|PRODUCT")
297
+ command.add_argument("second", metavar="PRODUCT|VERSION")
298
+ list_command = commands.add_parser("list", help="list cached releases")
299
+ list_command.add_argument("product", nargs="?", default="gspc", metavar="PRODUCT")
300
+ return parser
301
+
302
+
303
+ def _version_and_product(first: str, second: str) -> tuple[str, str]:
304
+ """Accept both PRODUCT VERSION and the historical VERSION PRODUCT order."""
305
+
306
+ try:
307
+ get_product(first)
308
+ except GspManagerError:
309
+ get_product(second)
310
+ return first, second
311
+ return second, first
312
+
313
+
314
+ def _normalize_manager_argv(argv: list[str] | None) -> list[str] | None:
315
+ """Also accept ``gsp.manager PRODUCT COMMAND VERSION`` for convenience."""
316
+
317
+ raw = list(argv) if argv is not None else None
318
+ if raw and raw[0].strip().lower() in {"gspc", "sim"} and len(raw) >= 2:
319
+ product, command, rest = raw[0], raw[1], raw[2:]
320
+ if command == "list":
321
+ return [command, product, *rest]
322
+ if command in {"install", "fetch", "reinstall", "path", "remove"}:
323
+ return [command, *rest, product]
324
+ return raw
325
+
326
+
327
+ def main(argv: list[str] | None = None) -> int:
328
+ parser = _parser()
329
+ options = parser.parse_args(_normalize_manager_argv(argv))
330
+ try:
331
+ product = options.product if options.command == "list" else _version_and_product(options.first, options.second)[1]
332
+ manager = GspManager(
333
+ product=product,
334
+ base_url=options.base_url,
335
+ cache_dir=options.cache_dir,
336
+ public_keys=load_public_keys(options.public_keys_file, options.public_key),
337
+ )
338
+ if options.command == "list":
339
+ for product_name, version, platform_name, executable in manager.list_cached():
340
+ print(f"{product_name}\t{version}\t{platform_name}\t{executable}")
341
+ return 0
342
+ version, _ = _version_and_product(options.first, options.second)
343
+ if options.command == "path":
344
+ executable = manager.cached(version)
345
+ if executable is None:
346
+ raise GspManagerError(f"{manager.product.display_name} {version} is not cached for this host")
347
+ print(executable)
348
+ return 0
349
+ if options.command == "remove":
350
+ if not manager.remove(version):
351
+ raise GspManagerError(f"{manager.product.display_name} {version} is not cached for this host")
352
+ return 0
353
+ executable = manager.resolve(version, reinstall=options.command == "reinstall")
354
+ print(executable)
355
+ return 0
356
+ except GspcManagerError as exc:
357
+ parser.exit(2, f"gsp.manager: error: {exc}\n")
358
+
359
+
360
+ if __name__ == "__main__":
361
+ raise SystemExit(main())