Repository navigation
Expand file tree
/
Copy pathcheck_extras_sync.py
More file actions
118 lines (93 loc) · 3.88 KB
/
Copy pathcheck_extras_sync.py
File metadata and controls
118 lines (93 loc) · 3.88 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
"""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))