Skip to content

Commit 7d70c65

Browse files
edvilmeCopilot
andauthored
Use PythonVersion class to parse and compare Python versions (#1748)
This pull request introduces a new `PythonVersion` class to provide robust, normalized parsing and comparison of Python version strings. The codebase is updated to use this new class in the `getLatest` utility, replacing the previous PEP 440-based implementation. Comprehensive unit tests are also added for both the version normalization logic and the updated selection of the latest Python environment. **Python version normalization and comparison:** * Added the `PythonVersion` class in `src/common/pythonVersion.ts`, which normalizes Python version strings, supports comparison, and handles various version formats and prerelease suffixes. **Integration with environment selection:** * Updated `getLatest` in `src/managers/common/utils.ts` to use `PythonVersion` for selecting the latest Python environment, improving reliability and correctness in version comparisons. **Testing improvements:** * Added unit tests for `PythonVersion` in `src/test/common/pythonVersion.unit.test.ts`, covering normalization, comparison, error handling, and parsing of various version formats. * Added unit tests for `getLatest` in `src/test/managers/common/utils.getLatest.unit.test.ts` to verify correct selection among multiple Python environments, including handling of invalid and errored entries. **Dependency updates:** * Updated imports in `src/managers/common/utils.ts` to include the new `PythonVersion` class. Fixes #1743 --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 26c78f6 commit 7d70c65

7 files changed

Lines changed: 504 additions & 99 deletions

File tree

‎src/common/inlineScript/metadata.ts‎

Lines changed: 8 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as tomljs from '@iarna/toml';
55
import * as fs from 'fs/promises';
66
import { Uri } from 'vscode';
77
import { traceVerbose, traceWarn } from '../logging';
8-
import { compareReleaseSegments, parseReleaseSegments } from '../utils/pep440Release';
8+
import { PythonVersion } from '../pythonVersion';
99

1010
/**
1111
* Parsed and validated PEP 723 `script` metadata block.
@@ -304,101 +304,15 @@ export function matchesPythonVersion(requiresPython: string, version: string): b
304304
if (!requiresPython || !version) {
305305
return false;
306306
}
307-
const clauses = requiresPython
308-
.split(',')
309-
.map((c) => c.trim())
310-
.filter((c) => c.length > 0);
311-
if (clauses.length === 0) {
307+
const parsedVersion = PythonVersion.tryParse(version);
308+
if (!parsedVersion) {
309+
traceWarn(`inline script metadata: cannot parse Python version: ${JSON.stringify(version)}`);
312310
return false;
313311
}
314-
for (const clause of clauses) {
315-
if (!matchSingleClause(clause, version)) {
316-
return false;
317-
}
318-
}
319-
return true;
320-
}
321-
322-
// Longest-match-first order matters: `===` must beat `==`, `~=` and
323-
// `>=` / `<=` / `!=` must beat the single-char operators.
324-
const SPECIFIER_RE = /^(===|~=|==|!=|>=|<=|>|<)\s*(.+)$/;
325-
326-
function matchSingleClause(clause: string, version: string): boolean {
327-
const m = clause.match(SPECIFIER_RE);
328-
if (!m) {
329-
traceWarn(`inline script metadata: unrecognized requires-python clause: ${JSON.stringify(clause)}`);
330-
return false;
331-
}
332-
const op = m[1];
333-
const specVersion = m[2].trim();
334-
335-
if (op === '===') {
336-
// Arbitrary-equality: exact string comparison after stripping
337-
// a leading 'v' (which PEP 440 permits).
338-
const normSpec = specVersion.replace(/^v/i, '');
339-
const normVer = version.replace(/^v/i, '');
340-
return normSpec === normVer;
341-
}
342-
343-
if (specVersion.endsWith('.*')) {
344-
if (op !== '==' && op !== '!=') {
345-
traceWarn(
346-
`inline script metadata: wildcard versions are only valid with '==' or '!=': ${JSON.stringify(clause)}`,
347-
);
348-
return false;
349-
}
350-
const prefix = parseReleaseSegments(specVersion.slice(0, -2));
351-
const ver = parseReleaseSegments(version);
352-
if (prefix === undefined || ver === undefined) {
353-
traceWarn(`inline script metadata: cannot parse version for clause ${JSON.stringify(clause)}`);
354-
return false;
355-
}
356-
const isPrefixMatch = ver.length >= prefix.length && prefix.every((seg, i) => ver[i] === seg);
357-
return op === '==' ? isPrefixMatch : !isPrefixMatch;
358-
}
359-
360-
const specSegs = parseReleaseSegments(specVersion);
361-
const verSegs = parseReleaseSegments(version);
362-
if (specSegs === undefined || verSegs === undefined) {
363-
traceWarn(`inline script metadata: cannot parse version for clause ${JSON.stringify(clause)}`);
312+
const result = parsedVersion.satisfies(requiresPython);
313+
if (result === undefined) {
314+
traceWarn(`inline script metadata: invalid requires-python specifier: ${JSON.stringify(requiresPython)}`);
364315
return false;
365316
}
366-
367-
const cmp = compareReleaseSegments(verSegs, specSegs);
368-
switch (op) {
369-
case '==':
370-
return cmp === 0;
371-
case '!=':
372-
return cmp !== 0;
373-
case '>=':
374-
return cmp >= 0;
375-
case '<=':
376-
return cmp <= 0;
377-
case '>':
378-
return cmp > 0;
379-
case '<':
380-
return cmp < 0;
381-
case '~=': {
382-
// Compatible release. `~=X.Y` is equivalent to
383-
// `>= X.Y, == X.*`; `~=X.Y.Z` is `>= X.Y.Z, == X.Y.*`.
384-
// PEP 440 requires at least two release segments here.
385-
if (specSegs.length < 2) {
386-
traceWarn(
387-
`inline script metadata: '~=' requires at least two release segments: ${JSON.stringify(clause)}`,
388-
);
389-
return false;
390-
}
391-
if (cmp < 0) {
392-
return false;
393-
}
394-
const prefix = specSegs.slice(0, -1);
395-
if (verSegs.length < prefix.length) {
396-
return false;
397-
}
398-
return prefix.every((seg, i) => verSegs[i] === seg);
399-
}
400-
default:
401-
// Unreachable — SPECIFIER_RE only matches the operators above.
402-
return false;
403-
}
317+
return result;
404318
}

‎src/common/pythonVersion.ts‎

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
type PythonReleaseLevel = 'alpha' | 'beta' | 'candidate' | 'final';
2+
3+
export class PythonVersion {
4+
private static readonly VERSION_PATTERN =
5+
/^(?<major>\d+)(?:\.(?<minor>\d+))?(?:\.(?<patch>\d+))?(?:(?:\.(?<longLevel>alpha|beta|candidate|final)\.(?<longSerial>\d+))|(?:(?<shortLevel>a|b|rc)(?<shortSerial>\d+)))?$/i;
6+
7+
private static readonly WILDCARD_PATTERN = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?\.\*$/;
8+
9+
private static readonly SPECIFIER_PATTERN = /^(===|~=|==|!=|>=|<=|>|<)\s*(.+)$/;
10+
11+
private static readonly RELEASE_LEVEL_ALIASES: Readonly<Record<string, PythonReleaseLevel>> = {
12+
a: 'alpha',
13+
alpha: 'alpha',
14+
b: 'beta',
15+
beta: 'beta',
16+
rc: 'candidate',
17+
candidate: 'candidate',
18+
final: 'final',
19+
};
20+
21+
private static readonly RELEASE_LEVEL_ORDER: Readonly<Record<PythonReleaseLevel, number>> = {
22+
alpha: 0,
23+
beta: 1,
24+
candidate: 2,
25+
final: 3,
26+
};
27+
28+
/**
29+
* Creates a normalized Python release version.
30+
*
31+
* Missing minor and patch components are normalized to zero. Python
32+
* `sys.version_info` suffixes and compact prerelease suffixes are
33+
* normalized, so `3.14.0.beta.1` and `3.14.0b1` are both represented as
34+
* `3.14.0b1`.
35+
*
36+
* @param version A Python release version.
37+
*/
38+
constructor(version: string) {
39+
const normalizedVersion = version.trim();
40+
const match = PythonVersion.VERSION_PATTERN.exec(normalizedVersion);
41+
if (!match) {
42+
throw new TypeError(`Invalid Python version: ${version}`);
43+
}
44+
45+
const groups = match.groups!;
46+
this.original = normalizedVersion;
47+
this.releaseComponentCount = groups.patch !== undefined ? 3 : groups.minor !== undefined ? 2 : 1;
48+
this.major = parseNumericComponent(groups.major, version);
49+
this.minor = parseNumericComponent(groups.minor, version);
50+
this.patch = parseNumericComponent(groups.patch, version);
51+
this.releaseLevel = PythonVersion.normalizeReleaseLevel(groups.longLevel ?? groups.shortLevel);
52+
this.releaseSerial = parseNumericComponent(groups.longSerial ?? groups.shortSerial, version);
53+
if (this.releaseLevel === 'final' && this.releaseSerial !== 0) {
54+
throw new TypeError(`Invalid Python version: ${version}`);
55+
}
56+
}
57+
58+
readonly major: number;
59+
readonly minor: number;
60+
readonly patch: number;
61+
readonly releaseLevel: PythonReleaseLevel;
62+
readonly releaseSerial: number;
63+
private readonly original: string;
64+
private readonly releaseComponentCount: number;
65+
66+
/**
67+
* Attempts to parse a Python version without propagating malformed input errors.
68+
*
69+
* @param version The value to parse.
70+
* @returns A normalized version, or `undefined` when the value is unsupported.
71+
*/
72+
static tryParse(version: unknown): PythonVersion | undefined {
73+
if (typeof version !== 'string') {
74+
return undefined;
75+
}
76+
77+
try {
78+
return new PythonVersion(version);
79+
} catch {
80+
return undefined;
81+
}
82+
}
83+
84+
/**
85+
* Compares this version with another normalized Python version.
86+
*
87+
* @param other The version to compare against.
88+
* @returns A negative number when this version is older, zero when both
89+
* versions are equal, and a positive number when this version is newer.
90+
*/
91+
compareTo(other: PythonVersion): number {
92+
return (
93+
this.compareReleaseTo(other) ||
94+
compareNumbers(
95+
PythonVersion.RELEASE_LEVEL_ORDER[this.releaseLevel],
96+
PythonVersion.RELEASE_LEVEL_ORDER[other.releaseLevel],
97+
) ||
98+
compareNumbers(this.releaseSerial, other.releaseSerial)
99+
);
100+
}
101+
102+
/**
103+
* Tests whether this version satisfies a Python version specifier.
104+
*
105+
* Supports `==`, `!=`, `>=`, `<=`, `>`, `<`, `~=`, and `===` operators,
106+
* comma-separated AND clauses, and terminal wildcards with `==` or `!=`.
107+
* Prerelease suffixes are ignored for ordered and release-equality
108+
* comparisons, matching the inline-script interpreter behavior.
109+
*
110+
* @param specifier A version specifier such as `>=3.11,<3.14` or `==3.12.*`.
111+
* @returns Whether every clause matches, or `undefined` when the specifier is invalid.
112+
*/
113+
satisfies(specifier: unknown): boolean | undefined {
114+
if (typeof specifier !== 'string') {
115+
return undefined;
116+
}
117+
118+
const clauses = specifier.split(',').map((clause) => clause.trim());
119+
if (clauses.some((clause) => clause.length === 0)) {
120+
return undefined;
121+
}
122+
123+
let satisfiesAll = true;
124+
for (const clause of clauses) {
125+
const result = this.matchClause(clause);
126+
if (result === undefined) {
127+
return undefined;
128+
}
129+
satisfiesAll &&= result;
130+
}
131+
return satisfiesAll;
132+
}
133+
134+
/** Returns the normalized Python version representation. */
135+
toString(): string {
136+
const release = `${this.major}.${this.minor}.${this.patch}`;
137+
switch (this.releaseLevel) {
138+
case 'alpha':
139+
return `${release}a${this.releaseSerial}`;
140+
case 'beta':
141+
return `${release}b${this.releaseSerial}`;
142+
case 'candidate':
143+
return `${release}rc${this.releaseSerial}`;
144+
case 'final':
145+
return release;
146+
}
147+
}
148+
149+
private static normalizeReleaseLevel(value: string | undefined): PythonReleaseLevel {
150+
return value ? (PythonVersion.RELEASE_LEVEL_ALIASES[value.toLowerCase()] ?? 'final') : 'final';
151+
}
152+
153+
private matchClause(clause: string): boolean | undefined {
154+
const match = PythonVersion.SPECIFIER_PATTERN.exec(clause);
155+
if (!match) {
156+
return undefined;
157+
}
158+
159+
const operator = match[1];
160+
const expected = match[2].trim();
161+
if (operator === '===') {
162+
return expected ? this.original.replace(/^v/i, '') === expected.replace(/^v/i, '') : undefined;
163+
}
164+
if (expected.endsWith('.*')) {
165+
const wildcard = PythonVersion.parseWildcard(expected);
166+
if ((operator !== '==' && operator !== '!=') || !wildcard) {
167+
return undefined;
168+
}
169+
const matches = this.matchesReleaseComponents(wildcard);
170+
return operator === '==' ? matches : !matches;
171+
}
172+
173+
const expectedVersion = PythonVersion.tryParse(expected);
174+
if (!expectedVersion || (operator === '~=' && expectedVersion.releaseComponentCount < 2)) {
175+
return undefined;
176+
}
177+
178+
const comparison = this.compareReleaseTo(expectedVersion);
179+
switch (operator) {
180+
case '==':
181+
return comparison === 0;
182+
case '!=':
183+
return comparison !== 0;
184+
case '>=':
185+
return comparison >= 0;
186+
case '<=':
187+
return comparison <= 0;
188+
case '>':
189+
return comparison > 0;
190+
case '<':
191+
return comparison < 0;
192+
case '~=':
193+
return (
194+
comparison >= 0 &&
195+
this.matchesReleaseComponents(
196+
expectedVersion.releasePrefix(expectedVersion.releaseComponentCount - 1),
197+
)
198+
);
199+
default:
200+
return false;
201+
}
202+
}
203+
204+
private compareReleaseTo(other: PythonVersion): number {
205+
return (
206+
compareNumbers(this.major, other.major) ||
207+
compareNumbers(this.minor, other.minor) ||
208+
compareNumbers(this.patch, other.patch)
209+
);
210+
}
211+
212+
private releasePrefix(length: number): readonly number[] {
213+
return [this.major, this.minor, this.patch].slice(0, length);
214+
}
215+
216+
private matchesReleaseComponents(expected: readonly number[]): boolean {
217+
return (
218+
expected[0] === this.major &&
219+
(expected.length < 2 || expected[1] === this.minor) &&
220+
(expected.length < 3 || expected[2] === this.patch)
221+
);
222+
}
223+
224+
private static parseWildcard(wildcard: unknown): number[] | undefined {
225+
if (typeof wildcard !== 'string') {
226+
return undefined;
227+
}
228+
229+
const match = PythonVersion.WILDCARD_PATTERN.exec(wildcard.trim());
230+
if (!match) {
231+
return undefined;
232+
}
233+
234+
const components = match
235+
.slice(1)
236+
.filter((component): component is string => component !== undefined)
237+
.map(Number);
238+
return components.every(Number.isSafeInteger) ? components : undefined;
239+
}
240+
}
241+
242+
function parseNumericComponent(value: string | undefined, version: string): number {
243+
const parsed = Number(value ?? 0);
244+
if (!Number.isSafeInteger(parsed)) {
245+
throw new TypeError(`Invalid Python version: ${version}`);
246+
}
247+
return parsed;
248+
}
249+
250+
function compareNumbers(left: number, right: number): number {
251+
return left === right ? 0 : left < right ? -1 : 1;
252+
}

0 commit comments

Comments
 (0)