Skip to content

Commit c8009aa

Browse files
Add pypa/packaging conformance tests for PEP 440 specifier parsing (#1776)
## What Adds a data-driven conformance suite that validates `PythonVersionSpecifier` / `PythonVersion` against **pypa/packaging**, the reference PEP 440 implementation. `requires-python` in a PEP 723 inline-script block is a PEP 440 version specifier, and we evaluate it ourselves (in `matchesPythonVersion`) to choose a base interpreter and to decide which Python version to ask uv to install. This pins that behaviour to the reference implementation so edge cases can't drift. 118 cases ported from `tests/test_specifiers.py`: | Suite | Cases | Asserts | | --- | --- | --- | | `rejects specifiers packaging rejects` | 31 | `tryParse` returns `undefined` | | `matches packaging semantics` | 85 | `matches()` equals packaging's result | | `diverges from packaging by design` | 2 | our intentional behaviour | ## How expected values were produced They were **generated by calling packaging 24.1 directly**, not hand-transcribed. Two things to know when re-generating: 1. **Prerelease semantics.** Upstream's harness constructs `Specifier(spec, prereleases=True)`, which is *not* packaging's default. Expected values here use the **default**, which is the correct model for `requires-python`. Against the default we match packaging on all 85 cases. 2. **Scope.** Cases using epochs (`2!1.0`), local labels (`2.0+deadbeef`), post/dev releases, or more than three release segments are omitted. A Python interpreter version never has them, and `PythonVersion` deliberately does not model them (see its class doc). ## The two intentional divergences packaging's `Specifier.prereleases` auto-enables prereleases only for `==`, `>=`, `<=`, `~=` and `===` — **`<` and `>` are excluded**. So under packaging's default: - `>3.0.0a7` matches only *final* releases (`3.0.0`, `3.0.1`); it rejects `3.0.0a8`, `3.0.0b1`, `3.0.0rc1` - `<3.0.0a8` rejects every 3.x prerelease Our rule is uniform: any clause naming a prerelease enables prereleases. That is what the class docs describe and what `pythonVersionSpecifier.unit.test.ts` already asserts for `<3.14.0rc2`. These two cases are isolated in their own suite with the reason recorded, so they're an explicit decision rather than an accidental mismatch. ## Testing - 118/118 new tests pass - `npm run lint` clean on the new file - Full unit suite: **2220 passing, 0 failing** ## Licensing pypa/packaging is dual-licensed Apache-2.0 / BSD-2-Clause. The ported test data is used under **BSD-2-Clause**, with source, copyright and licence recorded in the file header. No production code is affected — this PR adds a test file only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent db0a3b2 commit c8009aa

1 file changed

Lines changed: 181 additions & 0 deletions

File tree

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
//
4+
// Test data in this file is derived from the pypa/packaging project.
5+
// Source: https://github.com/pypa/packaging/blob/main/tests/test_specifiers.py
6+
// Copyright: Donald Stufft and individual contributors
7+
// License: BSD-2-Clause (packaging is dual-licensed Apache-2.0 / BSD-2-Clause)
8+
//
9+
// Expected values follow packaging's DEFAULT prerelease semantics. Upstream forces
10+
// `prereleases=True`, which reports a different result for five wildcard and four
11+
// `<=` cases below; do not "correct" them back to the upstream values.
12+
//
13+
// Cases using epochs, local version labels, post/dev releases, or more than three
14+
// release segments are omitted: a Python interpreter version never has them, and
15+
// PythonVersion deliberately does not model them.
16+
17+
import assert from 'node:assert';
18+
import { PythonVersion } from '../../common/pythonVersion';
19+
import { PythonVersionSpecifier } from '../../common/pythonVersionSpecifier';
20+
21+
/** Mirrors `matchesPythonVersion`: malformed input yields `undefined`, never a throw. */
22+
function matches(version: string, specifier: string): boolean | undefined {
23+
const parsedVersion = PythonVersion.tryParse(version);
24+
const parsedSpecifier = PythonVersionSpecifier.tryParse(specifier);
25+
return parsedVersion && parsedSpecifier ? parsedSpecifier.matches(parsedVersion) : undefined;
26+
}
27+
28+
const INVALID_SPECIFIERS: readonly string[] = [
29+
'2.0',
30+
'=>2.0',
31+
'==',
32+
'~=1.0+5',
33+
'>=1.0+deadbeef',
34+
'<=1.0+abc123',
35+
'>1.0+watwat',
36+
'<1.0+1.0',
37+
'~=1.0.*',
38+
'>=1.0.*',
39+
'<=1.0.*',
40+
'>1.0.*',
41+
'<1.0.*',
42+
'==1.0.*+5',
43+
'!=1.0.*+deadbeef',
44+
'==2.0a1.*',
45+
'!=2.0a1.*',
46+
'==2.0.post1.*',
47+
'!=2.0.post1.*',
48+
'==2.0.dev1.*',
49+
'!=2.0.dev1.*',
50+
'==1.0+5.*',
51+
'!=1.0+deadbeef.*',
52+
'==1.0.*.5',
53+
'~=1',
54+
'==1.0.dev1.*',
55+
'!=1.0.dev1.*',
56+
'==1.2+\u0130',
57+
'==1.2+\u0130\u0131\u017fK',
58+
'~=1.2.3prev\u0131ew1',
59+
'~=1.2.3po\u017ft1',
60+
];
61+
62+
const MATCH_CASES: ReadonlyArray<readonly [specifier: string, version: string, expected: boolean]> = [
63+
['==2', '2.0', true],
64+
['==2.0', '2.0', true],
65+
['==2.0.0', '2.0', true],
66+
['==2.*', '2a1', false],
67+
['==2.*', '2b1', false],
68+
['==2.*', '2c1', false],
69+
['==2.*', '2rc1', false],
70+
['==2.0.*', '2rc1', false],
71+
['==2.*', '2', true],
72+
['==2.0.*', '2', true],
73+
['==2.0.0.*', '2', true],
74+
['==2.*', '2.0', true],
75+
['==2.*', '2.0.0', true],
76+
['!=2', '2.1', true],
77+
['!=2.0', '2.1', true],
78+
['!=2', '2.0.1', true],
79+
['!=2.0', '2.0.1', true],
80+
['!=2.0.0', '2.0.1', true],
81+
['!=3.*', '2.0', true],
82+
['!=2.0.*', '2.1', true],
83+
['!=2.0.0.*', '3', true],
84+
['>=2', '2.0', true],
85+
['>=2.0', '2.0', true],
86+
['>=2.0.0', '2.0', true],
87+
['>=2', '3', true],
88+
['>=3.0.0a7', '3.0.0a8', true],
89+
['<=2', '2.0', true],
90+
['<=2.0', '2.0', true],
91+
['<=2.0.0', '2.0', true],
92+
['<=2', '2.0a1', false],
93+
['<=2', '2.0b1', false],
94+
['<=2', '2.0c1', false],
95+
['<=2', '2.0rc1', false],
96+
['<=2', '1', true],
97+
['<=3.0.0a8', '3.0.0a7', true],
98+
['>2', '3', true],
99+
['>2.0', '2.1', true],
100+
['>2', '2.0.1', true],
101+
['<2', '1', true],
102+
['<2.1', '2.0', true],
103+
['~=1.0', '1', true],
104+
['~=1.0', '1.0.1', true],
105+
['~=1.0', '1.1', true],
106+
['~=1.0', '1.9999999', true],
107+
['~=1.0a1', '1.1', true],
108+
['~=2022.01.01', '2022.01.01', true],
109+
['==2', '2.1', false],
110+
['==2.0', '2.1', false],
111+
['==2.0.0', '2.1', false],
112+
['==3.*', '2.0', false],
113+
['==2.0.*', '2.1', false],
114+
['==2.0.0.*', '3', false],
115+
['!=2', '2.0', false],
116+
['!=2.0', '2.0', false],
117+
['!=2.0.0', '2.0', false],
118+
['!=2.*', '2a1', false],
119+
['!=2.*', '2b1', false],
120+
['!=2.*', '2c1', false],
121+
['!=2.*', '2rc1', false],
122+
['!=2.0.*', '2rc1', false],
123+
['!=2.*', '2', false],
124+
['!=2.0.*', '2', false],
125+
['!=2.0.0.*', '2', false],
126+
['!=2.*', '2.0', false],
127+
['!=2.*', '2.0.0', false],
128+
['>=2', '2.0a1', false],
129+
['>=2', '2.0b1', false],
130+
['>=2', '2.0c1', false],
131+
['>=2', '2.0rc1', false],
132+
['>=2', '1', false],
133+
['<=2', '3', false],
134+
['>2', '1', false],
135+
['>2', '2.0a1', false],
136+
['>2', '2.0b1', false],
137+
['>2', '2.0c1', false],
138+
['>2', '2.0rc1', false],
139+
['>2', '2.0', false],
140+
['<2', '2.0a1', false],
141+
['<2', '2.0b1', false],
142+
['<2', '2.0c1', false],
143+
['<2', '2.0rc1', false],
144+
['<2', '2.0', false],
145+
['<2', '3', false],
146+
['~=1.0', '2.0', false],
147+
['~=1.0.0', '1.1.0', false],
148+
];
149+
150+
// packaging enables prereleases only for ==, >=, <=, ~= and ===; we enable them for any
151+
// clause naming one, so these two cases intentionally differ from packaging's default.
152+
const INTENTIONAL_DIVERGENCES: ReadonlyArray<readonly [specifier: string, version: string, expected: boolean]> = [
153+
['>3.0.0a7', '3.0.0a8', true],
154+
['<3.0.0a8', '3.0.0a7', true],
155+
];
156+
157+
suite('PythonVersionSpecifier packaging conformance', () => {
158+
suite('rejects specifiers packaging rejects', () => {
159+
for (const specifier of INVALID_SPECIFIERS) {
160+
test(`rejects ${JSON.stringify(specifier)}`, () => {
161+
assert.strictEqual(PythonVersionSpecifier.tryParse(specifier), undefined);
162+
});
163+
}
164+
});
165+
166+
suite('matches packaging semantics', () => {
167+
for (const [specifier, version, expected] of MATCH_CASES) {
168+
test(`${specifier} ${expected ? 'matches' : 'does not match'} ${version}`, () => {
169+
assert.strictEqual(matches(version, specifier), expected);
170+
});
171+
}
172+
});
173+
174+
suite('diverges from packaging by design', () => {
175+
for (const [specifier, version, expected] of INTENTIONAL_DIVERGENCES) {
176+
test(`${specifier} ${expected ? 'matches' : 'does not match'} ${version}`, () => {
177+
assert.strictEqual(matches(version, specifier), expected);
178+
});
179+
}
180+
});
181+
});

0 commit comments

Comments
 (0)