#
# The high-throughput toolkit (httk)
# Copyright (C) 2012-2024 the httk AUTHORS
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""Portable documentation lock files and injectable ``uv pip compile`` tooling.
The lock keeps four contract headers: schema, a canonical-input SHA-256 hash,
Python 3.12, and Linux. The resolver's comment noise is discarded; only
``name==version`` pins are retained after the headers. Its pins cover runtime,
documentation, and build-backend requirements so a clean Python 3.12
environment can install the lock before using ``--no-build-isolation``.
"""
import hashlib
import json
import os
import re
import subprocess
import tempfile
import tomllib
from collections.abc import Iterable, Mapping, Sequence
from pathlib import Path
__all__ = [
"LockError",
"check_lock",
"compute_input_hash",
"filter_lock_pins",
"generate_lock",
"internal_pins",
"read_lock_pins",
]
[docs]
class LockError(RuntimeError):
"""Raised when a documentation lock is absent, stale, or cannot be made."""
def _normalize_name(name: str) -> str:
return re.sub(r"[-_.]+", "-", name).lower()
def _load_pyproject(path: Path) -> dict[str, object]:
try:
with path.open("rb") as stream:
metadata = tomllib.load(stream)
except (OSError, tomllib.TOMLDecodeError) as exc:
raise LockError(f"cannot read project metadata {path}: {exc}") from exc
if not isinstance(metadata, dict):
raise LockError(f"{path}: project metadata must be a table")
return metadata
def _build_requirements(metadata: dict[str, object], path: Path) -> list[str]:
build_system = metadata.get("build-system", {})
if not isinstance(build_system, dict):
raise LockError(f"{path}: build-system must be a table")
requirements = build_system.get("requires", [])
if not isinstance(requirements, list) or any(not isinstance(item, str) for item in requirements):
raise LockError(f"{path}: build-system.requires must be an array of strings")
return sorted(requirements)
_PIN_PATTERN = re.compile(r"^([A-Za-z0-9][A-Za-z0-9_.-]*)==([^\s#]+)$")
def _parse_pins(lines: Iterable[str], path: Path, *, allow_comments: bool) -> list[str]:
pin_lines: list[str] = []
names: set[str] = set()
for line in lines:
stripped = line.strip()
if not stripped:
continue
if stripped.startswith("#") and allow_comments:
continue
match = _PIN_PATTERN.fullmatch(stripped)
if match is None:
raise LockError(f"unrecognized lock line in {path}: {stripped!r}")
name = _normalize_name(match.group(1))
if name in names:
raise LockError(f"duplicate lock pin {match.group(1)!r} in {path}")
names.add(name)
pin_lines.append(stripped)
if not pin_lines:
raise LockError(f"lock contains no pins: {path}")
return sorted(pin_lines, key=lambda line: _normalize_name(line.split("==", 1)[0]))
[docs]
def generate_lock(
project_dir: str | Path,
output_path: str | Path,
*,
command_prefix: Sequence[str] | None = None,
) -> None:
"""Generate a lock by invoking ``uv pip compile`` or an injected command."""
project = Path(project_dir).resolve()
pyproject = project / "pyproject.toml"
input_hash = compute_input_hash(pyproject)
build_requirements = _build_requirements(_load_pyproject(pyproject), pyproject)
output = Path(output_path)
if not output.is_absolute():
output = project / output
if output.is_symlink():
raise LockError(f"refusing symlink lock destination: {output}")
output.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{output.name}.staging-", suffix=".txt", dir=output.parent)
os.close(descriptor)
temporary_path = Path(temporary_name)
build_input_path: Path | None = None
try:
if build_requirements:
build_descriptor, build_input_name = tempfile.mkstemp(
prefix=".httk-docs-build-input-", suffix=".txt", dir=project
)
build_input_path = Path(build_input_name)
with os.fdopen(build_descriptor, "w", encoding="utf-8") as stream:
stream.write("\n".join(build_requirements) + "\n")
command = list(command_prefix) if command_prefix is not None else ["uv"]
inputs = ["pyproject.toml"]
if build_input_path is not None:
inputs.append(str(build_input_path))
command.extend(
[
"pip",
"compile",
*inputs,
"--extra",
"docs",
"--python-version",
"3.12",
"--python-platform",
"linux",
"--no-header",
"-o",
str(temporary_path),
]
)
result = subprocess.run(command, cwd=project, capture_output=True, text=True, check=False)
if result.returncode != 0:
detail = (result.stderr or result.stdout).strip()
raise LockError(f"uv pip compile failed ({result.returncode}): {detail}")
pins = _parse_pins(temporary_path.read_text(encoding="utf-8").splitlines(), temporary_path, allow_comments=True)
headers = [
"# httk-docs-lock-schema: 1",
f"# input-hash: sha256:{input_hash}",
"# python: 3.12",
"# platform: linux",
]
temporary_path.write_text("\n".join(headers + pins) + "\n", encoding="utf-8")
if output.is_symlink():
raise LockError(f"refusing symlink lock destination: {output}")
os.replace(temporary_path, output)
except OSError as exc:
raise LockError(f"cannot generate lock {output}: {exc}") from exc
finally:
temporary_path.unlink(missing_ok=True)
if build_input_path is not None:
build_input_path.unlink(missing_ok=True)
def _read_hash_header(lock_path: Path) -> str:
try:
lines = lock_path.read_text(encoding="utf-8").splitlines()
except OSError as exc:
raise LockError(f"cannot read lock {lock_path}: {exc}") from exc
expected_headers = [
"# httk-docs-lock-schema: 1",
None,
"# python: 3.12",
"# platform: linux",
]
if len(lines) < 4 or lines[0] != expected_headers[0]:
raise LockError(f"lock schema header missing in {lock_path}")
header = lines[1] if len(lines) > 1 else None
if header is None or not re.fullmatch(r"# input-hash: sha256:[0-9a-f]{64}", header):
raise LockError(f"input-hash header missing or invalid in {lock_path}")
if lines[2] != expected_headers[2]:
raise LockError(f"python header invalid in {lock_path}; expected # python: 3.12")
if lines[3] != expected_headers[3]:
raise LockError(f"platform header invalid in {lock_path}; expected # platform: linux")
return header.split("sha256:", 1)[1]
[docs]
def check_lock(project_dir: str | Path, lock_path: str | Path) -> None:
"""Raise :class:`LockError` if a lock is missing or stale for *project_dir*."""
project = Path(project_dir).resolve()
path = Path(lock_path)
if not path.is_absolute():
path = project / path
if not path.is_file():
raise LockError(f"lock file missing: {path}; regenerate with make docs-lock")
recorded = _read_hash_header(path)
actual = compute_input_hash(project / "pyproject.toml")
if recorded != actual:
raise LockError(f"input-hash mismatch in {path}; regenerate with make docs-lock")
_parse_pins(path.read_text(encoding="utf-8").splitlines()[4:], path, allow_comments=True)
[docs]
def read_lock_pins(lock_path: str | Path) -> dict[str, str]:
"""Read normalized distribution names and versions from a lock file."""
pins: dict[str, str] = {}
for line in Path(lock_path).read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or "==" not in stripped:
continue
name, version = stripped.split("==", 1)
pins[_normalize_name(name.strip())] = version.strip()
return pins
[docs]
def filter_lock_pins(pins: Mapping[str, str], *, drop: Iterable[str]) -> dict[str, str]:
"""Return pins excluding all ``httk-*`` names and the explicitly dropped names."""
dropped = {_normalize_name(name) for name in drop}
normalized = {_normalize_name(name): version for name, version in pins.items()}
return {
name: version for name, version in normalized.items() if not name.startswith("httk-") and name not in dropped
}
[docs]
def internal_pins(pins: Mapping[str, str]) -> dict[str, str]:
"""Return the normalized subset of pins belonging to internal httk distributions."""
normalized = {_normalize_name(name): version for name, version in pins.items()}
return {name: version for name, version in normalized.items() if name.startswith("httk-")}