From 860c6bde6180ffaaacf480713c8f8ff3c4451160 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Mon, 31 Aug 2026 11:20:12 -0400 Subject: [PATCH 1/9] Add PythonVersion class to centralize parsing and comparing of Python versions --- src/common/pythonVersion.ts | 120 +++++++++++++++++++++ src/test/common/pythonVersion.unit.test.ts | 48 +++++++++ 2 files changed, 168 insertions(+) create mode 100644 src/common/pythonVersion.ts create mode 100644 src/test/common/pythonVersion.unit.test.ts diff --git a/src/common/pythonVersion.ts b/src/common/pythonVersion.ts new file mode 100644 index 00000000..5cc59833 --- /dev/null +++ b/src/common/pythonVersion.ts @@ -0,0 +1,120 @@ +export type PythonReleaseLevel = 'alpha' | 'beta' | 'candidate' | 'final'; + +const VERSION_PATTERN = + /^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:(?:\.(alpha|beta|candidate|final)\.(\d+))|(?:(a|b|rc)(\d+)))?$/i; + +const releaseLevelOrder: Record = { + alpha: 0, + beta: 1, + candidate: 2, + final: 3, +}; + +export class PythonVersion { + /** + * Creates a normalized Python release version. + * + * Missing minor and patch components are normalized to zero. Python + * `sys.version_info` suffixes and compact prerelease suffixes are + * normalized, so `3.14.0.beta.1` and `3.14.0b1` are both represented as + * `3.14.0b1`. + * + * @param version A Python release version. + */ + constructor(version: string) { + const match = VERSION_PATTERN.exec(version.trim()); + if (!match) { + throw new TypeError(`Invalid Python version: ${version}`); + } + + this.major = parseNumericComponent(match[1], version); + this.minor = parseNumericComponent(match[2], version); + this.patch = parseNumericComponent(match[3], version); + this.releaseLevel = normalizeReleaseLevel(match[4] ?? match[6]); + this.releaseSerial = parseNumericComponent(match[5] ?? match[7], version); + } + + readonly major: number; + readonly minor: number; + readonly patch: number; + readonly releaseLevel: PythonReleaseLevel; + readonly releaseSerial: number; + + /** + * Attempts to parse a Python version without propagating malformed input errors. + * + * @param version The value to parse. + * @returns A normalized version, or `undefined` when the value is unsupported. + */ + static tryParse(version: unknown): PythonVersion | undefined { + if (typeof version !== 'string') { + return undefined; + } + + try { + return new PythonVersion(version); + } catch { + return undefined; + } + } + + /** + * Compares this version with another normalized Python version. + * + * @param other The version to compare against. + * @returns A negative number when this version is older, zero when both + * versions are equal, and a positive number when this version is newer. + */ + compareTo(other: PythonVersion): number { + return ( + compareNumbers(this.major, other.major) || + compareNumbers(this.minor, other.minor) || + compareNumbers(this.patch, other.patch) || + compareNumbers(releaseLevelOrder[this.releaseLevel], releaseLevelOrder[other.releaseLevel]) || + compareNumbers(this.releaseSerial, other.releaseSerial) + ); + } + + /** Returns the normalized Python version representation. */ + toString(): string { + const release = `${this.major}.${this.minor}.${this.patch}`; + switch (this.releaseLevel) { + case 'alpha': + return `${release}a${this.releaseSerial}`; + case 'beta': + return `${release}b${this.releaseSerial}`; + case 'candidate': + return `${release}rc${this.releaseSerial}`; + case 'final': + return release; + } + } +} + +function parseNumericComponent(value: string | undefined, version: string): number { + const parsed = Number(value ?? 0); + if (!Number.isSafeInteger(parsed)) { + throw new TypeError(`Invalid Python version: ${version}`); + } + return parsed; +} + +function compareNumbers(left: number, right: number): number { + return left === right ? 0 : left < right ? -1 : 1; +} + +function normalizeReleaseLevel(value: string | undefined): PythonReleaseLevel { + switch (value?.toLowerCase()) { + case 'a': + case 'alpha': + return 'alpha'; + case 'b': + case 'beta': + return 'beta'; + case 'rc': + case 'candidate': + return 'candidate'; + default: + return 'final'; + } +} diff --git a/src/test/common/pythonVersion.unit.test.ts b/src/test/common/pythonVersion.unit.test.ts new file mode 100644 index 00000000..be500dd1 --- /dev/null +++ b/src/test/common/pythonVersion.unit.test.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert'; +import { PythonVersion } from '../../common/pythonVersion'; + +suite('PythonVersion', () => { + test('normalizes missing version components', () => { + assert.strictEqual(new PythonVersion('3').toString(), '3.0.0'); + assert.strictEqual(new PythonVersion('3.12').toString(), '3.12.0'); + assert.strictEqual(new PythonVersion('3.12.4').toString(), '3.12.4'); + }); + + test('normalizes surrounding whitespace and leading zeroes', () => { + assert.strictEqual(new PythonVersion(' 03.012.004 ').toString(), '3.12.4'); + }); + + test('normalizes Python release-level suffixes', () => { + assert.strictEqual(new PythonVersion('3.14.3.final.0').toString(), '3.14.3'); + assert.strictEqual(new PythonVersion('3.14.0.beta.1').toString(), '3.14.0b1'); + assert.strictEqual(new PythonVersion('3.14.0b1').toString(), '3.14.0b1'); + assert.strictEqual(new PythonVersion('3.15.0rc1').toString(), '3.15.0rc1'); + }); + + test('compares each numeric component in order', () => { + assert.ok(new PythonVersion('3.9').compareTo(new PythonVersion('3.10')) < 0); + assert.ok(new PythonVersion('3.12.9').compareTo(new PythonVersion('3.12.10')) < 0); + assert.ok(new PythonVersion('4').compareTo(new PythonVersion('3.99.99')) > 0); + assert.strictEqual(new PythonVersion('3.12').compareTo(new PythonVersion('3.12.0')), 0); + }); + + test('orders prereleases before the final release', () => { + assert.ok(new PythonVersion('3.14.0a1').compareTo(new PythonVersion('3.14.0b1')) < 0); + assert.ok(new PythonVersion('3.14.0b1').compareTo(new PythonVersion('3.14.0b2')) < 0); + assert.ok(new PythonVersion('3.14.0b2').compareTo(new PythonVersion('3.14.0rc1')) < 0); + assert.ok(new PythonVersion('3.14.0rc1').compareTo(new PythonVersion('3.14.0')) < 0); + }); + + test('rejects versions that cannot be compared safely', () => { + for (const version of ['', '3.', '3.12.1.4', 'Python 3.12', `${Number.MAX_SAFE_INTEGER}0.1.0`]) { + assert.throws(() => new PythonVersion(version), TypeError); + } + }); + + test('tries to parse untrusted values without throwing', () => { + assert.strictEqual(PythonVersion.tryParse('3.14.0b1')?.toString(), '3.14.0b1'); + assert.strictEqual(PythonVersion.tryParse('invalid'), undefined); + assert.strictEqual(PythonVersion.tryParse(undefined), undefined); + assert.strictEqual(PythonVersion.tryParse({ version: '3.14.0' }), undefined); + }); +}); From 610460280d003685dfd930744d12911b53a3b07c Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Mon, 31 Aug 2026 11:20:55 -0400 Subject: [PATCH 2/9] Use PythonVersion version when getting latest python version --- src/managers/common/utils.ts | 6 +++- .../common/utils.getLatest.unit.test.ts | 36 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 src/test/managers/common/utils.getLatest.unit.test.ts diff --git a/src/managers/common/utils.ts b/src/managers/common/utils.ts index ba7390c4..af53b3a5 100644 --- a/src/managers/common/utils.ts +++ b/src/managers/common/utils.ts @@ -4,6 +4,7 @@ import path from 'path'; import { commands, ConfigurationTarget, l10n, window, workspace } from 'vscode'; import { PythonCommandRunConfiguration, PythonEnvironment, PythonEnvironmentApi } from '../../api'; import { traceLog, traceVerbose } from '../../common/logging'; +import { PythonVersion } from '../../common/pythonVersion'; import { isWindows } from '../../common/utils/platformUtils'; import { ShellConstants } from '../../features/common/shellConstants'; import { getDefaultEnvManagerSetting, setDefaultEnvManagerBroken } from '../../features/settings/settingHelpers'; @@ -68,9 +69,12 @@ export function getLatest(collection: PythonEnvironment[]): PythonEnvironment | const candidates = nonErroredEnvs.length > 0 ? nonErroredEnvs : collection; let latest = candidates[0]; + let latestVersion: PythonVersion | undefined; for (const env of candidates) { - if (pep440Valid(env.version) && pep440Valid(latest.version) && pep440Compare(env.version, latest.version) > 0) { + const version = PythonVersion.tryParse(env.version); + if (version && (!latestVersion || version.compareTo(latestVersion) > 0)) { latest = env; + latestVersion = version; } } return latest; diff --git a/src/test/managers/common/utils.getLatest.unit.test.ts b/src/test/managers/common/utils.getLatest.unit.test.ts new file mode 100644 index 00000000..410d2e25 --- /dev/null +++ b/src/test/managers/common/utils.getLatest.unit.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert'; +import path from 'node:path'; +import { getLatest } from '../../../managers/common/utils'; +import { createMockPythonEnvironment } from '../../mocks/pythonEnvironment'; + +suite('getLatest', () => { + test('selects the latest normalized Python version', () => { + const systemPython = createMockPythonEnvironment({ + envPath: path.join('usr', 'bin', 'python3'), + version: '3.9.6.final.0', + }); + const homebrewPython = createMockPythonEnvironment({ + envPath: path.join('opt', 'homebrew', 'bin', 'python3'), + version: '3.14.3.final.0', + }); + + assert.strictEqual(getLatest([systemPython, homebrewPython]), homebrewPython); + }); + + test('prefers a comparable version when the first version is invalid', () => { + const invalid = createMockPythonEnvironment({ envPath: path.join('invalid', 'python'), version: 'unknown' }); + const valid = createMockPythonEnvironment({ envPath: path.join('valid', 'python'), version: '3.14.3' }); + + assert.strictEqual(getLatest([invalid, valid]), valid); + }); + + test('excludes errored environments when a usable environment exists', () => { + const older = createMockPythonEnvironment({ envPath: path.join('older', 'python'), version: '3.9.6' }); + const errored = { + ...createMockPythonEnvironment({ envPath: path.join('errored', 'python'), version: '3.14.3' }), + error: 'Broken interpreter', + }; + + assert.strictEqual(getLatest([older, errored]), older); + }); +}); \ No newline at end of file From 3ea03fccc05b5a9b2ac896cb1f13765246f24d2b Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Mon, 31 Aug 2026 11:27:14 -0400 Subject: [PATCH 3/9] Update tests --- src/test/managers/common/utils.getLatest.unit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/managers/common/utils.getLatest.unit.test.ts b/src/test/managers/common/utils.getLatest.unit.test.ts index 410d2e25..aa623829 100644 --- a/src/test/managers/common/utils.getLatest.unit.test.ts +++ b/src/test/managers/common/utils.getLatest.unit.test.ts @@ -33,4 +33,4 @@ suite('getLatest', () => { assert.strictEqual(getLatest([older, errored]), older); }); -}); \ No newline at end of file +}); From ab8daaf712fa5b946cc19164c354d68a006f547a Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Mon, 31 Aug 2026 11:42:43 -0400 Subject: [PATCH 4/9] Implement in sorting --- src/managers/common/utils.ts | 16 ++++-- .../utils.sortEnvironments.unit.test.ts | 55 +++++++++++++++++++ 2 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 src/test/managers/common/utils.sortEnvironments.unit.test.ts diff --git a/src/managers/common/utils.ts b/src/managers/common/utils.ts index af53b3a5..7446779b 100644 --- a/src/managers/common/utils.ts +++ b/src/managers/common/utils.ts @@ -1,4 +1,4 @@ -import { major, minor, patch, compare as pep440Compare, valid as pep440Valid } from '@renovatebot/pep440'; +import { major, minor, patch, valid as pep440Valid } from '@renovatebot/pep440'; import * as fs from 'fs-extra'; import path from 'path'; import { commands, ConfigurationTarget, l10n, window, workspace } from 'vscode'; @@ -47,10 +47,18 @@ export function sortEnvironments(collection: PythonEnvironment[]): PythonEnviron return -1; } if (a.version !== b.version) { - if (pep440Valid(a.version) && pep440Valid(b.version)) { - return pep440Compare(b.version, a.version); // descending + const aVersion = PythonVersion.tryParse(a.version); + const bVersion = PythonVersion.tryParse(b.version); + if (aVersion && bVersion) { + const comparison = bVersion.compareTo(aVersion); + if (comparison !== 0) { + return comparison; + } + } else if (aVersion) { + return -1; + } else if (bVersion) { + return 1; } - return a.version ? 1 : -1; } const value = a.name.localeCompare(b.name); if (value !== 0) { diff --git a/src/test/managers/common/utils.sortEnvironments.unit.test.ts b/src/test/managers/common/utils.sortEnvironments.unit.test.ts new file mode 100644 index 00000000..c668fc87 --- /dev/null +++ b/src/test/managers/common/utils.sortEnvironments.unit.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert'; +import path from 'node:path'; +import { sortEnvironments } from '../../../managers/common/utils'; +import { createMockPythonEnvironment } from '../../mocks/pythonEnvironment'; + +suite('sortEnvironments', () => { + test('sorts normalized PET versions in descending order', () => { + const versions = ['3.9.6.final.0', '3.14.3.final.0', '3.11.9.final.0']; + const environments = versions.map((version) => + createMockPythonEnvironment({ envPath: path.join('python', version), version }), + ); + + assert.deepStrictEqual( + sortEnvironments(environments).map((environment) => environment.version), + ['3.14.3.final.0', '3.11.9.final.0', '3.9.6.final.0'], + ); + }); + + test('sorts prereleases before their final release', () => { + const versions = ['3.14.0b2', '3.14.0', '3.14.0rc1', '3.14.0a1']; + const environments = versions.map((version) => + createMockPythonEnvironment({ envPath: path.join('python', version), version }), + ); + + assert.deepStrictEqual( + sortEnvironments(environments).map((environment) => environment.version), + ['3.14.0', '3.14.0rc1', '3.14.0b2', '3.14.0a1'], + ); + }); + + test('sorts valid versions before invalid versions', () => { + const invalid = createMockPythonEnvironment({ + name: 'invalid', + envPath: path.join('python', 'invalid'), + version: 'unknown', + }); + const valid = createMockPythonEnvironment({ + name: 'valid', + envPath: path.join('python', 'valid'), + version: '3.14.3', + }); + + assert.deepStrictEqual(sortEnvironments([invalid, valid]), [valid, invalid]); + }); + + test('sorts errored environments after usable environments regardless of version', () => { + const usable = createMockPythonEnvironment({ envPath: path.join('python', 'usable'), version: '3.9.6' }); + const errored = { + ...createMockPythonEnvironment({ envPath: path.join('python', 'errored'), version: '3.14.3' }), + error: 'Broken interpreter', + }; + + assert.deepStrictEqual(sortEnvironments([errored, usable]), [usable, errored]); + }); +}); From f8e01f1ff186fa95485ad7dffd242fc9ac24a99b Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Mon, 31 Aug 2026 11:44:41 -0400 Subject: [PATCH 5/9] Refactoring --- src/common/pythonVersion.ts | 59 +++++++++++++++++++------------------ 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/src/common/pythonVersion.ts b/src/common/pythonVersion.ts index 5cc59833..d5fa7ecc 100644 --- a/src/common/pythonVersion.ts +++ b/src/common/pythonVersion.ts @@ -1,16 +1,26 @@ -export type PythonReleaseLevel = 'alpha' | 'beta' | 'candidate' | 'final'; +type PythonReleaseLevel = 'alpha' | 'beta' | 'candidate' | 'final'; -const VERSION_PATTERN = - /^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:(?:\.(alpha|beta|candidate|final)\.(\d+))|(?:(a|b|rc)(\d+)))?$/i; +export class PythonVersion { + private static readonly VERSION_PATTERN = + /^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:(?:\.(alpha|beta|candidate|final)\.(\d+))|(?:(a|b|rc)(\d+)))?$/i; -const releaseLevelOrder: Record = { - alpha: 0, - beta: 1, - candidate: 2, - final: 3, -}; + private static readonly RELEASE_LEVEL_ALIASES: Readonly> = { + a: 'alpha', + alpha: 'alpha', + b: 'beta', + beta: 'beta', + rc: 'candidate', + candidate: 'candidate', + final: 'final', + }; + + private static readonly RELEASE_LEVEL_ORDER: Readonly> = { + alpha: 0, + beta: 1, + candidate: 2, + final: 3, + }; -export class PythonVersion { /** * Creates a normalized Python release version. * @@ -22,7 +32,7 @@ export class PythonVersion { * @param version A Python release version. */ constructor(version: string) { - const match = VERSION_PATTERN.exec(version.trim()); + const match = PythonVersion.VERSION_PATTERN.exec(version.trim()); if (!match) { throw new TypeError(`Invalid Python version: ${version}`); } @@ -30,7 +40,7 @@ export class PythonVersion { this.major = parseNumericComponent(match[1], version); this.minor = parseNumericComponent(match[2], version); this.patch = parseNumericComponent(match[3], version); - this.releaseLevel = normalizeReleaseLevel(match[4] ?? match[6]); + this.releaseLevel = PythonVersion.normalizeReleaseLevel(match[4] ?? match[6]); this.releaseSerial = parseNumericComponent(match[5] ?? match[7], version); } @@ -70,7 +80,10 @@ export class PythonVersion { compareNumbers(this.major, other.major) || compareNumbers(this.minor, other.minor) || compareNumbers(this.patch, other.patch) || - compareNumbers(releaseLevelOrder[this.releaseLevel], releaseLevelOrder[other.releaseLevel]) || + compareNumbers( + PythonVersion.RELEASE_LEVEL_ORDER[this.releaseLevel], + PythonVersion.RELEASE_LEVEL_ORDER[other.releaseLevel], + ) || compareNumbers(this.releaseSerial, other.releaseSerial) ); } @@ -89,6 +102,10 @@ export class PythonVersion { return release; } } + + private static normalizeReleaseLevel(value: string | undefined): PythonReleaseLevel { + return value ? (PythonVersion.RELEASE_LEVEL_ALIASES[value.toLowerCase()] ?? 'final') : 'final'; + } } function parseNumericComponent(value: string | undefined, version: string): number { @@ -102,19 +119,3 @@ function parseNumericComponent(value: string | undefined, version: string): numb function compareNumbers(left: number, right: number): number { return left === right ? 0 : left < right ? -1 : 1; } - -function normalizeReleaseLevel(value: string | undefined): PythonReleaseLevel { - switch (value?.toLowerCase()) { - case 'a': - case 'alpha': - return 'alpha'; - case 'b': - case 'beta': - return 'beta'; - case 'rc': - case 'candidate': - return 'candidate'; - default: - return 'final'; - } -} From 88ecf1d1bf2bc8e18f35a23eb946389195773e1c Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Mon, 31 Aug 2026 12:18:12 -0400 Subject: [PATCH 6/9] Add satisfies command --- src/common/pythonVersion.ts | 136 ++++++++++++++++++++- src/test/common/pythonVersion.unit.test.ts | 65 ++++++++++ 2 files changed, 200 insertions(+), 1 deletion(-) diff --git a/src/common/pythonVersion.ts b/src/common/pythonVersion.ts index d5fa7ecc..7d6b4987 100644 --- a/src/common/pythonVersion.ts +++ b/src/common/pythonVersion.ts @@ -4,6 +4,10 @@ export class PythonVersion { private static readonly VERSION_PATTERN = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:(?:\.(alpha|beta|candidate|final)\.(\d+))|(?:(a|b|rc)(\d+)))?$/i; + private static readonly WILDCARD_PATTERN = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?\.\*$/; + + private static readonly SPECIFIER_PATTERN = /^(===|~=|==|!=|>=|<=|>|<)\s*(.+)$/; + private static readonly RELEASE_LEVEL_ALIASES: Readonly> = { a: 'alpha', alpha: 'alpha', @@ -32,11 +36,14 @@ export class PythonVersion { * @param version A Python release version. */ constructor(version: string) { - const match = PythonVersion.VERSION_PATTERN.exec(version.trim()); + const normalizedVersion = version.trim(); + const match = PythonVersion.VERSION_PATTERN.exec(normalizedVersion); if (!match) { throw new TypeError(`Invalid Python version: ${version}`); } + this.original = normalizedVersion; + this.releaseComponentCount = match[3] !== undefined ? 3 : match[2] !== undefined ? 2 : 1; this.major = parseNumericComponent(match[1], version); this.minor = parseNumericComponent(match[2], version); this.patch = parseNumericComponent(match[3], version); @@ -49,6 +56,8 @@ export class PythonVersion { readonly patch: number; readonly releaseLevel: PythonReleaseLevel; readonly releaseSerial: number; + private readonly original: string; + private readonly releaseComponentCount: number; /** * Attempts to parse a Python version without propagating malformed input errors. @@ -88,6 +97,40 @@ export class PythonVersion { ); } + /** + * Tests whether this version matches a release-prefix wildcard. + * + * @param wildcard A terminal wildcard such as `3.*`, `3.14.*`, or `3.14.0.*`. + * @returns `true` when all components before the wildcard match, otherwise `false`. + */ + satisfiesWildcard(wildcard: unknown): boolean { + const expected = PythonVersion.parseWildcard(wildcard); + return expected !== undefined && this.matchesReleaseComponents(expected); + } + + /** + * Tests whether this version satisfies a Python version specifier. + * + * Supports `==`, `!=`, `>=`, `<=`, `>`, `<`, `~=`, and `===` operators, + * comma-separated AND clauses, and terminal wildcards with `==` or `!=`. + * Prerelease suffixes are ignored for ordered and release-equality + * comparisons, matching the inline-script interpreter behavior. + * + * @param specifier A version specifier such as `>=3.11,<3.14` or `==3.12.*`. + * @returns `true` when every clause matches; otherwise `false`. + */ + satisfies(specifier: unknown): boolean { + if (typeof specifier !== 'string') { + return false; + } + + const clauses = specifier + .split(',') + .map((clause) => clause.trim()) + .filter((clause) => clause.length > 0); + return clauses.length > 0 && clauses.every((clause) => this.satisfiesClause(clause)); + } + /** Returns the normalized Python version representation. */ toString(): string { const release = `${this.major}.${this.minor}.${this.patch}`; @@ -106,6 +149,97 @@ export class PythonVersion { private static normalizeReleaseLevel(value: string | undefined): PythonReleaseLevel { return value ? (PythonVersion.RELEASE_LEVEL_ALIASES[value.toLowerCase()] ?? 'final') : 'final'; } + + private satisfiesClause(clause: string): boolean { + const match = PythonVersion.SPECIFIER_PATTERN.exec(clause); + if (!match) { + return false; + } + + const operator = match[1]; + const expected = match[2].trim(); + if (operator === '===') { + return this.original.replace(/^v/i, '') === expected.replace(/^v/i, ''); + } + + if (expected.endsWith('.*')) { + if (operator !== '==' && operator !== '!=') { + return false; + } + const expectedComponents = PythonVersion.parseWildcard(expected); + if (!expectedComponents) { + return false; + } + const matches = this.matchesReleaseComponents(expectedComponents); + return operator === '==' ? matches : !matches; + } + + const expectedVersion = PythonVersion.tryParse(expected); + if (!expectedVersion) { + return false; + } + + const comparison = this.compareReleaseTo(expectedVersion); + switch (operator) { + case '==': + return comparison === 0; + case '!=': + return comparison !== 0; + case '>=': + return comparison >= 0; + case '<=': + return comparison <= 0; + case '>': + return comparison > 0; + case '<': + return comparison < 0; + case '~=': + return ( + expectedVersion.releaseComponentCount >= 2 && + comparison >= 0 && + this.matchesReleaseComponents( + expectedVersion.releaseComponents.slice(0, expectedVersion.releaseComponentCount - 1), + ) + ); + default: + return false; + } + } + + private compareReleaseTo(other: PythonVersion): number { + for (let index = 0; index < this.releaseComponents.length; index++) { + const comparison = compareNumbers(this.releaseComponents[index], other.releaseComponents[index]); + if (comparison !== 0) { + return comparison; + } + } + return 0; + } + + private get releaseComponents(): readonly number[] { + return [this.major, this.minor, this.patch]; + } + + private matchesReleaseComponents(expected: readonly number[]): boolean { + return expected.every((component, index) => component === this.releaseComponents[index]); + } + + private static parseWildcard(wildcard: unknown): number[] | undefined { + if (typeof wildcard !== 'string') { + return undefined; + } + + const match = PythonVersion.WILDCARD_PATTERN.exec(wildcard.trim()); + if (!match) { + return undefined; + } + + const components = match + .slice(1) + .filter((component): component is string => component !== undefined) + .map(Number); + return components.every(Number.isSafeInteger) ? components : undefined; + } } function parseNumericComponent(value: string | undefined, version: string): number { diff --git a/src/test/common/pythonVersion.unit.test.ts b/src/test/common/pythonVersion.unit.test.ts index be500dd1..9af8316e 100644 --- a/src/test/common/pythonVersion.unit.test.ts +++ b/src/test/common/pythonVersion.unit.test.ts @@ -33,6 +33,71 @@ suite('PythonVersion', () => { assert.ok(new PythonVersion('3.14.0rc1').compareTo(new PythonVersion('3.14.0')) < 0); }); + test('satisfies release-prefix wildcards', () => { + const version = new PythonVersion('3.14.0b1'); + + assert.strictEqual(version.satisfiesWildcard('3.*'), true); + assert.strictEqual(version.satisfiesWildcard('3.14.*'), true); + assert.strictEqual(version.satisfiesWildcard('3.14.0.*'), true); + assert.strictEqual(version.satisfiesWildcard('3.13.*'), false); + assert.strictEqual(version.satisfiesWildcard('4.*'), false); + }); + + test('rejects malformed wildcards without throwing', () => { + const version = new PythonVersion('3.14.0'); + + assert.strictEqual(version.satisfiesWildcard('*'), false); + assert.strictEqual(version.satisfiesWildcard('3.*.0'), false); + assert.strictEqual(version.satisfiesWildcard('>=3.14.*'), false); + assert.strictEqual(version.satisfiesWildcard(`${Number.MAX_SAFE_INTEGER}0.*`), false); + assert.strictEqual(version.satisfiesWildcard(undefined), false); + }); + + test('satisfies ordered and compound specifiers', () => { + const version = new PythonVersion('3.12.4'); + + assert.strictEqual(version.satisfies('>=3.11'), true); + assert.strictEqual(version.satisfies('>=3.10,<3.13'), true); + assert.strictEqual(version.satisfies('>=3.13'), false); + assert.strictEqual(version.satisfies('<=3.12.4'), true); + assert.strictEqual(version.satisfies('>3.12.4'), false); + }); + + test('satisfies equality and wildcard specifiers', () => { + const version = new PythonVersion('3.12.4'); + + assert.strictEqual(version.satisfies('==3.12.4'), true); + assert.strictEqual(version.satisfies('!=3.12.3'), true); + assert.strictEqual(version.satisfies('==3.12.*'), true); + assert.strictEqual(version.satisfies('!=3.12.*'), false); + assert.strictEqual(version.satisfies('==3.11.*'), false); + }); + + test('satisfies compatible-release specifiers', () => { + assert.strictEqual(new PythonVersion('3.12.4').satisfies('~=3.11'), true); + assert.strictEqual(new PythonVersion('4.0.0').satisfies('~=3.11'), false); + assert.strictEqual(new PythonVersion('3.11.10').satisfies('~=3.11.2'), true); + assert.strictEqual(new PythonVersion('3.12.0').satisfies('~=3.11.2'), false); + }); + + test('supports arbitrary equality and release equality', () => { + assert.strictEqual(new PythonVersion('3.11').satisfies('==3.11.0'), true); + assert.strictEqual(new PythonVersion('3.11').satisfies('===3.11'), true); + assert.strictEqual(new PythonVersion('3.11').satisfies('===3.11.0'), false); + assert.strictEqual(new PythonVersion('3.11.0rc1').satisfies('>=3.11'), true); + }); + + test('rejects malformed specifiers without throwing', () => { + const version = new PythonVersion('3.12.4'); + + assert.strictEqual(version.satisfies(''), false); + assert.strictEqual(version.satisfies('3.12'), false); + assert.strictEqual(version.satisfies('>=3.12.*'), false); + assert.strictEqual(version.satisfies(`!=${Number.MAX_SAFE_INTEGER}0.*`), false); + assert.strictEqual(version.satisfies('~=3'), false); + assert.strictEqual(version.satisfies(undefined), false); + }); + test('rejects versions that cannot be compared safely', () => { for (const version of ['', '3.', '3.12.1.4', 'Python 3.12', `${Number.MAX_SAFE_INTEGER}0.1.0`]) { assert.throws(() => new PythonVersion(version), TypeError); From e8b2e04deac7ce5a13bcc32bd5339028cc194915 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Mon, 31 Aug 2026 12:31:09 -0400 Subject: [PATCH 7/9] Add satisfies command --- src/common/inlineScript/metadata.ts | 102 ++---------------- src/common/pythonVersion.ts | 90 ++++++++++------ .../common/inlineScript/metadata.unit.test.ts | 6 ++ src/test/common/pythonVersion.unit.test.ts | 16 ++- 4 files changed, 88 insertions(+), 126 deletions(-) diff --git a/src/common/inlineScript/metadata.ts b/src/common/inlineScript/metadata.ts index 45f6d34b..af3b44d0 100644 --- a/src/common/inlineScript/metadata.ts +++ b/src/common/inlineScript/metadata.ts @@ -5,7 +5,7 @@ import * as tomljs from '@iarna/toml'; import * as fs from 'fs/promises'; import { Uri } from 'vscode'; import { traceVerbose, traceWarn } from '../logging'; -import { compareReleaseSegments, parseReleaseSegments } from '../utils/pep440Release'; +import { PythonVersion } from '../pythonVersion'; /** * Parsed and validated PEP 723 `script` metadata block. @@ -304,101 +304,15 @@ export function matchesPythonVersion(requiresPython: string, version: string): b if (!requiresPython || !version) { return false; } - const clauses = requiresPython - .split(',') - .map((c) => c.trim()) - .filter((c) => c.length > 0); - if (clauses.length === 0) { + const parsedVersion = PythonVersion.tryParse(version); + if (!parsedVersion) { + traceWarn(`inline script metadata: cannot parse Python version: ${JSON.stringify(version)}`); return false; } - for (const clause of clauses) { - if (!matchSingleClause(clause, version)) { - return false; - } - } - return true; -} - -// Longest-match-first order matters: `===` must beat `==`, `~=` and -// `>=` / `<=` / `!=` must beat the single-char operators. -const SPECIFIER_RE = /^(===|~=|==|!=|>=|<=|>|<)\s*(.+)$/; - -function matchSingleClause(clause: string, version: string): boolean { - const m = clause.match(SPECIFIER_RE); - if (!m) { - traceWarn(`inline script metadata: unrecognized requires-python clause: ${JSON.stringify(clause)}`); - return false; - } - const op = m[1]; - const specVersion = m[2].trim(); - - if (op === '===') { - // Arbitrary-equality: exact string comparison after stripping - // a leading 'v' (which PEP 440 permits). - const normSpec = specVersion.replace(/^v/i, ''); - const normVer = version.replace(/^v/i, ''); - return normSpec === normVer; - } - - if (specVersion.endsWith('.*')) { - if (op !== '==' && op !== '!=') { - traceWarn( - `inline script metadata: wildcard versions are only valid with '==' or '!=': ${JSON.stringify(clause)}`, - ); - return false; - } - const prefix = parseReleaseSegments(specVersion.slice(0, -2)); - const ver = parseReleaseSegments(version); - if (prefix === undefined || ver === undefined) { - traceWarn(`inline script metadata: cannot parse version for clause ${JSON.stringify(clause)}`); - return false; - } - const isPrefixMatch = ver.length >= prefix.length && prefix.every((seg, i) => ver[i] === seg); - return op === '==' ? isPrefixMatch : !isPrefixMatch; - } - - const specSegs = parseReleaseSegments(specVersion); - const verSegs = parseReleaseSegments(version); - if (specSegs === undefined || verSegs === undefined) { - traceWarn(`inline script metadata: cannot parse version for clause ${JSON.stringify(clause)}`); + const result = parsedVersion.matchSpecifier(requiresPython); + if (result === undefined) { + traceWarn(`inline script metadata: invalid requires-python specifier: ${JSON.stringify(requiresPython)}`); return false; } - - const cmp = compareReleaseSegments(verSegs, specSegs); - switch (op) { - case '==': - return cmp === 0; - case '!=': - return cmp !== 0; - case '>=': - return cmp >= 0; - case '<=': - return cmp <= 0; - case '>': - return cmp > 0; - case '<': - return cmp < 0; - case '~=': { - // Compatible release. `~=X.Y` is equivalent to - // `>= X.Y, == X.*`; `~=X.Y.Z` is `>= X.Y.Z, == X.Y.*`. - // PEP 440 requires at least two release segments here. - if (specSegs.length < 2) { - traceWarn( - `inline script metadata: '~=' requires at least two release segments: ${JSON.stringify(clause)}`, - ); - return false; - } - if (cmp < 0) { - return false; - } - const prefix = specSegs.slice(0, -1); - if (verSegs.length < prefix.length) { - return false; - } - return prefix.every((seg, i) => verSegs[i] === seg); - } - default: - // Unreachable — SPECIFIER_RE only matches the operators above. - return false; - } + return result; } diff --git a/src/common/pythonVersion.ts b/src/common/pythonVersion.ts index 7d6b4987..4c3a8932 100644 --- a/src/common/pythonVersion.ts +++ b/src/common/pythonVersion.ts @@ -1,4 +1,9 @@ type PythonReleaseLevel = 'alpha' | 'beta' | 'candidate' | 'final'; +type ComparisonOperator = '~=' | '==' | '!=' | '>=' | '<=' | '>' | '<'; +type ParsedClause = + | { readonly operator: '==='; readonly expected: string } + | { readonly operator: '==' | '!='; readonly wildcard: readonly number[] } + | { readonly operator: ComparisonOperator; readonly expected: PythonVersion }; export class PythonVersion { private static readonly VERSION_PATTERN = @@ -43,12 +48,15 @@ export class PythonVersion { } this.original = normalizedVersion; - this.releaseComponentCount = match[3] !== undefined ? 3 : match[2] !== undefined ? 2 : 1; + this.releaseComponentCount = match[3] !== undefined ? 3 : match[2] !== undefined ? 2 : 1; this.major = parseNumericComponent(match[1], version); this.minor = parseNumericComponent(match[2], version); this.patch = parseNumericComponent(match[3], version); this.releaseLevel = PythonVersion.normalizeReleaseLevel(match[4] ?? match[6]); this.releaseSerial = parseNumericComponent(match[5] ?? match[7], version); + if (this.releaseLevel === 'final' && this.releaseSerial !== 0) { + throw new TypeError(`Invalid Python version: ${version}`); + } } readonly major: number; @@ -86,9 +94,7 @@ export class PythonVersion { */ compareTo(other: PythonVersion): number { return ( - compareNumbers(this.major, other.major) || - compareNumbers(this.minor, other.minor) || - compareNumbers(this.patch, other.patch) || + this.compareReleaseTo(other) || compareNumbers( PythonVersion.RELEASE_LEVEL_ORDER[this.releaseLevel], PythonVersion.RELEASE_LEVEL_ORDER[other.releaseLevel], @@ -120,15 +126,18 @@ export class PythonVersion { * @returns `true` when every clause matches; otherwise `false`. */ satisfies(specifier: unknown): boolean { - if (typeof specifier !== 'string') { - return false; - } + return this.matchSpecifier(specifier) ?? false; + } - const clauses = specifier - .split(',') - .map((clause) => clause.trim()) - .filter((clause) => clause.length > 0); - return clauses.length > 0 && clauses.every((clause) => this.satisfiesClause(clause)); + /** + * Evaluates a Python version specifier while distinguishing invalid syntax. + * + * @param specifier The specifier to evaluate. + * @returns Whether this version matches, or `undefined` for an invalid specifier. + */ + matchSpecifier(specifier: unknown): boolean | undefined { + const clauses = PythonVersion.parseSpecifier(specifier); + return clauses?.every((clause) => this.satisfiesClause(clause)); } /** Returns the normalized Python version representation. */ @@ -150,37 +159,57 @@ export class PythonVersion { return value ? (PythonVersion.RELEASE_LEVEL_ALIASES[value.toLowerCase()] ?? 'final') : 'final'; } - private satisfiesClause(clause: string): boolean { + private static parseSpecifier(specifier: unknown): readonly ParsedClause[] | undefined { + if (typeof specifier !== 'string') { + return undefined; + } + + const clauses = specifier + .split(',') + .map((clause) => clause.trim()) + .filter((clause) => clause.length > 0); + if (clauses.length === 0) { + return undefined; + } + + const parsed = clauses.map((clause) => PythonVersion.parseClause(clause)); + return parsed.every((clause): clause is ParsedClause => clause !== undefined) ? parsed : undefined; + } + + private static parseClause(clause: string): ParsedClause | undefined { const match = PythonVersion.SPECIFIER_PATTERN.exec(clause); if (!match) { - return false; + return undefined; } const operator = match[1]; const expected = match[2].trim(); if (operator === '===') { - return this.original.replace(/^v/i, '') === expected.replace(/^v/i, ''); + return expected.length > 0 ? { operator, expected } : undefined; } - if (expected.endsWith('.*')) { - if (operator !== '==' && operator !== '!=') { - return false; - } - const expectedComponents = PythonVersion.parseWildcard(expected); - if (!expectedComponents) { - return false; - } - const matches = this.matchesReleaseComponents(expectedComponents); - return operator === '==' ? matches : !matches; + const wildcard = PythonVersion.parseWildcard(expected); + return (operator === '==' || operator === '!=') && wildcard ? { operator, wildcard } : undefined; } const expectedVersion = PythonVersion.tryParse(expected); - if (!expectedVersion) { - return false; + if (!expectedVersion || (operator === '~=' && expectedVersion.releaseComponentCount < 2)) { + return undefined; + } + return { operator: operator as ComparisonOperator, expected: expectedVersion }; + } + + private satisfiesClause(clause: ParsedClause): boolean { + if (clause.operator === '===') { + return this.original.replace(/^v/i, '') === clause.expected.replace(/^v/i, ''); + } + if ('wildcard' in clause) { + const matches = this.matchesReleaseComponents(clause.wildcard); + return clause.operator === '==' ? matches : !matches; } - const comparison = this.compareReleaseTo(expectedVersion); - switch (operator) { + const comparison = this.compareReleaseTo(clause.expected); + switch (clause.operator) { case '==': return comparison === 0; case '!=': @@ -195,10 +224,9 @@ export class PythonVersion { return comparison < 0; case '~=': return ( - expectedVersion.releaseComponentCount >= 2 && comparison >= 0 && this.matchesReleaseComponents( - expectedVersion.releaseComponents.slice(0, expectedVersion.releaseComponentCount - 1), + clause.expected.releaseComponents.slice(0, clause.expected.releaseComponentCount - 1), ) ); default: diff --git a/src/test/common/inlineScript/metadata.unit.test.ts b/src/test/common/inlineScript/metadata.unit.test.ts index 87dd1257..73310a05 100644 --- a/src/test/common/inlineScript/metadata.unit.test.ts +++ b/src/test/common/inlineScript/metadata.unit.test.ts @@ -467,6 +467,12 @@ suite('inlineScriptMetadata', () => { assert.strictEqual(matchesPythonVersion('>=3.11', '3.10.0rc1'), false); }); + test('supports normalized interpreter version formats', () => { + assert.strictEqual(matchesPythonVersion('>=3.14', '3.14.3.final.0'), true); + assert.strictEqual(matchesPythonVersion('==3.14.*', '3.14.0b1'), true); + assert.strictEqual(matchesPythonVersion('<3.14', '3.14.0b1'), false); + }); + test('invalid specifier returns false and logs warn', () => { assert.strictEqual(matchesPythonVersion('weird-thing', '3.11'), false); assert.ok(traceWarnStub.called); diff --git a/src/test/common/pythonVersion.unit.test.ts b/src/test/common/pythonVersion.unit.test.ts index 9af8316e..d8488375 100644 --- a/src/test/common/pythonVersion.unit.test.ts +++ b/src/test/common/pythonVersion.unit.test.ts @@ -98,8 +98,22 @@ suite('PythonVersion', () => { assert.strictEqual(version.satisfies(undefined), false); }); + test('distinguishes invalid specifiers from valid non-matches', () => { + const version = new PythonVersion('3.12.4'); + + assert.strictEqual(version.matchSpecifier('>=3.13'), false); + assert.strictEqual(version.matchSpecifier('>=3.12.*'), undefined); + }); + test('rejects versions that cannot be compared safely', () => { - for (const version of ['', '3.', '3.12.1.4', 'Python 3.12', `${Number.MAX_SAFE_INTEGER}0.1.0`]) { + for (const version of [ + '', + '3.', + '3.12.1.4', + '3.12.1.final.1', + 'Python 3.12', + `${Number.MAX_SAFE_INTEGER}0.1.0`, + ]) { assert.throws(() => new PythonVersion(version), TypeError); } }); From e2ffa07ee95238d68f542af9b674d2a025ffdb9d Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Mon, 31 Aug 2026 12:54:38 -0400 Subject: [PATCH 8/9] Simplified satisfies logic --- src/common/inlineScript/metadata.ts | 2 +- src/common/pythonVersion.ts | 129 ++++++++------------- src/test/common/pythonVersion.unit.test.ts | 41 ++++--- 3 files changed, 72 insertions(+), 100 deletions(-) diff --git a/src/common/inlineScript/metadata.ts b/src/common/inlineScript/metadata.ts index af3b44d0..b44277a3 100644 --- a/src/common/inlineScript/metadata.ts +++ b/src/common/inlineScript/metadata.ts @@ -309,7 +309,7 @@ export function matchesPythonVersion(requiresPython: string, version: string): b traceWarn(`inline script metadata: cannot parse Python version: ${JSON.stringify(version)}`); return false; } - const result = parsedVersion.matchSpecifier(requiresPython); + const result = parsedVersion.satisfies(requiresPython); if (result === undefined) { traceWarn(`inline script metadata: invalid requires-python specifier: ${JSON.stringify(requiresPython)}`); return false; diff --git a/src/common/pythonVersion.ts b/src/common/pythonVersion.ts index 4c3a8932..9b3b7c6b 100644 --- a/src/common/pythonVersion.ts +++ b/src/common/pythonVersion.ts @@ -1,13 +1,8 @@ type PythonReleaseLevel = 'alpha' | 'beta' | 'candidate' | 'final'; -type ComparisonOperator = '~=' | '==' | '!=' | '>=' | '<=' | '>' | '<'; -type ParsedClause = - | { readonly operator: '==='; readonly expected: string } - | { readonly operator: '==' | '!='; readonly wildcard: readonly number[] } - | { readonly operator: ComparisonOperator; readonly expected: PythonVersion }; export class PythonVersion { private static readonly VERSION_PATTERN = - /^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:(?:\.(alpha|beta|candidate|final)\.(\d+))|(?:(a|b|rc)(\d+)))?$/i; + /^(?\d+)(?:\.(?\d+))?(?:\.(?\d+))?(?:(?:\.(?alpha|beta|candidate|final)\.(?\d+))|(?:(?a|b|rc)(?\d+)))?$/i; private static readonly WILDCARD_PATTERN = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?\.\*$/; @@ -47,13 +42,14 @@ export class PythonVersion { throw new TypeError(`Invalid Python version: ${version}`); } + const groups = match.groups!; this.original = normalizedVersion; - this.releaseComponentCount = match[3] !== undefined ? 3 : match[2] !== undefined ? 2 : 1; - this.major = parseNumericComponent(match[1], version); - this.minor = parseNumericComponent(match[2], version); - this.patch = parseNumericComponent(match[3], version); - this.releaseLevel = PythonVersion.normalizeReleaseLevel(match[4] ?? match[6]); - this.releaseSerial = parseNumericComponent(match[5] ?? match[7], version); + this.releaseComponentCount = groups.patch !== undefined ? 3 : groups.minor !== undefined ? 2 : 1; + this.major = parseNumericComponent(groups.major, version); + this.minor = parseNumericComponent(groups.minor, version); + this.patch = parseNumericComponent(groups.patch, version); + this.releaseLevel = PythonVersion.normalizeReleaseLevel(groups.longLevel ?? groups.shortLevel); + this.releaseSerial = parseNumericComponent(groups.longSerial ?? groups.shortSerial, version); if (this.releaseLevel === 'final' && this.releaseSerial !== 0) { throw new TypeError(`Invalid Python version: ${version}`); } @@ -103,17 +99,6 @@ export class PythonVersion { ); } - /** - * Tests whether this version matches a release-prefix wildcard. - * - * @param wildcard A terminal wildcard such as `3.*`, `3.14.*`, or `3.14.0.*`. - * @returns `true` when all components before the wildcard match, otherwise `false`. - */ - satisfiesWildcard(wildcard: unknown): boolean { - const expected = PythonVersion.parseWildcard(wildcard); - return expected !== undefined && this.matchesReleaseComponents(expected); - } - /** * Tests whether this version satisfies a Python version specifier. * @@ -123,21 +108,27 @@ export class PythonVersion { * comparisons, matching the inline-script interpreter behavior. * * @param specifier A version specifier such as `>=3.11,<3.14` or `==3.12.*`. - * @returns `true` when every clause matches; otherwise `false`. + * @returns Whether every clause matches, or `undefined` when the specifier is invalid. */ - satisfies(specifier: unknown): boolean { - return this.matchSpecifier(specifier) ?? false; - } + satisfies(specifier: unknown): boolean | undefined { + if (typeof specifier !== 'string') { + return undefined; + } - /** - * Evaluates a Python version specifier while distinguishing invalid syntax. - * - * @param specifier The specifier to evaluate. - * @returns Whether this version matches, or `undefined` for an invalid specifier. - */ - matchSpecifier(specifier: unknown): boolean | undefined { - const clauses = PythonVersion.parseSpecifier(specifier); - return clauses?.every((clause) => this.satisfiesClause(clause)); + const clauses = specifier.split(',').map((clause) => clause.trim()); + if (clauses.some((clause) => clause.length === 0)) { + return undefined; + } + + let satisfiesAll = true; + for (const clause of clauses) { + const result = this.matchClause(clause); + if (result === undefined) { + return undefined; + } + satisfiesAll &&= result; + } + return satisfiesAll; } /** Returns the normalized Python version representation. */ @@ -159,24 +150,7 @@ export class PythonVersion { return value ? (PythonVersion.RELEASE_LEVEL_ALIASES[value.toLowerCase()] ?? 'final') : 'final'; } - private static parseSpecifier(specifier: unknown): readonly ParsedClause[] | undefined { - if (typeof specifier !== 'string') { - return undefined; - } - - const clauses = specifier - .split(',') - .map((clause) => clause.trim()) - .filter((clause) => clause.length > 0); - if (clauses.length === 0) { - return undefined; - } - - const parsed = clauses.map((clause) => PythonVersion.parseClause(clause)); - return parsed.every((clause): clause is ParsedClause => clause !== undefined) ? parsed : undefined; - } - - private static parseClause(clause: string): ParsedClause | undefined { + private matchClause(clause: string): boolean | undefined { const match = PythonVersion.SPECIFIER_PATTERN.exec(clause); if (!match) { return undefined; @@ -185,31 +159,24 @@ export class PythonVersion { const operator = match[1]; const expected = match[2].trim(); if (operator === '===') { - return expected.length > 0 ? { operator, expected } : undefined; + return expected ? this.original.replace(/^v/i, '') === expected.replace(/^v/i, '') : undefined; } if (expected.endsWith('.*')) { const wildcard = PythonVersion.parseWildcard(expected); - return (operator === '==' || operator === '!=') && wildcard ? { operator, wildcard } : undefined; + if ((operator !== '==' && operator !== '!=') || !wildcard) { + return undefined; + } + const matches = this.matchesReleaseComponents(wildcard); + return operator === '==' ? matches : !matches; } const expectedVersion = PythonVersion.tryParse(expected); if (!expectedVersion || (operator === '~=' && expectedVersion.releaseComponentCount < 2)) { return undefined; } - return { operator: operator as ComparisonOperator, expected: expectedVersion }; - } - - private satisfiesClause(clause: ParsedClause): boolean { - if (clause.operator === '===') { - return this.original.replace(/^v/i, '') === clause.expected.replace(/^v/i, ''); - } - if ('wildcard' in clause) { - const matches = this.matchesReleaseComponents(clause.wildcard); - return clause.operator === '==' ? matches : !matches; - } - const comparison = this.compareReleaseTo(clause.expected); - switch (clause.operator) { + const comparison = this.compareReleaseTo(expectedVersion); + switch (operator) { case '==': return comparison === 0; case '!=': @@ -226,7 +193,7 @@ export class PythonVersion { return ( comparison >= 0 && this.matchesReleaseComponents( - clause.expected.releaseComponents.slice(0, clause.expected.releaseComponentCount - 1), + expectedVersion.releasePrefix(expectedVersion.releaseComponentCount - 1), ) ); default: @@ -235,21 +202,23 @@ export class PythonVersion { } private compareReleaseTo(other: PythonVersion): number { - for (let index = 0; index < this.releaseComponents.length; index++) { - const comparison = compareNumbers(this.releaseComponents[index], other.releaseComponents[index]); - if (comparison !== 0) { - return comparison; - } - } - return 0; + return ( + compareNumbers(this.major, other.major) || + compareNumbers(this.minor, other.minor) || + compareNumbers(this.patch, other.patch) + ); } - private get releaseComponents(): readonly number[] { - return [this.major, this.minor, this.patch]; + private releasePrefix(length: number): readonly number[] { + return [this.major, this.minor, this.patch].slice(0, length); } private matchesReleaseComponents(expected: readonly number[]): boolean { - return expected.every((component, index) => component === this.releaseComponents[index]); + return ( + expected[0] === this.major && + (expected.length < 2 || expected[1] === this.minor) && + (expected.length < 3 || expected[2] === this.patch) + ); } private static parseWildcard(wildcard: unknown): number[] | undefined { diff --git a/src/test/common/pythonVersion.unit.test.ts b/src/test/common/pythonVersion.unit.test.ts index d8488375..fcee8689 100644 --- a/src/test/common/pythonVersion.unit.test.ts +++ b/src/test/common/pythonVersion.unit.test.ts @@ -33,24 +33,24 @@ suite('PythonVersion', () => { assert.ok(new PythonVersion('3.14.0rc1').compareTo(new PythonVersion('3.14.0')) < 0); }); - test('satisfies release-prefix wildcards', () => { + test('satisfies release-prefix wildcard specifiers', () => { const version = new PythonVersion('3.14.0b1'); - assert.strictEqual(version.satisfiesWildcard('3.*'), true); - assert.strictEqual(version.satisfiesWildcard('3.14.*'), true); - assert.strictEqual(version.satisfiesWildcard('3.14.0.*'), true); - assert.strictEqual(version.satisfiesWildcard('3.13.*'), false); - assert.strictEqual(version.satisfiesWildcard('4.*'), false); + assert.strictEqual(version.satisfies('==3.*'), true); + assert.strictEqual(version.satisfies('==3.14.*'), true); + assert.strictEqual(version.satisfies('==3.14.0.*'), true); + assert.strictEqual(version.satisfies('==3.13.*'), false); + assert.strictEqual(version.satisfies('==4.*'), false); }); test('rejects malformed wildcards without throwing', () => { const version = new PythonVersion('3.14.0'); - assert.strictEqual(version.satisfiesWildcard('*'), false); - assert.strictEqual(version.satisfiesWildcard('3.*.0'), false); - assert.strictEqual(version.satisfiesWildcard('>=3.14.*'), false); - assert.strictEqual(version.satisfiesWildcard(`${Number.MAX_SAFE_INTEGER}0.*`), false); - assert.strictEqual(version.satisfiesWildcard(undefined), false); + assert.strictEqual(version.satisfies('==*'), undefined); + assert.strictEqual(version.satisfies('==3.*.0'), undefined); + assert.strictEqual(version.satisfies('>=3.14.*'), undefined); + assert.strictEqual(version.satisfies(`==${Number.MAX_SAFE_INTEGER}0.*`), undefined); + assert.strictEqual(version.satisfies(undefined), undefined); }); test('satisfies ordered and compound specifiers', () => { @@ -90,19 +90,22 @@ suite('PythonVersion', () => { test('rejects malformed specifiers without throwing', () => { const version = new PythonVersion('3.12.4'); - assert.strictEqual(version.satisfies(''), false); - assert.strictEqual(version.satisfies('3.12'), false); - assert.strictEqual(version.satisfies('>=3.12.*'), false); - assert.strictEqual(version.satisfies(`!=${Number.MAX_SAFE_INTEGER}0.*`), false); - assert.strictEqual(version.satisfies('~=3'), false); - assert.strictEqual(version.satisfies(undefined), false); + assert.strictEqual(version.satisfies(''), undefined); + assert.strictEqual(version.satisfies('3.12'), undefined); + assert.strictEqual(version.satisfies('>=3.12.*'), undefined); + assert.strictEqual(version.satisfies(`!=${Number.MAX_SAFE_INTEGER}0.*`), undefined); + assert.strictEqual(version.satisfies('~=3'), undefined); + assert.strictEqual(version.satisfies('>=3.11,'), undefined); + assert.strictEqual(version.satisfies('>=3.11,,<4'), undefined); + assert.strictEqual(version.satisfies(undefined), undefined); }); test('distinguishes invalid specifiers from valid non-matches', () => { const version = new PythonVersion('3.12.4'); - assert.strictEqual(version.matchSpecifier('>=3.13'), false); - assert.strictEqual(version.matchSpecifier('>=3.12.*'), undefined); + assert.strictEqual(version.satisfies('>=3.13'), false); + assert.strictEqual(version.satisfies('>=3.12.*'), undefined); + assert.strictEqual(version.satisfies('>=3.13,invalid'), undefined); }); test('rejects versions that cannot be compared safely', () => { From 1c1bbd6ba9e98ea30749b5252336376699597c68 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Mon, 31 Aug 2026 19:09:32 -0400 Subject: [PATCH 9/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/test/managers/common/utils.sortEnvironments.unit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/managers/common/utils.sortEnvironments.unit.test.ts b/src/test/managers/common/utils.sortEnvironments.unit.test.ts index c668fc87..58643f90 100644 --- a/src/test/managers/common/utils.sortEnvironments.unit.test.ts +++ b/src/test/managers/common/utils.sortEnvironments.unit.test.ts @@ -16,7 +16,7 @@ suite('sortEnvironments', () => { ); }); - test('sorts prereleases before their final release', () => { + test('sorts final releases before prereleases', () => { const versions = ['3.14.0b2', '3.14.0', '3.14.0rc1', '3.14.0a1']; const environments = versions.map((version) => createMockPythonEnvironment({ envPath: path.join('python', version), version }),