Skip to content

Commit e2ffa07

Browse files
committed
Simplified satisfies logic
1 parent e8b2e04 commit e2ffa07

3 files changed

Lines changed: 72 additions & 100 deletions

File tree

src/common/inlineScript/metadata.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ export function matchesPythonVersion(requiresPython: string, version: string): b
309309
traceWarn(`inline script metadata: cannot parse Python version: ${JSON.stringify(version)}`);
310310
return false;
311311
}
312-
const result = parsedVersion.matchSpecifier(requiresPython);
312+
const result = parsedVersion.satisfies(requiresPython);
313313
if (result === undefined) {
314314
traceWarn(`inline script metadata: invalid requires-python specifier: ${JSON.stringify(requiresPython)}`);
315315
return false;

src/common/pythonVersion.ts

Lines changed: 49 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
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 };
72

83
export class PythonVersion {
94
private static readonly VERSION_PATTERN =
10-
/^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:(?:\.(alpha|beta|candidate|final)\.(\d+))|(?:(a|b|rc)(\d+)))?$/i;
5+
/^(?<major>\d+)(?:\.(?<minor>\d+))?(?:\.(?<patch>\d+))?(?:(?:\.(?<longLevel>alpha|beta|candidate|final)\.(?<longSerial>\d+))|(?:(?<shortLevel>a|b|rc)(?<shortSerial>\d+)))?$/i;
116

127
private static readonly WILDCARD_PATTERN = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?\.\*$/;
138

@@ -47,13 +42,14 @@ export class PythonVersion {
4742
throw new TypeError(`Invalid Python version: ${version}`);
4843
}
4944

45+
const groups = match.groups!;
5046
this.original = normalizedVersion;
51-
this.releaseComponentCount = match[3] !== undefined ? 3 : match[2] !== undefined ? 2 : 1;
52-
this.major = parseNumericComponent(match[1], version);
53-
this.minor = parseNumericComponent(match[2], version);
54-
this.patch = parseNumericComponent(match[3], version);
55-
this.releaseLevel = PythonVersion.normalizeReleaseLevel(match[4] ?? match[6]);
56-
this.releaseSerial = parseNumericComponent(match[5] ?? match[7], version);
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);
5753
if (this.releaseLevel === 'final' && this.releaseSerial !== 0) {
5854
throw new TypeError(`Invalid Python version: ${version}`);
5955
}
@@ -103,17 +99,6 @@ export class PythonVersion {
10399
);
104100
}
105101

106-
/**
107-
* Tests whether this version matches a release-prefix wildcard.
108-
*
109-
* @param wildcard A terminal wildcard such as `3.*`, `3.14.*`, or `3.14.0.*`.
110-
* @returns `true` when all components before the wildcard match, otherwise `false`.
111-
*/
112-
satisfiesWildcard(wildcard: unknown): boolean {
113-
const expected = PythonVersion.parseWildcard(wildcard);
114-
return expected !== undefined && this.matchesReleaseComponents(expected);
115-
}
116-
117102
/**
118103
* Tests whether this version satisfies a Python version specifier.
119104
*
@@ -123,21 +108,27 @@ export class PythonVersion {
123108
* comparisons, matching the inline-script interpreter behavior.
124109
*
125110
* @param specifier A version specifier such as `>=3.11,<3.14` or `==3.12.*`.
126-
* @returns `true` when every clause matches; otherwise `false`.
111+
* @returns Whether every clause matches, or `undefined` when the specifier is invalid.
127112
*/
128-
satisfies(specifier: unknown): boolean {
129-
return this.matchSpecifier(specifier) ?? false;
130-
}
113+
satisfies(specifier: unknown): boolean | undefined {
114+
if (typeof specifier !== 'string') {
115+
return undefined;
116+
}
131117

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));
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;
141132
}
142133

