# SPDX-FileCopyrightText: 2023-present Rohit Goswami <rog32@hi.is>
# SPDX-License-Identifier: MIT
"""Consume Python lock files / SBOMs as install pins for dispatch + AUTO_DEPS.
Supported formats (auto-detected by path/content):
* **PEP 751** ``pylock.toml`` / ``pylock.*.toml`` — standards-track lock
* **uv.lock** — uv native TOML lock (``[[package]]``)
* **CycloneDX JSON** — e.g. eb-stack ``--sbom-out`` or ``uv export --format cyclonedx1.5``
rgpycrumbs does **not** generate these files. When a path is provided via
``RGPYCRUMBS_LOCK`` / ``--lock`` (or the SBOM aliases), PyPI packages become
``name==version`` constraints for ``uv run`` and ``ensure_import``.
Non-Python / non-PyPI entries (e.g. eb-stack ``pkg:generic/...``) are skipped.
.. versionadded:: 1.9.13
.. versionchanged:: 1.9.14
Also accept PEP 751 pylock and uv.lock (not only CycloneDX).
"""
from __future__ import annotations
import json
import re
import sys
from enum import Enum
from pathlib import Path
from typing import Any
if sys.version_info >= (3, 11):
import tomllib
else: # pragma: no cover
try:
import tomli as tomllib # type: ignore[no-redef]
except ImportError as exc: # pragma: no cover
[docs]
tomllib = None # type: ignore[assignment]
_TOML_IMPORT_ERROR = exc
else:
_TOML_IMPORT_ERROR = None
# Primary path env (any supported format). Ecosystem name first.
[docs]
LOCK_PATH_ENV = "RGPKGS_LOCK"
[docs]
LOCK_PATH_ENV_LEGACY = "RGPYCRUMBS_LOCK"
# Backward-compatible alias (CycloneDX or any format — same loader).
[docs]
SBOM_PATH_ENV = "RGPYCRUMBS_SBOM"
# JSON object {normalized_name: version} for child ensure_import / AUTO_DEPS.
[docs]
PINS_ENV = "RGPKGS_LOCK_PINS"
[docs]
PINS_ENV_LEGACY = "RGPYCRUMBS_LOCK_PINS"
# Older SBOM-era name still read (dispatch writes all).
[docs]
SBOM_PINS_ENV = "RGPYCRUMBS_SBOM_PINS"
[docs]
_VERSION_OPS = re.compile(r"(==|>=|<=|~=|!=|>|<)")
[docs]
_PYLOCK_NAME = re.compile(r"^pylock(\.[^.]+)?\.toml$", re.IGNORECASE)
[docs]
def normalize_pypi_name(name: str) -> str:
"""PEP 503-ish name normalization for pin lookups."""
return re.sub(r"[-_.]+", "-", name).lower()
[docs]
def package_name_from_spec(pip_spec: str) -> str:
"""Return the distribution name from a pip requirement string."""
spec = pip_spec.strip()
base = spec.split("[", maxsplit=1)[0]
parts = _VERSION_OPS.split(base, maxsplit=1)
return parts[0].strip()
[docs]
def _require_tomllib() -> Any:
if tomllib is None: # pragma: no cover
msg = "Reading TOML lock files requires Python 3.11+ tomllib or the tomli package"
raise ImportError(msg) from _TOML_IMPORT_ERROR
return tomllib
[docs]
def _pin_from_purl(purl: str) -> tuple[str, str] | None:
"""Parse ``pkg:pypi/name@version`` (optional qualifiers after ``?``)."""
if not purl.startswith("pkg:pypi/"):
return None
rest = purl[len("pkg:pypi/") :]
rest = rest.split("?", maxsplit=1)[0]
if "@" not in rest:
return None
name, version = rest.rsplit("@", maxsplit=1)
name = name.strip().replace("%2D", "-").replace("%5F", "_")
version = version.strip()
if not name or not version:
return None
return name, version
[docs]
def pypi_pins_from_cyclonedx(doc: dict[str, Any]) -> dict[str, str]:
"""Extract ``{normalized_name: version}`` for PyPI components only."""
pins: dict[str, str] = {}
components = doc.get("components") or []
if not isinstance(components, list):
return pins
for comp in components:
if not isinstance(comp, dict):
continue
purl = comp.get("purl") or ""
if isinstance(purl, str) and purl:
parsed = _pin_from_purl(purl)
if parsed is not None:
name, version = parsed
pins[normalize_pypi_name(name)] = version
continue
if purl.startswith("pkg:") and not purl.startswith("pkg:pypi/"):
continue
bom_ref = comp.get("bom-ref") or ""
if isinstance(bom_ref, str) and bom_ref.startswith("pkg:pypi/"):
parsed = _pin_from_purl(bom_ref)
if parsed is not None:
name, version = parsed
pins[normalize_pypi_name(name)] = version
continue
ctype = (comp.get("type") or "").lower()
name = comp.get("name")
version = comp.get("version")
if ctype in {"pypi", "library"} and name and version:
props = comp.get("properties") or []
marked_pypi = False
if isinstance(props, list):
for prop in props:
if not isinstance(prop, dict):
continue
if prop.get("name") in {
"pypi:package",
"rgpycrumbs:ecosystem",
} and str(prop.get("value", "")).lower() in {
"1",
"true",
"pypi",
"python",
}:
marked_pypi = True
break
if ctype == "pypi" or marked_pypi:
pins[normalize_pypi_name(str(name))] = str(version)
return pins
[docs]
def pypi_pins_from_pylock(doc: dict[str, Any]) -> dict[str, str]:
"""Extract pins from a PEP 751 pylock.toml document.
Uses ``[[packages]]`` entries with ``name`` + ``version``. Packages without
a version (rare) are skipped. Marker diversity is ignored for pin map
purposes: last wins (constraints still force that version for installs).
"""
pins: dict[str, str] = {}
packages = doc.get("packages") or []
if not isinstance(packages, list):
return pins
for pkg in packages:
if not isinstance(pkg, dict):
continue
name = pkg.get("name")
version = pkg.get("version")
if not name or version is None or version == "":
continue
pins[normalize_pypi_name(str(name))] = str(version)
return pins
[docs]
def pypi_pins_from_uv_lock(doc: dict[str, Any]) -> dict[str, str]:
"""Extract pins from a uv.lock TOML document (``[[package]]`` tables)."""
pins: dict[str, str] = {}
packages = doc.get("package") or []
if not isinstance(packages, list):
return pins
for pkg in packages:
if not isinstance(pkg, dict):
continue
name = pkg.get("name")
version = pkg.get("version")
if not name or version is None or version == "":
continue
# Skip virtual path sources without a real version? uv always has version.
pins[normalize_pypi_name(str(name))] = str(version)
return pins
[docs]
def load_cyclonedx(path: str | Path) -> dict[str, Any]:
"""Load and validate a CycloneDX JSON document from *path*."""
p = Path(path)
if not p.is_file():
msg = f"Lock/SBOM path does not exist or is not a file: {p}"
raise FileNotFoundError(msg)
try:
data = json.loads(p.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
msg = f"Lock/SBOM is not valid JSON: {p}: {exc}"
raise ValueError(msg) from exc
if not isinstance(data, dict):
msg = f"Lock/SBOM root must be a JSON object: {p}"
raise ValueError(msg)
bom_format = data.get("bomFormat")
if bom_format is not None and bom_format != "CycloneDX":
msg = f"Unsupported bomFormat {bom_format!r} (expected CycloneDX): {p}"
raise ValueError(msg)
return data
[docs]
def load_toml_lock(path: str | Path) -> dict[str, Any]:
"""Load a TOML lock document (pylock or uv.lock)."""
p = Path(path)
if not p.is_file():
msg = f"Lock path does not exist or is not a file: {p}"
raise FileNotFoundError(msg)
toml = _require_tomllib()
try:
data = toml.loads(p.read_text(encoding="utf-8"))
except Exception as exc:
msg = f"Lock file is not valid TOML: {p}: {exc}"
raise ValueError(msg) from exc
if not isinstance(data, dict):
msg = f"Lock root must be a TOML table: {p}"
raise ValueError(msg)
return data
[docs]
def load_pypi_pins(path: str | Path) -> dict[str, str]:
"""Load any supported lock/SBOM path and return a PyPI pin map.
Raises:
FileNotFoundError: path missing
ValueError: unreadable / unsupported / empty-of-python when required
"""
p = Path(path)
if not p.is_file():
msg = f"Lock/SBOM path does not exist or is not a file: {p}"
raise FileNotFoundError(msg)
text = p.read_text(encoding="utf-8")
fmt = detect_lock_format(p, text)
if fmt is LockFormat.CYCLONEDX:
return pypi_pins_from_cyclonedx(load_cyclonedx(p))
if fmt is LockFormat.PYLOCK:
doc = load_toml_lock(p)
if "lock-version" not in doc and "packages" not in doc:
msg = f"Not a PEP 751 pylock document (missing lock-version/packages): {p}"
raise ValueError(msg)
return pypi_pins_from_pylock(doc)
if fmt is LockFormat.UV_LOCK:
doc = load_toml_lock(p)
if "package" not in doc and "[[package]]" not in text:
msg = f"Not a uv.lock document (missing [[package]]): {p}"
raise ValueError(msg)
return pypi_pins_from_uv_lock(doc)
# Last-chance content sniff
stripped = text.lstrip()
if stripped.startswith("{"):
return pypi_pins_from_cyclonedx(load_cyclonedx(p))
if "[[packages]]" in text or "lock-version" in text:
return pypi_pins_from_pylock(load_toml_lock(p))
if "[[package]]" in text:
return pypi_pins_from_uv_lock(load_toml_lock(p))
msg = (
f"Unrecognized lock/SBOM format for {p}. "
"Supported: uv.lock, pylock.toml / pylock.*.toml, CycloneDX JSON."
)
raise ValueError(msg)
[docs]
def pins_to_constraint_lines(pins: dict[str, str]) -> list[str]:
"""Render pin map as pip/uv constraint lines (``name==version``)."""
return [f"{name}=={pins[name]}" for name in sorted(pins)]
[docs]
def apply_pin_to_spec(pip_spec: str, pins: dict[str, str]) -> str:
"""If *pip_spec*'s package is in *pins*, return ``name==version``."""
if not pins:
return pip_spec
raw_name = package_name_from_spec(pip_spec)
key = normalize_pypi_name(raw_name)
if key not in pins:
return pip_spec
return f"{raw_name}=={pins[key]}"
[docs]
def pins_from_env(env: dict[str, str] | None = None) -> dict[str, str]:
"""Read pin map from ``RGPKGS_LOCK_PINS`` / legacy pin envs."""
import os
environ = env if env is not None else os.environ
raw = (
environ.get(PINS_ENV, "").strip()
or environ.get(PINS_ENV_LEGACY, "").strip()
or environ.get(SBOM_PINS_ENV, "").strip()
)
if not raw:
return {}
try:
data = json.loads(raw)
except json.JSONDecodeError:
return {}
if not isinstance(data, dict):
return {}
out: dict[str, str] = {}
for k, v in data.items():
if k is None or v is None:
continue
out[normalize_pypi_name(str(k))] = str(v)
return out