Skip to content

Commit e8b2e04

Browse files
committed
Add satisfies command
1 parent 88ecf1d commit e8b2e04

4 files changed

Lines changed: 88 additions & 126 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.matchSpecifier(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: 59 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
type PythonReleaseLevel = 'alpha' | 'beta' | 'candidate' | 'final';
2+
type ComparisonOperator = '~=' | '==' | '!=' | '>=' | '<=' | '>' | '<';
3+
type ParsedClause =
4+
| { readonly operator: '==='; readonly expected: string }
5+
| { readonly operator: '==' | '!='; readonly wildcard: readonly number[] }
6+
| { readonly operator: ComparisonOperator; readonly expected: PythonVersion };
27

38
export class PythonVersion {
49
private static readonly VERSION_PATTERN =
@@ -43,12 +48,15 @@ export class PythonVersion {
4348
}
4449

4550
this.original = normalizedVersion;
46-
this.releaseComponentCount = match[3] !== undefined ? 3 : match[2] !== undefined ? 2 : 1;
51+
this.releaseComponentCount = match[3] !== undefined ? 3 : match[2] !== undefined ? 2 : 1;
4752
this.major = parseNumericComponent(match[1], version);
4853
this.minor = parseNumericComponent(match[2], version);
4954
this.patch = parseNumericComponent(match[3], version);
5055
this.releaseLevel = PythonVersion.normalizeReleaseLevel(match[4] ?? match[6]);
5156
this.releaseSerial = parseNumericComponent(match[5] ?? match[7], version);
57+
if (this.releaseLevel === 'final' && this.releaseSerial !== 0) {
58+
throw new TypeError(`Invalid Python version: ${version}`);
59+
}
5260
}
5361

5462
readonly major: number;
@@ -86,9 +94,7 @@ export class PythonVersion {
8694
*/
8795
compareTo(other: PythonVersion): number {
8896
return (
89-
compareNumbers(this.major, other.major) ||
90-
compareNumbers(this.minor, other.minor) ||
91-
compareNumbers(this.patch, other.patch) ||
97+
this.compareReleaseTo(other) ||
9298
compareNumbers(
9399
PythonVersion.RELEASE_LEVEL_ORDER[this.releaseLevel],
94100
PythonVersion.RELEASE_LEVEL_ORDER[other.releaseLevel],
@@ -120,15 +126,18 @@ export class PythonVersion {
120126
* @returns `true` when every clause matches; otherwise `false`.
121127
*/
122128
satisfies(specifier: unknown): boolean {
123-
if (typeof specifier !== 'string') {
124-
return false;
125-
}
129+
return this.matchSpecifier(specifier) ?? false;
130+
}
126131

127-
const clauses = specifier
128-
.split(',')
129-
.map((clause) => clause.trim())
130-
.filter((clause) => clause.length > 0);
131-
return clauses.length > 0 && clauses.every((clause) => this.satisfiesClause(clause));
132+
/**
133+
* Evaluates a Python version specifier while distinguishing invalid syntax.
134+
*
135+
* @param specifier The specifier to evaluate.
136+
* @returns Whether this version matches, or `undefined` for an invalid specifier.
137+
*/
138+
matchSpecifier(specifier: unknown): boolean | undefined {
139+
const clauses = PythonVersion.parseSpecifier(specifier);
140+
return clauses?.every((clause) => this.satisfiesClause(clause));
132141
}
133142

134143
/** Returns the normalized Python version representation. */
@@ -150,37 +159,57 @@ export class PythonVersion {
150159
return value ? (PythonVersion.RELEASE_LEVEL_ALIASES[value.toLowerCase()] ?? 'final') : 'final';
151160
}
152161

153-
private satisfiesClause(clause: string): boolean {
162+
private static parseSpecifier(specifier: unknown): readonly ParsedClause[] | undefined {
163+
if (typeof specifier !== 'string') {
164+
return undefined;
165+
}
166+
167+
const clauses = specifier
168+
.split(',')
169+
.map((clause) => clause.trim())
170+
.filter((clause) => clause.length > 0);
171+
if (clauses.length === 0) {
172+
return undefined;
173+
}
174+
175+
const parsed = clauses.map((clause) => PythonVersion.parseClause(clause));
176+
return parsed.every((clause): clause is ParsedClause => clause !== undefined) ? parsed : undefined;
177+
}
178+
179+
private static parseClause(clause: string): ParsedClause | undefined {
154180
const match = PythonVersion.SPECIFIER_PATTERN.exec(clause);
155181
if (!match) {
156-
return false;
182+
return undefined;
157183
}
158184

159185
const operator = match[1];
160186
const expected = match[2].trim();
161187
if (operator === '===') {
162-
return this.original.replace(/^v/i, '') === expected.replace(/^v/i, '');
188+
return expected.length > 0 ? { operator, expected } : undefined;
163189
}
164-
165190
if (expected.endsWith('.*')) {
166-
if (operator !== '==' && operator !== '!=') {
167-
return false;
168-
}
169-
const expectedComponents = PythonVersion.parseWildcard(expected);
170-
if (!expectedComponents) {
171-
return false;
172-
}
173-
const matches = this.matchesReleaseComponents(expectedComponents);
174-
return operator === '==' ? matches : !matches;
191+
const wildcard = PythonVersion.parseWildcard(expected);
192+
return (operator === '==' || operator === '!=') && wildcard ? { operator, wildcard } : undefined;
175193
}
176194

177195
const expectedVersion = PythonVersion.tryParse(expected);
178-
if (!expectedVersion) {
179-
return false;
196+
if (!expectedVersion || (operator === '~=' && expectedVersion.releaseComponentCount < 2)) {
197+
return undefined;
198+
}
199+
return { operator: operator as ComparisonOperator, expected: expectedVersion };
200+
}
201+
202+
private satisfiesClause(clause: ParsedClause): boolean {
203+
if (clause.operator === '===') {
204+
return this.original.replace(/^v/i, '') === clause.expected.replace(/^v/i, '');
205+
}
206+
if ('wildcard' in clause) {
207+
const matches = this.matchesReleaseComponents(clause.wildcard);
208+
return clause.operator === '==' ? matches : !matches;
180209
}
181210

182-
const comparison = this.compareReleaseTo(expectedVersion);
183-
switch (operator) {
211+
const comparison = this.compareReleaseTo(clause.expected);
212+
switch (clause.operator) {
184213
case '==':
185214
return comparison === 0;
186215
case '!=':
@@ -195,10 +224,9 @@ export class PythonVersion {
195224
return comparison < 0;
196225
case '~=':
197226
return (
198-
expectedVersion.releaseComponentCount >= 2 &&
199227
comparison >= 0 &&
200228
this.matchesReleaseComponents(
201-
expectedVersion.releaseComponents.slice(0, expectedVersion.releaseComponentCount - 1),
229+
clause.expected.releaseComponents.slice(0, clause.expected.releaseComponentCount - 1),
202230
)
203231
);
204232
default:

src/test/common/inlineScript/metadata.unit.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,12 @@ suite('inlineScriptMetadata', () => {
467467
assert.strictEqual(matchesPythonVersion('>=3.11', '3.10.0rc1'), false);
468468
});
469469

470+
test('supports normalized interpreter version formats', () => {
471+
assert.strictEqual(matchesPythonVersion('>=3.14', '3.14.3.final.0'), true);
472+
assert.strictEqual(matchesPythonVersion('==3.14.*', '3.14.0b1'), true);
473+
assert.strictEqual(matchesPythonVersion('<3.14', '3.14.0b1'), false);
474+
});
475+
470476
test('invalid specifier returns false and logs warn', () => {
471477
assert.strictEqual(matchesPythonVersion('weird-thing', '3.11'), false);
472478
assert.ok(traceWarnStub.called);

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,22 @@ suite('PythonVersion', () => {
9898
assert.strictEqual(version.satisfies(undefined), false);
9999
});
100100

101+
test('distinguishes invalid specifiers from valid non-matches', () => {
102+
const version = new PythonVersion('3.12.4');
103+
104+
assert.strictEqual(version.matchSpecifier('>=3.13'), false);
105+
assert.strictEqual(version.matchSpecifier('>=3.12.*'), undefined);
106+
});
107+
101108
test('rejects versions that cannot be compared safely', () => {
102-
for (const version of ['', '3.', '3.12.1.4', 'Python 3.12', `${Number.MAX_SAFE_INTEGER}0.1.0`]) {
109+
for (const version of [
110+
'',
111+
'3.',
112+
'3.12.1.4',
113+
'3.12.1.final.1',
114+
'Python 3.12',
115+
`${Number.MAX_SAFE_INTEGER}0.1.0`,
116+
]) {
103117
assert.throws(() => new PythonVersion(version), TypeError);
104118
}
105119
});

0 commit comments

Comments
 (0)