143134
/** Returns the normalized Python version representation. */
@@ -159,24 +150,7 @@ export class PythonVersion {
159150
return value ? (PythonVersion.RELEASE_LEVEL_ALIASES[value.toLowerCase()] ?? 'final') : 'final';
160151
}
161152

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 {
153+
private matchClause(clause: string): boolean | undefined {
180154
const match = PythonVersion.SPECIFIER_PATTERN.exec(clause);
181155
if (!match) {
182156
return undefined;
@@ -185,31 +159,24 @@ export class PythonVersion {
185159
const operator = match[1];
186160
const expected = match[2].trim();
187161
if (operator === '===') {
188-
return expected.length > 0 ? { operator, expected } : undefined;
162+
return expected ? this.original.replace(/^v/i, '') === expected.replace(/^v/i, '') : undefined;
189163
}
190164
if (expected.endsWith('.*')) {
191165
const wildcard = PythonVersion.parseWildcard(expected);
192-
return (operator === '==' || operator === '!=') && wildcard ? { operator, wildcard } : undefined;
166+
if ((operator !== '==' && operator !== '!=') || !wildcard) {
167+
return undefined;
168+
}
169+
const matches = this.matchesReleaseComponents(wildcard);
170+
return operator === '==' ? matches : !matches;
193171
}
194172

195173
const expectedVersion = PythonVersion.tryParse(expected);
196174
if (!expectedVersion || (operator === '~=' && expectedVersion.releaseComponentCount < 2)) {
197175
return undefined;
198176
}
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;
209-
}
210177

211-
const comparison = this.compareReleaseTo(clause.expected);
212-
switch (clause.operator) {
178+
const comparison = this.compareReleaseTo(expectedVersion);
179+
switch (operator) {
213180
case '==':
214181
return comparison === 0;
215182
case '!=':
@@ -226,7 +193,7 @@ export class PythonVersion {
226193
return (
227194
comparison >= 0 &&
228195
this.matchesReleaseComponents(
229-
clause.expected.releaseComponents.slice(0, clause.expected.releaseComponentCount - 1),
196+
expectedVersion.releasePrefix(expectedVersion.releaseComponentCount - 1),
230197
)
231198
);
232199
default:
@@ -235,21 +202,23 @@ export class PythonVersion {
235202
}
236203

237204
private compareReleaseTo(other: PythonVersion): number {
238-
for (let index = 0; index < this.releaseComponents.length; index++) {
239-
const comparison = compareNumbers(this.releaseComponents[index], other.releaseComponents[index]);
240-
if (comparison !== 0) {
241-
return comparison;
242-
}
243-
}
244-
return 0;
205+
return (
206+
compareNumbers(this.major, other.major) ||
207+
compareNumbers(this.minor, other.minor) ||
208+
compareNumbers(this.patch, other.patch)
209+
);
245210
}
246211

247-
private get releaseComponents(): readonly number[] {
248-
return [this.major, this.minor, this.patch];
212+
private releasePrefix(length: number): readonly number[] {
213+
return [this.major, this.minor, this.patch].slice(0, length);
249214
}
250215

251216
private matchesReleaseComponents(expected: readonly number[]): boolean {
252-
return expected.every((component, index) => component === this.releaseComponents[index]);
217+
return (
218+
expected[0] === this.major &&
219+
(expected.length < 2 || expected[1] === this.minor) &&
220+
(expected.length < 3 || expected[2] === this.patch)
221+
);
253222
}
254223

255224
private static parseWildcard(wildcard: unknown): number[] | undefined {

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

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -33,24 +33,24 @@ suite('PythonVersion', () => {
3333
assert.ok(new PythonVersion('3.14.0rc1').compareTo(new PythonVersion('3.14.0')) < 0);
3434
});
3535

36-
test('satisfies release-prefix wildcards', () => {
36+
test('satisfies release-prefix wildcard specifiers', () => {
3737
const version = new PythonVersion('3.14.0b1');
3838

39-
assert.strictEqual(version.satisfiesWildcard('3.*'), true);
40-
assert.strictEqual(version.satisfiesWildcard('3.14.*'), true);
41-
assert.strictEqual(version.satisfiesWildcard('3.14.0.*'), true);
42-
assert.strictEqual(version.satisfiesWildcard('3.13.*'), false);
43-
assert.strictEqual(version.satisfiesWildcard('4.*'), false);
39+
assert.strictEqual(version.satisfies('==3.*'), true);
40+
assert.strictEqual(version.satisfies('==3.14.*'), true);
41+
assert.strictEqual(version.satisfies('==3.14.0.*'), true);
42+
assert.strictEqual(version.satisfies('==3.13.*'), false);
43+
assert.strictEqual(version.satisfies('==4.*'), false);
4444
});
4545

4646
test('rejects malformed wildcards without throwing', () => {
4747
const version = new PythonVersion('3.14.0');
4848

49-
assert.strictEqual(version.satisfiesWildcard('*'), false);
50-
assert.strictEqual(version.satisfiesWildcard('3.*.0'), false);
51-
assert.strictEqual(version.satisfiesWildcard('>=3.14.*'), false);
52-
assert.strictEqual(version.satisfiesWildcard(`${Number.MAX_SAFE_INTEGER}0.*`), false);
53-
assert.strictEqual(version.satisfiesWildcard(undefined), false);
49+
assert.strictEqual(version.satisfies('==*'), undefined);
50+
assert.strictEqual(version.satisfies('==3.*.0'), undefined);
51+
assert.strictEqual(version.satisfies('>=3.14.*'), undefined);
52+
assert.strictEqual(version.satisfies(`==${Number.MAX_SAFE_INTEGER}0.*`), undefined);
53+
assert.strictEqual(version.satisfies(undefined), undefined);
5454
});
5555

5656
test('satisfies ordered and compound specifiers', () => {
@@ -90,19 +90,22 @@ suite('PythonVersion', () => {
9090
test('rejects malformed specifiers without throwing', () => {
9191
const version = new PythonVersion('3.12.4');
9292

93-
assert.strictEqual(version.satisfies(''), false);
94-
assert.strictEqual(version.satisfies('3.12'), false);
95-
assert.strictEqual(version.satisfies('>=3.12.*'), false);
96-
assert.strictEqual(version.satisfies(`!=${Number.MAX_SAFE_INTEGER}0.*`), false);
97-
assert.strictEqual(version.satisfies('~=3'), false);
98-
assert.strictEqual(version.satisfies(undefined), false);
93+
assert.strictEqual(version.satisfies(''), undefined);
94+
assert.strictEqual(version.satisfies('3.12'), undefined);
95+
assert.strictEqual(version.satisfies('>=3.12.*'), undefined);
96+
assert.strictEqual(version.satisfies(`!=${Number.MAX_SAFE_INTEGER}0.*`), undefined);
97+
assert.strictEqual(version.satisfies('~=3'), undefined);
98+
assert.strictEqual(version.satisfies('>=3.11,'), undefined);
99+
assert.strictEqual(version.satisfies('>=3.11,,<4'), undefined);
100+
assert.strictEqual(version.satisfies(undefined), undefined);
99101
});
100102

101103
test('distinguishes invalid specifiers from valid non-matches', () => {
102104
const version = new PythonVersion('3.12.4');
103105

104-
assert.strictEqual(version.matchSpecifier('>=3.13'), false);
105-
assert.strictEqual(version.matchSpecifier('>=3.12.*'), undefined);
106+
assert.strictEqual(version.satisfies('>=3.13'), false);
107+
assert.strictEqual(version.satisfies('>=3.12.*'), undefined);
108+
assert.strictEqual(version.satisfies('>=3.13,invalid'), undefined);
106109
});
107110

108111
test('rejects versions that cannot be compared safely', () => {

0 commit comments

Comments
 (0)