119 lines
3.9 KiB
Python
119 lines
3.9 KiB
Python
"""Check that optional extras stay in sync with required dependencies.
|
|
|
|
When a package appears in both [project.dependencies] and
|
|
[project.optional-dependencies], we ensure their version constraints match.
|
|
This prevents silent version drift (e.g. bumping a required dep but
|
|
forgetting the corresponding extra).
|
|
"""
|
|
|
|
import sys
|
|
import tomllib
|
|
from pathlib import Path
|
|
from re import compile as re_compile
|
|
|
|
# Matches the package name at the start of a PEP 508 dependency string.
|
|
# Stops at the first non-name character; downstream code is responsible for
|
|
# stripping extras (`[...]`) and env markers (`; ...`) from the remainder.
|
|
_NAME_RE = re_compile(r"^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)")
|
|
|
|
|
|
def _normalize(name: str) -> str:
|
|
"""Normalize a package name for equality comparison.
|
|
|
|
Lowercases and maps `-` and `.` to `_`. Looser than PEP 503
|
|
(which uses `-` and collapses runs), but sufficient for matching the
|
|
same package across two PEP 508 strings.
|
|
|
|
Returns:
|
|
Lowercased, underscore-normalized package name.
|
|
"""
|
|
return name.lower().replace("-", "_").replace(".", "_")
|
|
|
|
|
|
def _parse_dep(dep: str) -> tuple[str, str]:
|
|
"""Return `(normalized_name, version_spec)` from a PEP 508 string.
|
|
|
|
Strips extras (`pkg[async]`), environment markers (`; python_version ...`),
|
|
URL specifiers (`pkg @ git+...`), and whitespace so the returned
|
|
`version_spec` is directly comparable between a required and optional dep.
|
|
|
|
Returns:
|
|
Tuple of normalized package name and bare version specifier.
|
|
|
|
Raises:
|
|
ValueError: If the dependency string cannot be parsed.
|
|
"""
|
|
match = _NAME_RE.match(dep)
|
|
if not match:
|
|
msg = f"Cannot parse dependency: {dep!r}"
|
|
raise ValueError(msg)
|
|
name = match.group(1)
|
|
rest = dep[match.end() :].strip()
|
|
|
|
if rest.startswith("["):
|
|
close = rest.find("]")
|
|
if close == -1:
|
|
msg = f"Unclosed extras bracket in dependency: {dep!r}"
|
|
raise ValueError(msg)
|
|
rest = rest[close + 1 :].strip()
|
|
|
|
if ";" in rest:
|
|
rest = rest.split(";", 1)[0].strip()
|
|
|
|
# URL specifiers have no comparable version; treat as unconstrained.
|
|
if rest.startswith("@"):
|
|
rest = ""
|
|
|
|
rest = " ".join(rest.split())
|
|
return _normalize(name), rest
|
|
|
|
|
|
def main(pyproject_path: Path) -> int:
|
|
"""Check extras sync and return `0` on pass, `1` on mismatch or parse error."""
|
|
with pyproject_path.open("rb") as f:
|
|
data = tomllib.load(f)
|
|
|
|
required: dict[str, str] = {}
|
|
for dep in data.get("project", {}).get("dependencies", []):
|
|
try:
|
|
name, spec = _parse_dep(dep)
|
|
except ValueError as e:
|
|
print(f"::error file={pyproject_path}::{e}")
|
|
return 1
|
|
required[name] = spec
|
|
|
|
optional = data.get("project", {}).get("optional-dependencies", {})
|
|
if not optional:
|
|
return 0
|
|
|
|
mismatches: list[str] = []
|
|
for group, deps in optional.items():
|
|
for dep in deps:
|
|
try:
|
|
name, spec = _parse_dep(dep)
|
|
except ValueError as e:
|
|
print(f"::error file={pyproject_path}::{e}")
|
|
return 1
|
|
if name in required and spec != required[name]:
|
|
mismatches.append(
|
|
f" [{group}] {name}: extra has '{spec}' "
|
|
f"but required dep has '{required[name]}'"
|
|
)
|
|
|
|
if mismatches:
|
|
print(f"Extra / required dependency version mismatch in {pyproject_path}:")
|
|
print("\n".join(mismatches))
|
|
print(
|
|
"\nUpdate the optional extras in [project.optional-dependencies] "
|
|
"to match [project.dependencies]."
|
|
)
|
|
return 1
|
|
|
|
print(f"All extras in {pyproject_path} are in sync with required dependencies.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("pyproject.toml")
|
|
raise SystemExit(main(path))
|