-
Notifications
You must be signed in to change notification settings - Fork 5.1k
Expand file tree
/
Copy pathcheck-python-version-policy.py
More file actions
142 lines (118 loc) · 5.45 KB
/
Copy pathcheck-python-version-policy.py
File metadata and controls
142 lines (118 loc) · 5.45 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
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SUPPORTED = ("3.10", "3.11", "3.12", "3.13", "3.14")
MINIMUM = SUPPORTED[0]
CURRENT_STABLE = SUPPORTED[-1]
PRERELEASE = "3.15"
UNMARKED_DEPENDENCIES = (
"aiohttp>=3.14.1",
"httpx2>=2.7.0, <3",
"anyio>=4.10.0, <5",
"botocore>=1.40.0,<2",
)
def require(condition: bool, message: str) -> None:
if not condition:
raise RuntimeError(message)
def workflow_job(workflow: str, name: str) -> str:
match = re.search(
rf"^ {re.escape(name)}:\n(?P<body>.*?)(?=^ [A-Za-z0-9_-]+:\n|\Z)",
workflow,
re.MULTILINE | re.DOTALL,
)
if match is None:
raise RuntimeError(f"CI does not define the {name!r} job")
return match.group("body")
def matrix_body(job: str) -> str:
match = re.search(
r"^ matrix:\n(?P<body>.*?)(?=^ [A-Za-z0-9_-]+:\n|\Z)",
job,
re.MULTILINE | re.DOTALL,
)
if match is None:
raise RuntimeError("CI job does not define a strategy matrix")
return match.group("body")
def matrix_versions(job: str) -> tuple[str, ...]:
versions: list[str] = []
for line in matrix_body(job).splitlines():
if re.match(r"^\s+(?:-\s+)?python-version:", line):
versions.extend(re.findall(r'"(3\.\d+)"', line))
return tuple(versions)
def compatibility_matrix(job: str) -> tuple[tuple[str, bool], ...]:
rows = re.findall(
r'^ +-\s+python-version: "(3\.\d+)"\n +experimental: (true|false)$',
matrix_body(job),
re.MULTILINE,
)
return tuple((version, experimental == "true") for version, experimental in rows)
def main() -> None:
pyproject = (ROOT / "pyproject.toml").read_text()
readme = (ROOT / "README.md").read_text()
contributing = (ROOT / "CONTRIBUTING.md").read_text()
policy = (ROOT / "PYTHON_VERSION_POLICY.md").read_text()
workflow = (ROOT / ".github/workflows/ci.yml").read_text()
uv_lock = (ROOT / "uv.lock").read_text()
realtime_example = (ROOT / "examples/realtime/push_to_talk_app.py").read_text()
python_version = (ROOT / ".python-version").read_text().strip()
requires_python = re.findall(r'^requires-python = "([^"]+)"$', pyproject, re.MULTILINE)
require(requires_python == [f">= {MINIMUM}"], f"Unexpected requires-python values: {requires_python}")
uv_metadata = uv_lock.split("[[package]]", 1)[0]
uv_requires_python = re.findall(r'^requires-python = "([^"]+)"$', uv_metadata, re.MULTILINE)
require(
uv_requires_python == [f">={MINIMUM}"],
f"uv.lock requires-python is {uv_requires_python}, expected >={MINIMUM}",
)
classifiers = re.findall(r'"Programming Language :: Python :: (3\.\d+)"', pyproject)
require(tuple(classifiers) == SUPPORTED, f"Unexpected Python classifiers: {classifiers}")
require(f'pythonVersion = "{MINIMUM}"' in pyproject, "Pyright does not target the minimum Python")
require(f'target-version = "py{MINIMUM.replace(".", "")}"' in pyproject, "Ruff does not target the minimum Python")
require(python_version.startswith(f"{MINIMUM}."), f".python-version is not on Python {MINIMUM}: {python_version}")
require(f"Python {MINIMUM}+" in readme, "README introduction does not state the minimum Python")
require(f"Python {MINIMUM} or higher." in readme, "README requirements do not state the minimum Python")
require("PYTHON_VERSION_POLICY.md" in contributing, "CONTRIBUTING does not link the Python policy")
require(
f'# requires-python = ">={MINIMUM}"' in realtime_example,
"Realtime example metadata does not state the minimum Python",
)
pr_versions = matrix_versions(workflow_job(workflow, "test"))
expected_pr_versions = (MINIMUM, CURRENT_STABLE)
require(
pr_versions == expected_pr_versions,
f"PR test matrix is {pr_versions}, expected {expected_pr_versions}",
)
compatibility_job = workflow_job(workflow, "compatibility")
require(
"continue-on-error: ${{ matrix.experimental }}" in compatibility_job,
"Scheduled compatibility failures are not controlled by matrix.experimental",
)
require(
"allow-prereleases: ${{ matrix.experimental }}" in compatibility_job,
"Scheduled compatibility prerelease setup is not controlled by matrix.experimental",
)
compatibility = compatibility_matrix(compatibility_job)
expected_compatibility = tuple((version, False) for version in SUPPORTED) + ((PRERELEASE, True),)
require(
compatibility == expected_compatibility,
f"Scheduled compatibility matrix is {compatibility}, expected {expected_compatibility}",
)
require(
f"Python {MINIMUM} through\n{CURRENT_STABLE}" in policy,
"Policy current-compatibility text does not match the supported matrix",
)
project_metadata = pyproject.split("[tool.rye]", 1)[0]
for requirement in UNMARKED_DEPENDENCIES:
require(
f'"{requirement}"' in project_metadata,
f"Package metadata does not contain the unmarked requirement {requirement!r}",
)
require(
re.search(rf'"{re.escape(requirement)};\s*python_version\b', project_metadata) is None,
f"Package metadata still contains a Python marker for {requirement!r}",
)
print(
f"Python policy is synchronized: minimum {MINIMUM}, "
f"supported {SUPPORTED[0]}-{SUPPORTED[-1]}, prerelease {PRERELEASE}"
)
if __name__ == "__main__":
main()