Skip to content

Commit fde7be5

Browse files
Merge pull request #200 from dashscope/dev/issues-9-1
fix(packaging): declare Python 3.14 and guard the classifiers in CI
2 parents e3f1aa7 + c346c2f commit fde7be5

4 files changed

Lines changed: 301 additions & 1 deletion

File tree

‎.github/workflows/unit_test.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616
strategy:
1717
matrix:
1818
os: [ ubuntu-latest ]
19-
python-version: [ '3.9', '3.12', '3.13' ]
19+
python-version: [ '3.9', '3.12', '3.13', '3.14' ]
2020

2121

2222
steps:

‎.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,6 @@ cython_debug/
138138
.pypirc
139139

140140
/.acli/
141+
142+
# Local PyPI investigation tooling; the CI guard lives in tests/unit/.
143+
/scripts/

‎setup.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ def readme():
6161
"Programming Language :: Python :: 3.11",
6262
"Programming Language :: Python :: 3.12",
6363
"Programming Language :: Python :: 3.13",
64+
"Programming Language :: Python :: 3.14",
6465
],
6566
platforms="Posix; MacOS X; Windows",
6667
python_requires=">=3.9",
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
# -*- coding: utf-8 -*-
2+
"""Declared Python support must match python_requires, CI and real usage.
3+
4+
Until 1.27.5 this package declared classifiers only through 3.11 while 3.12
5+
accounted for ~80% of downloads. A missing classifier never fails an install,
6+
so nothing downstream surfaced the gap for six months. Each check below pins
7+
one seam that can drift silently.
8+
9+
The helpers live here rather than in a standalone script so the guard runs as
10+
part of the ordinary unit-test job, with no separate CI entry to keep in sync.
11+
"""
12+
13+
import ast
14+
import json
15+
import re
16+
import sys
17+
import urllib.error
18+
import urllib.request
19+
from pathlib import Path
20+
from typing import Any, Dict, List, Optional, Tuple
21+
22+
import pytest
23+
24+
ROOT = Path(__file__).resolve().parents[2]
25+
SETUP_PY = ROOT / "setup.py"
26+
WORKFLOW = ROOT / ".github" / "workflows" / "unit_test.yml"
27+
PYPISTATS_URL = (
28+
"https://pypistats.org/api/packages/{pkg}/python_minor?period=day"
29+
)
30+
USER_AGENT = "dashscope-sdk-python/packaging-check"
31+
PREFIX = "Programming Language :: Python :: "
32+
VERSION_RE = re.compile(r"^(\d+)\.(\d+)$")
33+
RECENT_DAYS = 30
34+
THRESHOLD = 1.0
35+
36+
CURRENT = [(3, 9), (3, 10), (3, 11), (3, 12), (3, 13), (3, 14)]
37+
38+
# Asking pypistats from all four matrix jobs at once invites a 429 that would
39+
# skip the very check it is meant to run, so only one interpreter asks.
40+
LIVE_CHECK_PYTHON = (3, 12)
41+
42+
43+
def version_key(value: str) -> Tuple[int, int]:
44+
"""Sort "3.9" before "3.10", which float() would get backwards."""
45+
parts = value.split(".")
46+
return int(parts[0]), int(parts[1] if len(parts) > 1 else 0)
47+
48+
49+
def setup_kwargs() -> Dict[str, Any]:
50+
"""Read literal setup() keyword args without importing setuptools."""
51+
tree = ast.parse(SETUP_PY.read_text(encoding="utf-8"))
52+
literal = (ast.List, ast.Tuple, ast.Dict, ast.Constant)
53+
for node in ast.walk(tree):
54+
func = node.func if isinstance(node, ast.Call) else None
55+
if not isinstance(func, ast.Attribute) or func.attr != "setup":
56+
continue
57+
return {
58+
kw.arg: ast.literal_eval(kw.value)
59+
for kw in node.keywords
60+
if kw.arg is not None and isinstance(kw.value, literal)
61+
}
62+
raise AssertionError("no setup() call found in setup.py")
63+
64+
65+
def declared_versions(classifiers: List[str]) -> List[Tuple[int, int]]:
66+
found = []
67+
for item in classifiers:
68+
if not isinstance(item, str) or not item.startswith(PREFIX):
69+
continue
70+
match = VERSION_RE.match(item[len(PREFIX) :].strip())
71+
if match:
72+
found.append((int(match.group(1)), int(match.group(2))))
73+
return sorted(set(found))
74+
75+
76+
def requires_floor(spec: str) -> Optional[Tuple[int, int]]:
77+
match = re.search(r"(\d+)\.(\d+)", spec or "")
78+
if not match:
79+
return None
80+
return int(match.group(1)), int(match.group(2))
81+
82+
83+
def ci_versions() -> Optional[List[str]]:
84+
"""Collect python-version entries from every CI job matrix."""
85+
try:
86+
import yaml # pylint: disable=import-outside-toplevel
87+
except ImportError:
88+
return None
89+
data = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) or {}
90+
found: List[str] = []
91+
for job in (data.get("jobs") or {}).values():
92+
matrix = ((job or {}).get("strategy") or {}).get("matrix") or {}
93+
for key, values in matrix.items():
94+
if "python" in str(key) and isinstance(values, list):
95+
found.extend(str(value) for value in values)
96+
return found
97+
98+
99+
def download_shares(package: str) -> Optional[Dict[str, float]]:
100+
"""Each Python minor's share of downloads over RECENT_DAYS.
101+
102+
Returns None rather than raising when pypistats is unreachable or answers
103+
with something unexpected, because a rate limit is not a packaging defect
104+
and must not be able to turn CI red.
105+
"""
106+
request = urllib.request.Request(
107+
PYPISTATS_URL.format(pkg=package),
108+
headers={"User-Agent": USER_AGENT},
109+
)
110+
try:
111+
with urllib.request.urlopen(request, timeout=30) as response:
112+
rows = json.load(response)["data"]
113+
except (
114+
urllib.error.URLError,
115+
TimeoutError,
116+
OSError,
117+
KeyError,
118+
ValueError,
119+
):
120+
return None
121+
122+
recent = set(sorted({row["date"] for row in rows})[-RECENT_DAYS:])
123+
counts: Dict[str, int] = {}
124+
total = 0
125+
for row in rows:
126+
if row["date"] not in recent:
127+
continue
128+
total += row["downloads"]
129+
category = row["category"]
130+
counts[category] = counts.get(category, 0) + row["downloads"]
131+
if not total:
132+
return None
133+
return {name: 100.0 * hits / total for name, hits in counts.items()}
134+
135+
136+
def ranked_shares(shares: Dict[str, float]) -> List[Tuple[str, float]]:
137+
"""Download share per Python minor, highest first."""
138+
return sorted(
139+
(
140+
(name, share)
141+
for name, share in shares.items()
142+
if VERSION_RE.match(name)
143+
),
144+
key=lambda pair: -pair[1],
145+
)
146+
147+
148+
def audit(
149+
declared: List[Tuple[int, int]],
150+
floor: Optional[Tuple[int, int]],
151+
tested: Optional[List[str]],
152+
shares: Optional[Dict[str, float]],
153+
threshold: float,
154+
) -> List[str]:
155+
"""Return one message per way the support metadata has drifted."""
156+
if not declared:
157+
return [f"no '{PREFIX}X.Y' classifiers declared"]
158+
159+
named = {f"{major}.{minor}" for major, minor in declared}
160+
violations: List[str] = []
161+
162+
if floor and floor != declared[0]:
163+
violations.append(
164+
f"python_requires floor {floor[0]}.{floor[1]} != lowest "
165+
f"classifier {declared[0][0]}.{declared[0][1]}",
166+
)
167+
168+
for previous, current in zip(declared, declared[1:]):
169+
if current[0] != previous[0] or current[1] != previous[1] + 1:
170+
violations.append(
171+
f"classifier gap between {previous[0]}.{previous[1]} "
172+
f"and {current[0]}.{current[1]}",
173+
)
174+
175+
for version in sorted(set(tested or []), key=version_key):
176+
if version not in named:
177+
violations.append(
178+
f"CI tests Python {version} but no classifier declares it",
179+
)
180+
181+
for name, share in ranked_shares(shares or {}):
182+
if share >= threshold and name not in named:
183+
violations.append(
184+
f"Python {name} is {share:.2f}% of downloads "
185+
f"(>= {threshold:g}%) but has no classifier",
186+
)
187+
return violations
188+
189+
190+
def repo_audit(shares: Optional[Dict[str, float]] = None) -> List[str]:
191+
"""Audit this repository's own setup.py against its own CI matrix."""
192+
kwargs = setup_kwargs()
193+
return audit(
194+
declared=declared_versions(list(kwargs.get("classifiers") or [])),
195+
floor=requires_floor(str(kwargs.get("python_requires", ""))),
196+
tested=ci_versions(),
197+
shares=shares,
198+
threshold=THRESHOLD,
199+
)
200+
201+
202+
def test_version_key_sorts_minors_numerically():
203+
# float("3.10") == 3.1 < float("3.9"), so sorting by float inverts these
204+
ordered = sorted(["3.10", "3.9", "3.14", "3.2"], key=version_key)
205+
assert ordered == ["3.2", "3.9", "3.10", "3.14"]
206+
207+
208+
def test_declared_versions_ignores_other_classifiers():
209+
found = declared_versions(
210+
[
211+
"Development Status :: 4 - Beta",
212+
"Programming Language :: Python",
213+
"Programming Language :: Python :: 3",
214+
"Programming Language :: Python :: 3.12",
215+
"Programming Language :: Python :: 3.9",
216+
"Programming Language :: Python :: 3.9",
217+
],
218+
)
219+
assert found == [(3, 9), (3, 12)]
220+
221+
222+
def test_requires_floor_handles_micro_versions():
223+
assert requires_floor(">=3.9") == (3, 9)
224+
assert requires_floor(">=3.8.0") == (3, 8)
225+
assert requires_floor("") is None
226+
227+
228+
def test_audit_flags_undeclared_version_carrying_real_traffic():
229+
# dashscope <=1.27.4 declared 3.8-3.11 while 3.12 drove most downloads
230+
violations = audit(
231+
declared=[(3, 8), (3, 9), (3, 10), (3, 11)],
232+
floor=(3, 8),
233+
tested=["3.9", "3.11"],
234+
shares={"3.11": 24.0, "3.12": 62.0, "null": 0.4},
235+
threshold=1.0,
236+
)
237+
assert any("3.12 is 62.00%" in item for item in violations)
238+
239+
240+
def test_audit_flags_gap_between_declared_versions():
241+
violations = audit(
242+
declared=[(3, 9), (3, 11)],
243+
floor=(3, 9),
244+
tested=None,
245+
shares=None,
246+
threshold=1.0,
247+
)
248+
assert any("gap" in item for item in violations)
249+
250+
251+
def test_audit_flags_ci_testing_an_undeclared_version():
252+
violations = audit(
253+
declared=[(3, 9), (3, 10)],
254+
floor=(3, 9),
255+
tested=["3.9", "3.12"],
256+
shares=None,
257+
threshold=1.0,
258+
)
259+
assert any("CI tests Python 3.12" in item for item in violations)
260+
261+
262+
def test_audit_flags_floor_above_lowest_classifier():
263+
violations = audit(
264+
declared=[(3, 8), (3, 9)],
265+
floor=(3, 9),
266+
tested=None,
267+
shares=None,
268+
threshold=1.0,
269+
)
270+
assert any("python_requires floor" in item for item in violations)
271+
272+
273+
def test_audit_accepts_consistent_metadata():
274+
violations = audit(
275+
declared=CURRENT,
276+
floor=(3, 9),
277+
tested=["3.9", "3.12", "3.13", "3.14"],
278+
shares={"3.12": 61.0, "3.15": 0.4, "3.8": 0.09},
279+
threshold=1.0,
280+
)
281+
assert not violations
282+
283+
284+
def test_repo_metadata_passes_offline_checks():
285+
assert not repo_audit()
286+
287+
288+
@pytest.mark.skipif(
289+
sys.version_info[:2] != LIVE_CHECK_PYTHON,
290+
reason="only one matrix job queries pypistats",
291+
)
292+
def test_repo_classifiers_cover_every_python_carrying_real_traffic():
293+
shares = download_shares("dashscope")
294+
if shares is None:
295+
pytest.skip("pypistats unreachable")
296+
assert not repo_audit(shares)

0 commit comments

Comments
 (0)