diff --git a/src/common/inlineScript/metadata.ts b/src/common/inlineScript/metadata.ts index 45f6d34b9..b44277a36 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.satisfies(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 new file mode 100644 index 000000000..9b3b7c6b7 --- /dev/null +++ b/src/common/pythonVersion.ts @@ -0,0 +1,252 @@ +type PythonReleaseLevel = 'alpha' | 'beta' | 'candidate' | 'final'; + +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', + 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, + }; + + /** + * 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 normalizedVersion = version.trim(); + const match = PythonVersion.VERSION_PATTERN.exec(normalizedVersion); + if (!match) { + throw new TypeError(`Invalid Python version: ${version}`); + } + + const groups = match.groups!; + this.original = normalizedVersion; + 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}`); + } + } + + readonly major: number; + readonly minor: number; + 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. + * + * @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 ( + this.compareReleaseTo(other) || + compareNumbers( + PythonVersion.RELEASE_LEVEL_ORDER[this.releaseLevel], + PythonVersion.RELEASE_LEVEL_ORDER[other.releaseLevel], + ) || + compareNumbers(this.releaseSerial, other.releaseSerial) + ); + } + + /** + * 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 Whether every clause matches, or `undefined` when the specifier is invalid. + */ + satisfies(specifier: unknown): boolean | undefined { + if (typeof specifier !== 'string') { + return undefined; + } + + 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. */ + 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; + } + } + + private static normalizeReleaseLevel(value: string | undefined): PythonReleaseLevel { + return value ? (PythonVersion.RELEASE_LEVEL_ALIASES[value.toLowerCase()] ?? 'final') : 'final'; + } + + private matchClause(clause: string): boolean | undefined { + const match = PythonVersion.SPECIFIER_PATTERN.exec(clause); + if (!match) { + return undefined; + } + + const operator = match[1]; + const expected = match[2].trim(); + if (operator === '===') { + return expected ? this.original.replace(/^v/i, '') === expected.replace(/^v/i, '') : undefined; + } + if (expected.endsWith('.*')) { + const wildcard = PythonVersion.parseWildcard(expected); + 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; + } + + 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 ( + comparison >= 0 && + this.matchesReleaseComponents( + expectedVersion.releasePrefix(expectedVersion.releaseComponentCount - 1), + ) + ); + default: + return false; + } + } + + private compareReleaseTo(other: PythonVersion): number { + return ( + compareNumbers(this.major, other.major) || + compareNumbers(this.minor, other.minor) || + compareNumbers(this.patch, other.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[0] === this.major && + (expected.length < 2 || expected[1] === this.minor) && + (expected.length < 3 || expected[2] === this.patch) + ); + } + + 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 { + 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; +} diff --git a/src/managers/common/utils.ts b/src/managers/common/utils.ts index ba7390c4d..7446779b1 100644 --- a/src/managers/common/utils.ts +++ b/src/managers/common/utils.ts @@ -1,9 +1,10 @@ -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'; 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'; @@ -46,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) { @@ -68,9 +77,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/common/inlineScript/metadata.unit.test.ts b/src/test/common/inlineScript/metadata.unit.test.ts index 87dd12575..73310a05d 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 new file mode 100644 index 000000000..fcee8689a --- /dev/null +++ b/src/test/common/pythonVersion.unit.test.ts @@ -0,0 +1,130 @@ +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('satisfies release-prefix wildcard specifiers', () => { + const version = new PythonVersion('3.14.0b1'); + + 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.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', () => { + 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(''), 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.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', () => { + 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); + } + }); + + 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); + }); +}); 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 000000000..aa623829a --- /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); + }); +}); 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 000000000..58643f90e --- /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 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 }), + ); + + 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]); + }); +});