Skip to content

Commit 0afe955

Browse files
edvilmeCopilot
andcommitted
Address PR review on prerelease parsing and upper bounds
Accept every prerelease spelling consistently. The parser had separate branches for the dotted `sys.version_info` form and the compact form, so word aliases without dots such as `3.14.0alpha1` were rejected even though the previous PEP 440-based matching accepted them. One alternation now covers all spellings with optional `.`, `-`, or `_` separators, which also removes the duplicated level and serial capture groups. Encode the exclusive upper bound prerelease rule per clause. Prerelease admission was applied to the specifier as a whole, so `3.14.0rc1` satisfied `>=3.13.0rc1,<3.14` because another clause named a prerelease. An exclusive bound now rejects prereleases of its own release, while `<3.14.0rc2` still admits them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6444890d-5c9c-4e8e-82b3-37a157ead632
1 parent ab6ddfb commit 0afe955

4 files changed

Lines changed: 59 additions & 12 deletions

File tree

‎src/common/pythonVersion.ts‎

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ type PythonReleaseLevel = 'alpha' | 'beta' | 'candidate' | 'final';
44
const RELEASE_LEVELS: readonly PythonReleaseLevel[] = ['alpha', 'beta', 'candidate', 'final'];
55

66
/**
7-
* Maps the alternative spellings onto the release level they name.
7+
* Maps the abbreviated spellings onto the release level they name.
88
*
9-
* Python reports `alpha`, `beta`, and `candidate`, while version strings use
10-
* the compact `a`, `b`, and `rc`. A specifier may also spell a release
11-
* candidate `c`, `pre`, or `preview`.
9+
* Python reports `alpha`, `beta`, and `candidate`, while version strings
10+
* abbreviate them as `a`, `b`, and `rc`. A release candidate may also be
11+
* spelled `c`, `pre`, or `preview`.
1212
*/
1313
const RELEASE_LEVEL_ALIASES: Readonly<Record<string, PythonReleaseLevel>> = {
1414
a: 'alpha',
@@ -19,8 +19,16 @@ const RELEASE_LEVEL_ALIASES: Readonly<Record<string, PythonReleaseLevel>> = {
1919
preview: 'candidate',
2020
};
2121

22+
/**
23+
* Matches a release, optionally followed by a prerelease level and serial.
24+
*
25+
* Every spelling of a level is accepted in one alternation, so the dotted
26+
* `sys.version_info` form and the compact and separated forms differ only in
27+
* their optional `.`, `-`, or `_` separators. Longer spellings precede the
28+
* abbreviations they start with, so `alpha` wins over `a`.
29+
*/
2230
const VERSION_PATTERN =
23-
/^(?<major>\d+)(?:\.(?<minor>\d+))?(?:\.(?<patch>\d+))?(?:(?:\.(?<longLevel>alpha|beta|candidate|final)\.(?<longSerial>\d+))|(?:(?<shortLevel>preview|pre|rc|a|b|c)[._-]?(?<shortSerial>\d+)))?$/i;
31+
/^(?<major>\d+)(?:\.(?<minor>\d+))?(?:\.(?<patch>\d+))?(?:[._-]?(?<level>alpha|beta|candidate|final|preview|pre|rc|a|b|c)[._-]?(?<serial>\d+))?$/i;
2432

2533
/**
2634
* A Python interpreter release, such as `3.12.4` or `3.14.0rc1`.
@@ -35,9 +43,9 @@ export class PythonVersion {
3543
/**
3644
* Creates a normalized Python release version.
3745
*
38-
* Missing minor and patch components are normalized to zero. Python
39-
* `sys.version_info` suffixes and compact prerelease suffixes are
40-
* normalized, so `3.14.0.beta.1` and `3.14.0b1` are both represented as
46+
* Missing minor and patch components are normalized to zero. Every
47+
* spelling of a prerelease is normalized, so `3.14.0.beta.1`,
48+
* `3.14.0beta1`, `3.14.0-beta-1`, and `3.14.0b1` are all represented as
4149
* `3.14.0b1`.
4250
*
4351
* @param version A Python release version.
@@ -55,8 +63,8 @@ export class PythonVersion {
5563
this.major = Number(groups.major);
5664
this.minor = Number(groups.minor ?? 0);
5765
this.patch = Number(groups.patch ?? 0);
58-
this.releaseLevel = toReleaseLevel(groups.longLevel ?? groups.shortLevel);
59-
this.releaseSerial = Number(groups.longSerial ?? groups.shortSerial ?? 0);
66+
this.releaseLevel = toReleaseLevel(groups.level);
67+
this.releaseSerial = Number(groups.serial ?? 0);
6068
if (
6169
![this.major, this.minor, this.patch, this.releaseSerial].every(Number.isSafeInteger) ||
6270
(this.releaseLevel === 'final' && this.releaseSerial !== 0)

‎src/common/pythonVersionSpecifier.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { PythonVersion } from './pythonVersion';
88
* specifier as a whole.
99
*/
1010
interface VersionClause {
11-
/** Tests a version against this clause alone, ignoring prerelease exclusion. */
11+
/** Tests a version against this clause alone. */
1212
readonly matches: (version: PythonVersion) => boolean;
1313
/** Whether this clause explicitly names a prerelease. */
1414
readonly allowsPrereleases: boolean;
@@ -86,6 +86,8 @@ export class PythonVersionSpecifier {
8686
*
8787
* A prerelease only satisfies a specifier that itself names a prerelease,
8888
* so `3.14.0rc1` does not satisfy `>=3.11` but does satisfy `>=3.14.0rc1`.
89+
* An exclusive upper bound still rejects prereleases of its own release,
90+
* so `3.14.0rc1` does not satisfy `>=3.13.0rc1,<3.14`.
8991
*
9092
* @param version The version to test.
9193
*/
@@ -147,5 +149,18 @@ function parseClause(clause: string): VersionClause | undefined {
147149
}
148150

149151
const comparison = COMPARISONS[operator];
150-
return comparison ? { matches: (version) => comparison(version.compareTo(bound)), allowsPrereleases } : undefined;
152+
if (!comparison) {
153+
return undefined;
154+
}
155+
156+
// An exclusive upper bound never admits a prerelease of the bound itself,
157+
// so `<3.14` rejects `3.14.0rc1` even when another clause names a
158+
// prerelease, while `<3.14.0rc2` still admits it.
159+
const excludesBoundPrereleases = operator === '<' && !allowsPrereleases;
160+
return {
161+
matches: (version) =>
162+
comparison(version.compareTo(bound)) &&
163+
!(excludesBoundPrereleases && version.releaseLevel !== 'final' && version.matchesReleasePrefix(bound, 3)),
164+
allowsPrereleases,
165+
};
151166
}

‎src/test/common/pythonVersion.unit.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,22 @@ suite('PythonVersion', () => {
1919
assert.strictEqual(new PythonVersion('3.15.0rc1').toString(), '3.15.0rc1');
2020
});
2121

22+
test('normalizes every prerelease spelling and separator', () => {
23+
assert.strictEqual(new PythonVersion('3.14.0alpha1').toString(), '3.14.0a1');
24+
assert.strictEqual(new PythonVersion('3.14.0beta1').toString(), '3.14.0b1');
25+
assert.strictEqual(new PythonVersion('3.14.0candidate1').toString(), '3.14.0rc1');
26+
assert.strictEqual(new PythonVersion('3.14.0-alpha-1').toString(), '3.14.0a1');
27+
assert.strictEqual(new PythonVersion('3.14.0_alpha_1').toString(), '3.14.0a1');
28+
assert.strictEqual(new PythonVersion('3.14.0.alpha.1').toString(), '3.14.0a1');
29+
assert.strictEqual(new PythonVersion('3.14.0ALPHA1').toString(), '3.14.0a1');
30+
});
31+
32+
test('treats every release-candidate alias as the same level', () => {
33+
for (const alias of ['rc1', 'c1', 'pre1', 'preview1', 'candidate1']) {
34+
assert.strictEqual(new PythonVersion(`3.14.0${alias}`).toString(), '3.14.0rc1', alias);
35+
}
36+
});
37+
2238
test('compares each numeric component in order', () => {
2339
assert.ok(new PythonVersion('3.9').compareTo(new PythonVersion('3.10')) < 0);
2440
assert.ok(new PythonVersion('3.12.9').compareTo(new PythonVersion('3.12.10')) < 0);

‎src/test/common/pythonVersionSpecifier.unit.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ suite('PythonVersionSpecifier', () => {
6969
assert.strictEqual(matches('3.14.0b1', '==3.14.*'), false);
7070
});
7171

72+
test('excludes prereleases of an exclusive upper bound', () => {
73+
assert.strictEqual(matches('3.14.0rc1', '>=3.13.0rc1,<3.14'), false);
74+
assert.strictEqual(matches('3.13.5rc1', '>=3.13.0rc1,<3.14'), true);
75+
assert.strictEqual(matches('3.14.0rc1', '>=3.13.0rc1,<3.14.0rc2'), true);
76+
assert.strictEqual(matches('3.14.0rc1', '>=3.13.0rc1,<=3.14'), true);
77+
assert.strictEqual(matches('3.13.9', '>=3.13,<3.14'), true);
78+
});
79+
7280
test('accepts a leading v on the literal', () => {
7381
assert.strictEqual(matches('3.12.4', '>=v3.11'), true);
7482
assert.strictEqual(matches('3.12.4', '==v3.12.*'), true);

0 commit comments

Comments
 (0)