pypilot-cli 1.3.0__py3-none-win_amd64.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.
- pypilot/__init__.py +34 -0
- pypilot/_engine/pypilot.exe +0 -0
- pypilot/api.py +151 -0
- pypilot/binary.py +90 -0
- pypilot/cli.py +117 -0
- pypilot/engine.py +548 -0
- pypilot/py.typed +0 -0
- pypilot_cli-1.3.0.dist-info/METADATA +284 -0
- pypilot_cli-1.3.0.dist-info/RECORD +12 -0
- pypilot_cli-1.3.0.dist-info/WHEEL +4 -0
- pypilot_cli-1.3.0.dist-info/entry_points.txt +2 -0
- pypilot_cli-1.3.0.dist-info/licenses/LICENSE +661 -0
pypilot/engine.py
ADDED
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pypilot.engine: locate, download and verify the native `pypilot` engine.
|
|
3
|
+
|
|
4
|
+
Lookup order (first match wins), see `locate_engine`:
|
|
5
|
+
|
|
6
|
+
1. `PYPILOT_ENGINE_VERSION=vX.Y.Z` set: that release only, from the cache or
|
|
7
|
+
downloaded and checked against the release's `SHA256SUMS`.
|
|
8
|
+
2. The engine bundled in a platform wheel (`pypilot/_engine/pypilot[.exe]`).
|
|
9
|
+
3. A previous verified download of the pinned release in the cache.
|
|
10
|
+
4. `~/.cargo/bin/pypilot` (a source build), if at least `MIN_VERSION`.
|
|
11
|
+
5. A `pypilot` engine on PATH, if at least `MIN_VERSION`.
|
|
12
|
+
6. Download the pinned release, verified against `ENGINE_SHA256`, unless
|
|
13
|
+
`PYPILOT_OFFLINE` is set.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import os
|
|
20
|
+
import platform
|
|
21
|
+
import shutil
|
|
22
|
+
import stat
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import sysconfig
|
|
26
|
+
import tarfile
|
|
27
|
+
import tempfile
|
|
28
|
+
import urllib.error
|
|
29
|
+
import urllib.parse
|
|
30
|
+
import urllib.request
|
|
31
|
+
import zipfile
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Dict, Optional, Set, Tuple, Union
|
|
35
|
+
|
|
36
|
+
from .binary import BUNDLED_VERSION, DEFAULT_TAG, ENGINE_SHA256, GITHUB_REPO, MIN_VERSION
|
|
37
|
+
|
|
38
|
+
Version = Tuple[int, int, int]
|
|
39
|
+
StrPath = Union[str, "os.PathLike[str]"]
|
|
40
|
+
|
|
41
|
+
RELEASE_DOWNLOAD_URL = "https://github.com/{repo}/releases/download/{tag}/{asset}"
|
|
42
|
+
SHA256SUMS_ASSET = "SHA256SUMS"
|
|
43
|
+
DEFAULT_DOWNLOAD_TIMEOUT = 60.0
|
|
44
|
+
|
|
45
|
+
ENV_OFFLINE = "PYPILOT_OFFLINE"
|
|
46
|
+
ENV_ENGINE_VERSION = "PYPILOT_ENGINE_VERSION"
|
|
47
|
+
ENV_ALLOW_UNVERIFIED = "PYPILOT_ALLOW_UNVERIFIED"
|
|
48
|
+
ENV_DOWNLOAD_TIMEOUT = "PYPILOT_DOWNLOAD_TIMEOUT"
|
|
49
|
+
# Set while probing a candidate engine. A `pypilot` on PATH can be this very
|
|
50
|
+
# wrapper (a console-script launcher), and probing it must not make it search
|
|
51
|
+
# PATH again or download anything.
|
|
52
|
+
_ENV_PROBING = "_PYPILOT_PROBING"
|
|
53
|
+
|
|
54
|
+
# Values of `EngineInfo.source`.
|
|
55
|
+
SOURCE_OVERRIDE = "override"
|
|
56
|
+
SOURCE_BUNDLED = "bundled"
|
|
57
|
+
SOURCE_CACHE = "cache"
|
|
58
|
+
SOURCE_CARGO = "cargo"
|
|
59
|
+
SOURCE_PATH = "path"
|
|
60
|
+
SOURCE_DOWNLOAD = "download"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class EngineError(RuntimeError):
|
|
64
|
+
"""The native engine could not be located, downloaded or verified."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class EngineNotFoundError(EngineError):
|
|
68
|
+
"""No engine is available and none may be downloaded."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class EngineDownloadError(EngineError):
|
|
72
|
+
"""A release file could not be fetched."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, message: str, status: Optional[int] = None) -> None:
|
|
75
|
+
super().__init__(message)
|
|
76
|
+
self.status = status
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class EngineVerificationError(EngineError):
|
|
80
|
+
"""A download did not match its expected SHA-256, or could not be checked."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class EngineInfo:
|
|
85
|
+
"""Where the engine was found and which version it reports."""
|
|
86
|
+
|
|
87
|
+
path: Path
|
|
88
|
+
source: str
|
|
89
|
+
version: Optional[Version] = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _env_flag(name: str) -> bool:
|
|
93
|
+
return os.environ.get(name, "").strip().lower() in ("1", "true", "yes", "on")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def is_offline() -> bool:
|
|
97
|
+
"""True when `PYPILOT_OFFLINE` forbids every download."""
|
|
98
|
+
return _env_flag(ENV_OFFLINE)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_platform_slug() -> Tuple[str, str]:
|
|
102
|
+
"""
|
|
103
|
+
Returns (slug, extension), e.g. ("windows-x64", "zip") or ("linux-x64", "tar.gz").
|
|
104
|
+
"""
|
|
105
|
+
system = platform.system().lower()
|
|
106
|
+
machine = platform.machine().lower()
|
|
107
|
+
|
|
108
|
+
if system == "linux":
|
|
109
|
+
if machine in ("x86_64", "amd64"):
|
|
110
|
+
return "linux-x64", "tar.gz"
|
|
111
|
+
raise RuntimeError(f"Unsupported Linux architecture for PyPilot: {machine}")
|
|
112
|
+
elif system == "darwin":
|
|
113
|
+
if machine in ("arm64", "aarch64"):
|
|
114
|
+
return "darwin-arm64", "tar.gz"
|
|
115
|
+
elif machine in ("x86_64", "amd64"):
|
|
116
|
+
return "darwin-x64", "tar.gz"
|
|
117
|
+
raise RuntimeError(f"Unsupported macOS architecture for PyPilot: {machine}")
|
|
118
|
+
elif system == "windows":
|
|
119
|
+
if machine in ("x86_64", "amd64"):
|
|
120
|
+
return "windows-x64", "zip"
|
|
121
|
+
raise RuntimeError(f"Unsupported Windows architecture for PyPilot: {machine}")
|
|
122
|
+
else:
|
|
123
|
+
raise RuntimeError(f"Unsupported operating system for PyPilot: {system}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def asset_name() -> str:
|
|
127
|
+
"""The release asset for this platform, e.g. `pypilot-windows-x64.zip`."""
|
|
128
|
+
slug, ext = get_platform_slug()
|
|
129
|
+
return f"pypilot-{slug}.{ext}"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_cache_dir() -> Path:
|
|
133
|
+
"""Returns directory for storing cached PyPilot binaries."""
|
|
134
|
+
if sys.platform == "win32":
|
|
135
|
+
base = os.environ.get("LOCALAPPDATA")
|
|
136
|
+
if base:
|
|
137
|
+
cache = Path(base) / "pypilot" / "bin"
|
|
138
|
+
else:
|
|
139
|
+
cache = Path.home() / ".cache" / "pypilot" / "bin"
|
|
140
|
+
else:
|
|
141
|
+
base = os.environ.get("XDG_CACHE_HOME")
|
|
142
|
+
if base:
|
|
143
|
+
cache = Path(base) / "pypilot" / "bin"
|
|
144
|
+
else:
|
|
145
|
+
cache = Path.home() / ".cache" / "pypilot" / "bin"
|
|
146
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
return cache
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def cache_dir_for(tag: str) -> Path:
|
|
151
|
+
"""Cache directory for one release. Versioned, so two installs of
|
|
152
|
+
pypilot-cli pinned to different engines never fight over one file."""
|
|
153
|
+
path = get_cache_dir() / tag
|
|
154
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
return path
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def binary_name() -> str:
|
|
159
|
+
return "pypilot.exe" if sys.platform == "win32" else "pypilot"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def parse_version(out: str) -> Optional[Version]:
|
|
163
|
+
# Example: "pypilot 1.0.2"
|
|
164
|
+
for part in out.strip().split():
|
|
165
|
+
components = part.split(".")
|
|
166
|
+
if len(components) == 3 and all(c.isdigit() for c in components):
|
|
167
|
+
return int(components[0]), int(components[1]), int(components[2])
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def tag_version(tag: str) -> Optional[Version]:
|
|
172
|
+
return parse_version(tag.lstrip("v"))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def probe_binary(path: StrPath) -> Optional[Version]:
|
|
176
|
+
env = dict(os.environ)
|
|
177
|
+
env[_ENV_PROBING] = "1"
|
|
178
|
+
env[ENV_OFFLINE] = "1"
|
|
179
|
+
try:
|
|
180
|
+
res = subprocess.run(
|
|
181
|
+
[os.fspath(path), "--version"],
|
|
182
|
+
capture_output=True,
|
|
183
|
+
text=True,
|
|
184
|
+
env=env,
|
|
185
|
+
# macOS scans a freshly downloaded binary on its first launch, which
|
|
186
|
+
# can take several seconds. Timing out here made a good engine look
|
|
187
|
+
# broken and triggered a needless re-download.
|
|
188
|
+
timeout=30,
|
|
189
|
+
check=False,
|
|
190
|
+
)
|
|
191
|
+
if res.returncode == 0:
|
|
192
|
+
return parse_version(res.stdout)
|
|
193
|
+
except Exception:
|
|
194
|
+
pass
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def extract_archive(archive: StrPath, dest: StrPath) -> None:
|
|
199
|
+
"""Extract a downloaded `.zip` or `.tar.gz` release archive into `dest`."""
|
|
200
|
+
if os.fspath(archive).endswith(".zip"):
|
|
201
|
+
with zipfile.ZipFile(archive, "r") as zf:
|
|
202
|
+
zf.extractall(dest)
|
|
203
|
+
return
|
|
204
|
+
with tarfile.open(archive, "r:gz") as tf:
|
|
205
|
+
# Python 3.14 filters tar members by default and 3.12/3.13 warn that it
|
|
206
|
+
# is coming. Ask for the same behaviour everywhere it is available (it
|
|
207
|
+
# was backported to the late 3.8-3.11 releases): plain files only, and
|
|
208
|
+
# nothing may land outside `dest`.
|
|
209
|
+
if hasattr(tarfile, "data_filter"):
|
|
210
|
+
tf.extractall(dest, filter="data")
|
|
211
|
+
else:
|
|
212
|
+
tf.extractall(dest)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def install_binary(src: Path, dest: Path) -> None:
|
|
216
|
+
"""Put `src` at `dest` as a new file, never by rewriting `dest` in place.
|
|
217
|
+
|
|
218
|
+
macOS caches what it knows about an executable per file. Overwriting a
|
|
219
|
+
binary that was just run leaves the old cache entry pointing at new bytes,
|
|
220
|
+
and the next launch fails with "Exec format error". Copying to a temporary
|
|
221
|
+
name and renaming over `dest` gives it a fresh file instead.
|
|
222
|
+
|
|
223
|
+
Windows has no such cache, but its antivirus briefly holds a freshly
|
|
224
|
+
written .exe open, which makes an immediate rename fail with "Access is
|
|
225
|
+
denied". There, a plain copy is both sufficient and reliable.
|
|
226
|
+
"""
|
|
227
|
+
if sys.platform == "win32":
|
|
228
|
+
shutil.copy2(src, dest)
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
tmp = dest.with_name(f".{dest.name}.{os.getpid()}.tmp")
|
|
232
|
+
try:
|
|
233
|
+
shutil.copy2(src, tmp)
|
|
234
|
+
tmp.chmod(tmp.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
235
|
+
os.replace(tmp, dest)
|
|
236
|
+
finally:
|
|
237
|
+
if tmp.exists():
|
|
238
|
+
tmp.unlink()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def bundled_binary() -> Optional[Path]:
|
|
242
|
+
"""The engine shipped inside a platform wheel, if this is one."""
|
|
243
|
+
path = Path(__file__).resolve().parent / "_engine" / binary_name()
|
|
244
|
+
if not path.is_file():
|
|
245
|
+
return None
|
|
246
|
+
if sys.platform != "win32" and not os.access(path, os.X_OK):
|
|
247
|
+
# Wheels carry the executable bit, but a few installers drop it.
|
|
248
|
+
try:
|
|
249
|
+
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
250
|
+
except OSError:
|
|
251
|
+
return None
|
|
252
|
+
return path
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
# Downloading and verification
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def release_url(tag: str, asset: str) -> str:
|
|
260
|
+
return RELEASE_DOWNLOAD_URL.format(repo=GITHUB_REPO, tag=tag, asset=asset)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def download_timeout() -> float:
|
|
264
|
+
raw = os.environ.get(ENV_DOWNLOAD_TIMEOUT, "").strip()
|
|
265
|
+
try:
|
|
266
|
+
value = float(raw) if raw else DEFAULT_DOWNLOAD_TIMEOUT
|
|
267
|
+
except ValueError:
|
|
268
|
+
return DEFAULT_DOWNLOAD_TIMEOUT
|
|
269
|
+
return value if value > 0 else DEFAULT_DOWNLOAD_TIMEOUT
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _redact(url: str) -> str:
|
|
273
|
+
"""Drop a password from a proxy URL before it goes into a message."""
|
|
274
|
+
try:
|
|
275
|
+
parts = urllib.parse.urlsplit(url)
|
|
276
|
+
except ValueError:
|
|
277
|
+
return url
|
|
278
|
+
if parts.password is None:
|
|
279
|
+
return url
|
|
280
|
+
host = parts.hostname or ""
|
|
281
|
+
if parts.port:
|
|
282
|
+
host = f"{host}:{parts.port}"
|
|
283
|
+
netloc = f"{parts.username}:***@{host}" if parts.username else host
|
|
284
|
+
return urllib.parse.urlunsplit(parts._replace(netloc=netloc))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _network_hint(url: str, reason: object, timeout: float) -> str:
|
|
288
|
+
# The same lookup urllib's ProxyHandler does: HTTPS_PROXY / NO_PROXY first,
|
|
289
|
+
# then the Windows registry or macOS system settings.
|
|
290
|
+
proxies = urllib.request.getproxies()
|
|
291
|
+
host = urllib.parse.urlsplit(url).hostname or ""
|
|
292
|
+
proxy = proxies.get(urllib.parse.urlsplit(url).scheme)
|
|
293
|
+
if proxy and not urllib.request.proxy_bypass(host):
|
|
294
|
+
route = f"through proxy {_redact(proxy)}"
|
|
295
|
+
else:
|
|
296
|
+
route = "with no proxy"
|
|
297
|
+
return (
|
|
298
|
+
f"could not download {url} ({route}): {reason}. "
|
|
299
|
+
"Behind a proxy, set HTTPS_PROXY (and NO_PROXY for hosts that must "
|
|
300
|
+
"bypass it); the system proxy settings are used when those are unset. "
|
|
301
|
+
f"The timeout is {timeout:g}s ({ENV_DOWNLOAD_TIMEOUT}). To avoid the "
|
|
302
|
+
"download, install a platform wheel of pypilot-cli, build the engine "
|
|
303
|
+
"with `cargo install --path helper`, or put a `pypilot` engine on PATH."
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _download(url: str, dest: Path) -> None:
|
|
308
|
+
"""Fetch `url` into `dest`, honouring the environment's proxy settings."""
|
|
309
|
+
timeout = download_timeout()
|
|
310
|
+
request = urllib.request.Request(url, headers={"User-Agent": "pypilot-python"})
|
|
311
|
+
# A fresh opener per download: `urlopen` caches one built from the proxy
|
|
312
|
+
# settings at its first call.
|
|
313
|
+
opener = urllib.request.build_opener(urllib.request.ProxyHandler())
|
|
314
|
+
try:
|
|
315
|
+
with opener.open(request, timeout=timeout) as resp, open(dest, "wb") as out:
|
|
316
|
+
shutil.copyfileobj(resp, out)
|
|
317
|
+
except urllib.error.HTTPError as e:
|
|
318
|
+
e.close() # it holds the open response
|
|
319
|
+
raise EngineDownloadError(f"{url} returned HTTP {e.code} {e.reason}", status=e.code) from e
|
|
320
|
+
except (urllib.error.URLError, OSError) as e:
|
|
321
|
+
reason = getattr(e, "reason", None) or e
|
|
322
|
+
raise EngineDownloadError(_network_hint(url, reason, timeout)) from e
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def sha256_file(path: StrPath) -> str:
|
|
326
|
+
digest = hashlib.sha256()
|
|
327
|
+
with open(path, "rb") as f:
|
|
328
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
329
|
+
digest.update(chunk)
|
|
330
|
+
return digest.hexdigest()
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def parse_sha256sums(text: str) -> Dict[str, str]:
|
|
334
|
+
"""Parse `<sha256> <asset-name>` lines (the `sha256sum` format)."""
|
|
335
|
+
sums: Dict[str, str] = {}
|
|
336
|
+
for line in text.splitlines():
|
|
337
|
+
line = line.strip()
|
|
338
|
+
if not line or line.startswith("#"):
|
|
339
|
+
continue
|
|
340
|
+
parts = line.split()
|
|
341
|
+
if len(parts) < 2:
|
|
342
|
+
continue
|
|
343
|
+
# `sha256sum -b` marks binary mode with a leading `*`.
|
|
344
|
+
sums[parts[-1].lstrip("*")] = parts[0].lower()
|
|
345
|
+
return sums
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _offline_error(what: str) -> EngineNotFoundError:
|
|
349
|
+
return EngineNotFoundError(
|
|
350
|
+
f"{ENV_OFFLINE} is set, so PyPilot will not download {what}, and no "
|
|
351
|
+
"engine is available locally. Install a platform wheel of pypilot-cli "
|
|
352
|
+
"(it bundles the engine), build it with `cargo install --path helper`, "
|
|
353
|
+
f"put a `pypilot` engine on PATH, or unset {ENV_OFFLINE}."
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def expected_sha256(tag: str, asset: str) -> Optional[str]:
|
|
358
|
+
"""The SHA-256 `asset` of `tag` must have, or None if it may run unverified.
|
|
359
|
+
|
|
360
|
+
The pinned release is checked against `ENGINE_SHA256`; any other release
|
|
361
|
+
against its own `SHA256SUMS` asset. A release without one is refused
|
|
362
|
+
unless `PYPILOT_ALLOW_UNVERIFIED` is set.
|
|
363
|
+
"""
|
|
364
|
+
if tag == DEFAULT_TAG:
|
|
365
|
+
digest = ENGINE_SHA256.get(asset)
|
|
366
|
+
if digest is None:
|
|
367
|
+
raise EngineVerificationError(
|
|
368
|
+
f"pypilot-cli pins no SHA-256 for {asset} of {tag}, so it cannot be verified"
|
|
369
|
+
)
|
|
370
|
+
return digest.lower()
|
|
371
|
+
|
|
372
|
+
if is_offline():
|
|
373
|
+
raise _offline_error(f"{SHA256SUMS_ASSET} of {tag}")
|
|
374
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
375
|
+
sums_path = Path(tmpdir) / SHA256SUMS_ASSET
|
|
376
|
+
try:
|
|
377
|
+
_download(release_url(tag, SHA256SUMS_ASSET), sums_path)
|
|
378
|
+
except EngineDownloadError as e:
|
|
379
|
+
if e.status != 404:
|
|
380
|
+
raise
|
|
381
|
+
sums = None
|
|
382
|
+
else:
|
|
383
|
+
sums = parse_sha256sums(sums_path.read_text(encoding="utf-8", errors="replace"))
|
|
384
|
+
|
|
385
|
+
if sums is not None:
|
|
386
|
+
digest = sums.get(asset)
|
|
387
|
+
if digest is None:
|
|
388
|
+
raise EngineVerificationError(f"{SHA256SUMS_ASSET} of {tag} has no entry for {asset}")
|
|
389
|
+
return digest
|
|
390
|
+
|
|
391
|
+
if _env_flag(ENV_ALLOW_UNVERIFIED):
|
|
392
|
+
print(
|
|
393
|
+
f"PyPilot: warning: release {tag} publishes no {SHA256SUMS_ASSET}; "
|
|
394
|
+
f"running {asset} UNVERIFIED because {ENV_ALLOW_UNVERIFIED} is set.",
|
|
395
|
+
file=sys.stderr,
|
|
396
|
+
)
|
|
397
|
+
return None
|
|
398
|
+
raise EngineVerificationError(
|
|
399
|
+
f"release {tag} publishes no {SHA256SUMS_ASSET}, so {asset} cannot be verified. "
|
|
400
|
+
f"Set {ENV_ALLOW_UNVERIFIED}=1 to run it anyway, or unset {ENV_ENGINE_VERSION} "
|
|
401
|
+
f"to use the pinned {DEFAULT_TAG}."
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def download_verified_archive(tag: str, asset: str, dest_dir: StrPath) -> Path:
|
|
406
|
+
"""Download `asset` of release `tag` into `dest_dir` and check its SHA-256.
|
|
407
|
+
|
|
408
|
+
On a mismatch the file is deleted and `EngineVerificationError` raised, so
|
|
409
|
+
nothing unverified is ever extracted or run.
|
|
410
|
+
"""
|
|
411
|
+
if is_offline():
|
|
412
|
+
raise _offline_error(f"{asset} of {tag}")
|
|
413
|
+
expected = expected_sha256(tag, asset)
|
|
414
|
+
url = release_url(tag, asset)
|
|
415
|
+
archive = Path(dest_dir) / asset
|
|
416
|
+
print(f"PyPilot: downloading native engine from {url} ...", file=sys.stderr)
|
|
417
|
+
_download(url, archive)
|
|
418
|
+
if expected is not None:
|
|
419
|
+
actual = sha256_file(archive)
|
|
420
|
+
if actual != expected:
|
|
421
|
+
archive.unlink()
|
|
422
|
+
raise EngineVerificationError(
|
|
423
|
+
f"SHA-256 mismatch for {url}: expected {expected}, got {actual}. "
|
|
424
|
+
"The download was deleted without being run. Retry; if it keeps "
|
|
425
|
+
"failing, something between you and GitHub is altering the file."
|
|
426
|
+
)
|
|
427
|
+
return archive
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def download_binary(tag: str = DEFAULT_TAG) -> Path:
|
|
431
|
+
"""Download, verify and cache the engine of release `tag`."""
|
|
432
|
+
asset = asset_name()
|
|
433
|
+
bin_name = binary_name()
|
|
434
|
+
dest_binary = cache_dir_for(tag) / bin_name
|
|
435
|
+
|
|
436
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
437
|
+
archive = download_verified_archive(tag, asset, tmpdir)
|
|
438
|
+
out = Path(tmpdir) / "extracted"
|
|
439
|
+
out.mkdir()
|
|
440
|
+
extract_archive(archive, out)
|
|
441
|
+
|
|
442
|
+
extracted_bin = out / bin_name
|
|
443
|
+
if not extracted_bin.exists():
|
|
444
|
+
candidates = list(out.rglob(bin_name))
|
|
445
|
+
if not candidates:
|
|
446
|
+
raise FileNotFoundError(f"{bin_name} not found in downloaded archive {asset}")
|
|
447
|
+
extracted_bin = candidates[0]
|
|
448
|
+
|
|
449
|
+
install_binary(extracted_bin, dest_binary)
|
|
450
|
+
|
|
451
|
+
return dest_binary
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
# ---------------------------------------------------------------------------
|
|
455
|
+
# Lookup
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def requested_tag() -> Optional[str]:
|
|
459
|
+
"""The release `PYPILOT_ENGINE_VERSION` asks for, normalised to `vX.Y.Z`."""
|
|
460
|
+
raw = os.environ.get(ENV_ENGINE_VERSION, "").strip()
|
|
461
|
+
if not raw:
|
|
462
|
+
return None
|
|
463
|
+
return raw if raw.startswith("v") else f"v{raw}"
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _cached(tag: str) -> Optional[EngineInfo]:
|
|
467
|
+
path = cache_dir_for(tag) / binary_name()
|
|
468
|
+
if not path.is_file():
|
|
469
|
+
return None
|
|
470
|
+
version = probe_binary(path)
|
|
471
|
+
wanted = tag_version(tag)
|
|
472
|
+
if version is None or (wanted is not None and version != wanted):
|
|
473
|
+
return None
|
|
474
|
+
return EngineInfo(path, SOURCE_CACHE, version)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _own_launchers() -> Set[str]:
|
|
478
|
+
"""Paths under which this wrapper itself may appear on PATH."""
|
|
479
|
+
found: Set[str] = set()
|
|
480
|
+
candidates = []
|
|
481
|
+
try:
|
|
482
|
+
candidates.append(Path(sysconfig.get_path("scripts")) / binary_name())
|
|
483
|
+
except (KeyError, TypeError):
|
|
484
|
+
pass
|
|
485
|
+
if sys.argv and sys.argv[0]:
|
|
486
|
+
candidates += [Path(sys.argv[0]), Path(sys.argv[0] + ".exe")]
|
|
487
|
+
for candidate in candidates:
|
|
488
|
+
try:
|
|
489
|
+
found.add(os.path.normcase(str(candidate.resolve())))
|
|
490
|
+
except OSError:
|
|
491
|
+
pass
|
|
492
|
+
return found
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _from_path() -> Optional[EngineInfo]:
|
|
496
|
+
if os.environ.get(_ENV_PROBING):
|
|
497
|
+
return None
|
|
498
|
+
system_path = shutil.which(binary_name())
|
|
499
|
+
if not system_path:
|
|
500
|
+
return None
|
|
501
|
+
path = Path(system_path).resolve()
|
|
502
|
+
if str(path).lower().endswith((".py", ".pyw")):
|
|
503
|
+
return None
|
|
504
|
+
if os.path.normcase(str(path)) in _own_launchers():
|
|
505
|
+
return None
|
|
506
|
+
version = probe_binary(path)
|
|
507
|
+
if version and version >= MIN_VERSION:
|
|
508
|
+
return EngineInfo(path, SOURCE_PATH, version)
|
|
509
|
+
return None
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def locate_engine() -> EngineInfo:
|
|
513
|
+
"""Find the engine (downloading it if allowed); see the module docstring."""
|
|
514
|
+
override = requested_tag()
|
|
515
|
+
if override:
|
|
516
|
+
hit = _cached(override)
|
|
517
|
+
if hit is not None:
|
|
518
|
+
return EngineInfo(hit.path, SOURCE_OVERRIDE, hit.version)
|
|
519
|
+
path = download_binary(override)
|
|
520
|
+
return EngineInfo(path, SOURCE_OVERRIDE, probe_binary(path))
|
|
521
|
+
|
|
522
|
+
bundled = bundled_binary()
|
|
523
|
+
if bundled is not None:
|
|
524
|
+
# The build script only packs the pinned, verified asset.
|
|
525
|
+
return EngineInfo(bundled, SOURCE_BUNDLED, BUNDLED_VERSION)
|
|
526
|
+
|
|
527
|
+
hit = _cached(DEFAULT_TAG)
|
|
528
|
+
if hit is not None:
|
|
529
|
+
return hit
|
|
530
|
+
|
|
531
|
+
cargo_bin = Path.home() / ".cargo" / "bin" / binary_name()
|
|
532
|
+
if cargo_bin.is_file():
|
|
533
|
+
version = probe_binary(cargo_bin)
|
|
534
|
+
if version and version >= MIN_VERSION:
|
|
535
|
+
return EngineInfo(cargo_bin, SOURCE_CARGO, version)
|
|
536
|
+
|
|
537
|
+
on_path = _from_path()
|
|
538
|
+
if on_path is not None:
|
|
539
|
+
return on_path
|
|
540
|
+
|
|
541
|
+
if is_offline():
|
|
542
|
+
raise _offline_error("the engine")
|
|
543
|
+
return EngineInfo(download_binary(), SOURCE_DOWNLOAD, BUNDLED_VERSION)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def ensure_binary() -> Path:
|
|
547
|
+
"""Path to a usable engine, downloading it if needed and allowed."""
|
|
548
|
+
return locate_engine().path
|
pypilot/py.typed
ADDED
|
File without changes
|