81 lines
3.5 KiB
Python
81 lines
3.5 KiB
Python
"""Standard-library preflight for the device's offline Python requirements."""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
from pathlib import Path
|
|
import re
|
|
|
|
|
|
_REQUIREMENT = re.compile(r"^([A-Za-z0-9_.-]+)(?:\[[A-Za-z0-9_,.-]+\])?(.*)$")
|
|
_DIGEST_LINE = re.compile(r"^([0-9a-f]{64}) ([^/\\]+\.whl)$")
|
|
|
|
|
|
def _normalized(name: str) -> str:
|
|
return re.sub(r"[-_.]+", "-", name).lower()
|
|
|
|
|
|
def _requirements(source: Path, path: Path, visited: set[Path]) -> list[tuple[str, str]]:
|
|
path = path.resolve()
|
|
try:
|
|
path.relative_to(source.resolve())
|
|
except ValueError as exc:
|
|
raise ValueError("requirement include escapes the source directory") from exc
|
|
if path in visited:
|
|
return []
|
|
visited.add(path)
|
|
result: list[tuple[str, str]] = []
|
|
for line in path.read_text(encoding="utf-8").splitlines():
|
|
item = line.split("#", 1)[0].strip()
|
|
if not item:
|
|
continue
|
|
if item.startswith("-r "):
|
|
result.extend(_requirements(source, path.parent / item[3:].strip(), visited))
|
|
continue
|
|
match = _REQUIREMENT.fullmatch(item)
|
|
if match is None:
|
|
raise ValueError(f"unsupported device requirement in {path.name}")
|
|
name, specifier = match.groups()
|
|
if specifier and not specifier.startswith(("==", ">=", "<=", ">", "<", "!=", "~=")):
|
|
raise ValueError(f"unsupported device requirement in {path.name}")
|
|
result.append((_normalized(name), specifier))
|
|
return result
|
|
|
|
|
|
def validate_offline_wheels(source: Path, wheelhouse: Path) -> None:
|
|
"""Reject a missing direct dependency or inconsistent wheel SHA-256 list.
|
|
|
|
Full transitive and platform resolution remains a separate offline pip gate
|
|
against the extracted candidate, followed by the board's actual venv install.
|
|
"""
|
|
source = Path(source).resolve()
|
|
wheelhouse = Path(wheelhouse).resolve()
|
|
requirements = _requirements(source, source / "requirements-dev.txt", set())
|
|
listed: dict[str, str] = {}
|
|
for line in (wheelhouse / "SHA256SUMS").read_text(encoding="ascii").splitlines():
|
|
match = _DIGEST_LINE.fullmatch(line)
|
|
if match is None or match[2] in listed:
|
|
raise ValueError("offline wheel SHA256SUMS contains an invalid or repeated entry")
|
|
listed[match[2]] = match[1]
|
|
actual = {path.name for path in wheelhouse.iterdir() if path.is_file() and path.suffix == ".whl"}
|
|
if not listed or actual != set(listed):
|
|
raise ValueError("offline wheel files and SHA256SUMS do not match")
|
|
packages: dict[str, set[str]] = {}
|
|
for filename, expected in listed.items():
|
|
parts = filename.removesuffix(".whl").split("-")
|
|
if len(parts) < 5:
|
|
raise ValueError(f"invalid offline wheel filename: {filename}")
|
|
name, version = _normalized(parts[0]), parts[1]
|
|
packages.setdefault(name, set()).add(version)
|
|
digest = hashlib.sha256()
|
|
with (wheelhouse / filename).open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
if digest.hexdigest() != expected:
|
|
raise ValueError(f"offline wheel digest differs from SHA256SUMS: {filename}")
|
|
for name, specifier in requirements:
|
|
versions = packages.get(name, set())
|
|
if not versions:
|
|
raise ValueError(f"offline wheel missing for direct requirement: {name}")
|
|
if specifier.startswith("==") and specifier[2:] not in versions:
|
|
raise ValueError(f"offline wheel version missing for direct requirement: {name}{specifier}")
|