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/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