diff --git a/src/common/inlineScript/interpreter.ts b/src/common/inlineScript/interpreter.ts index 15316892..14a8bc9f 100644 --- a/src/common/inlineScript/interpreter.ts +++ b/src/common/inlineScript/interpreter.ts @@ -3,7 +3,8 @@ import { PythonEnvironment } from '../../api'; import { traceWarn } from '../logging'; -import { compareReleaseSegments, parseReleaseSegments } from '../utils/pep440Release'; +import { PythonVersion } from '../pythonVersion'; +import { splitClause } from '../pythonVersionSpecifier'; import { matchesPythonVersion } from './metadata'; /** @@ -26,12 +27,14 @@ export function pickCompatibleInterpreter( ): PythonEnvironment | undefined { const trimmedConstraint = requiresPython?.trim(); const constraint = trimmedConstraint ? trimmedConstraint : undefined; - const candidates = installed.filter((env) => isUsableBaseInterpreter(env, constraint)); + const candidates = installed.flatMap((env) => { + const version = isUsableBaseInterpreter(env, constraint) ? PythonVersion.tryParse(env.version) : undefined; + return version ? [{ env, version }] : []; + }); if (candidates.length === 0) { return undefined; } - const sorted = [...candidates].sort((a, b) => compareVersionsDescending(a.version, b.version)); - return sorted[0]; + return candidates.sort((a, b) => b.version.compareTo(a.version))[0].env; } /** @@ -62,15 +65,15 @@ export function extractLowerBoundVersion(requiresPython: string | undefined): st return undefined; } - let best: number[] | undefined; + let best: PythonVersion | undefined; let bestStr: string | undefined; for (const clause of clauses) { const lb = lowerBoundForClause(clause); if (lb === undefined) { continue; } - if (best === undefined || compareReleaseSegments(lb.segments, best) > 0) { - best = lb.segments; + if (best === undefined || lb.version.compareTo(best) > 0) { + best = lb.version; bestStr = lb.display; } } @@ -84,7 +87,7 @@ function isUsableBaseInterpreter(env: PythonEnvironment, requiresPython: string if (typeof env.version !== 'string' || env.version.length === 0) { return false; } - if (parseLeadingMajor(env.version) !== 3) { + if (PythonVersion.tryParse(env.version)?.major !== 3) { return false; } if (requiresPython !== undefined && !matchesPythonVersion(requiresPython, env.version)) { @@ -93,105 +96,45 @@ function isUsableBaseInterpreter(env: PythonEnvironment, requiresPython: string return true; } -function parseLeadingMajor(version: string): number | undefined { - const m = version.match(/^\s*(\d+)/); - if (!m) { - return undefined; - } - const n = Number.parseInt(m[1], 10); - return Number.isNaN(n) ? undefined : n; -} - -function compareVersionsDescending(a: string, b: string): number { - const aSeg = parseReleaseSegments(a); - const bSeg = parseReleaseSegments(b); - if (aSeg === undefined && bSeg === undefined) { - return 0; - } - if (aSeg === undefined) { - return 1; - } - if (bSeg === undefined) { - return -1; - } - return compareReleaseSegments(bSeg, aSeg); -} - -const CLAUSE_RE = /^(===|~=|==|!=|>=|<=|>|<)\s*(.+)$/; - interface LowerBound { - readonly segments: number[]; + readonly version: PythonVersion; readonly display: string; } function lowerBoundForClause(clause: string): LowerBound | undefined { - const m = clause.match(CLAUSE_RE); - if (!m) { + const parts = splitClause(clause); + if (!parts) { traceWarn(`inline-script interpreter: unrecognized requires-python clause: ${JSON.stringify(clause)}`); return undefined; } - const op = m[1]; - const raw = m[2].trim(); + const { operator, literal } = parts; - switch (op) { - case '>=': { - // Per PEP 440 wildcards are only legal with `==` / `!=`. Stay - // consistent with matchesPythonVersion (which rejects `>=X.*`) - // so we never hand uv a value the picker will then reject. - if (raw.endsWith('.*')) { - traceWarn( - `inline-script interpreter: wildcards are only valid with '==' / '!=': ${JSON.stringify(clause)}`, - ); - return undefined; - } - const segments = parseReleaseSegments(raw); - if (segments === undefined) { - return undefined; - } - return { segments, display: segmentsToString(segments) }; - } - case '==': { - const literal = raw.endsWith('.*') ? raw.slice(0, -2) : raw; - const segments = parseReleaseSegments(literal); - if (segments === undefined) { - return undefined; - } - return { segments, display: segmentsToString(segments) }; - } - case '~=': { - // PEP 440 requires at least two release segments and disallows - // wildcards for `~=`. Both rejections mirror matchesPythonVersion. - if (raw.endsWith('.*')) { - traceWarn( - `inline-script interpreter: wildcards are only valid with '==' / '!=': ${JSON.stringify(clause)}`, - ); - return undefined; - } - const segments = parseReleaseSegments(raw); - if (segments === undefined) { - return undefined; - } - if (segments.length < 2) { - traceWarn( - `inline-script interpreter: '~=' requires at least two release segments: ${JSON.stringify(clause)}`, - ); - return undefined; - } - return { segments, display: segmentsToString(segments) }; - } - case '>': - case '<': - case '<=': - case '!=': - case '===': - // No clean integer floor we can hand to `uv python install`. - // Caller falls back to uv default and re-verifies post-install. - return undefined; - default: - return undefined; + // Only the operators that establish a floor yield an install target. The + // rest leave no clean integer floor for `uv python install`, so the caller + // falls back to the uv default and re-verifies after installing. + if (operator !== '>=' && operator !== '==' && operator !== '~=') { + return undefined; + } + + // Per PEP 440 wildcards are only legal with `==` / `!=`. Stay consistent + // with matchesPythonVersion (which rejects `>=X.*`) so we never hand uv a + // value the picker will then reject. + if (literal.endsWith('.*') && operator !== '==') { + traceWarn(`inline-script interpreter: wildcards are only valid with '==' / '!=': ${JSON.stringify(clause)}`); + return undefined; + } + + const version = PythonVersion.tryParse(literal.endsWith('.*') ? literal.slice(0, -2) : literal); + if (!version) { + return undefined; + } + + // PEP 440 requires at least two release segments for `~=`, mirroring + // matchesPythonVersion. + if (operator === '~=' && version.precision < 2) { + traceWarn(`inline-script interpreter: '~=' requires at least two release segments: ${JSON.stringify(clause)}`); + return undefined; } -} -function segmentsToString(segments: ReadonlyArray): string { - return segments.join('.'); + return { version, display: version.toReleaseString() }; } diff --git a/src/common/inlineScript/metadata.ts b/src/common/inlineScript/metadata.ts index b44277a3..d8ff4040 100644 --- a/src/common/inlineScript/metadata.ts +++ b/src/common/inlineScript/metadata.ts @@ -6,6 +6,7 @@ import * as fs from 'fs/promises'; import { Uri } from 'vscode'; import { traceVerbose, traceWarn } from '../logging'; import { PythonVersion } from '../pythonVersion'; +import { PythonVersionSpecifier } from '../pythonVersionSpecifier'; /** * Parsed and validated PEP 723 `script` metadata block. @@ -309,10 +310,10 @@ export function matchesPythonVersion(requiresPython: string, version: string): b traceWarn(`inline script metadata: cannot parse Python version: ${JSON.stringify(version)}`); return false; } - const result = parsedVersion.satisfies(requiresPython); - if (result === undefined) { + const parsedSpecifier = PythonVersionSpecifier.tryParse(requiresPython); + if (!parsedSpecifier) { traceWarn(`inline script metadata: invalid requires-python specifier: ${JSON.stringify(requiresPython)}`); return false; } - return result; + return parsedSpecifier.matches(parsedVersion); } diff --git a/src/common/pythonVersion.ts b/src/common/pythonVersion.ts index 9b3b7c6b..79a43cd6 100644 --- a/src/common/pythonVersion.ts +++ b/src/common/pythonVersion.ts @@ -1,56 +1,74 @@ type PythonReleaseLevel = 'alpha' | 'beta' | 'candidate' | 'final'; +/** Release levels from oldest to newest; the index of a level is its rank. */ +const RELEASE_LEVELS: readonly PythonReleaseLevel[] = ['alpha', 'beta', 'candidate', 'final']; + +/** + * Maps the abbreviated spellings onto the release level they name. + * + * Python reports `alpha`, `beta`, and `candidate`, while version strings + * abbreviate them as `a`, `b`, and `rc`. A release candidate may also be + * spelled `c`, `pre`, or `preview`. + */ +const RELEASE_LEVEL_ALIASES: Readonly> = { + a: 'alpha', + b: 'beta', + rc: 'candidate', + c: 'candidate', + pre: 'candidate', + preview: 'candidate', +}; + +/** + * Matches a release, optionally followed by a prerelease level and serial. + * + * Every spelling of a level is accepted in one alternation, so the dotted + * `sys.version_info` form and the compact and separated forms differ only in + * their optional `.`, `-`, or `_` separators. Longer spellings precede the + * abbreviations they start with, so `alpha` wins over `a`. + */ +const VERSION_PATTERN = + /^(?\d+)(?:\.(?\d+))?(?:\.(?\d+))?(?:[._-]?(?alpha|beta|candidate|final|preview|pre|rc|a|b|c)[._-]?(?\d+))?$/i; + +/** + * A Python interpreter release, such as `3.12.4` or `3.14.0rc1`. + * + * This models the versions Python reports for itself through + * `sys.version_info`. It deliberately omits the PEP 440 packaging features + * that interpreters never use, such as epochs, post releases, dev releases, + * and local version labels; use a dedicated PEP 440 implementation for + * package versions. + */ 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 + * Missing minor and patch components are normalized to zero. Every + * spelling of a prerelease is normalized, so `3.14.0.beta.1`, + * `3.14.0beta1`, `3.14.0-beta-1`, and `3.14.0b1` are all represented as * `3.14.0b1`. * * @param version A Python release version. + * @throws TypeError When the version cannot be parsed. */ constructor(version: string) { - const normalizedVersion = version.trim(); - const match = PythonVersion.VERSION_PATTERN.exec(normalizedVersion); - if (!match) { + const source = version.trim(); + const groups = VERSION_PATTERN.exec(source)?.groups; + if (!groups) { 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) { + this.source = source; + this.precision = groups.patch !== undefined ? 3 : groups.minor !== undefined ? 2 : 1; + this.major = Number(groups.major); + this.minor = Number(groups.minor ?? 0); + this.patch = Number(groups.patch ?? 0); + this.releaseLevel = toReleaseLevel(groups.level); + this.releaseSerial = Number(groups.serial ?? 0); + if ( + ![this.major, this.minor, this.patch, this.releaseSerial].every(Number.isSafeInteger) || + (this.releaseLevel === 'final' && this.releaseSerial !== 0) + ) { throw new TypeError(`Invalid Python version: ${version}`); } } @@ -60,8 +78,16 @@ export class PythonVersion { readonly patch: number; readonly releaseLevel: PythonReleaseLevel; readonly releaseSerial: number; - private readonly original: string; - private readonly releaseComponentCount: number; + + /** + * How many release components were explicitly supplied: `1` for `3`, `2` + * for `3.12`, and `3` for `3.12.1`. Omitted components are normalized to + * zero, so this is the only record of how precisely the version was stated. + */ + readonly precision: number; + + /** The trimmed version exactly as it was supplied, before normalization. */ + readonly source: string; /** * Attempts to parse a Python version without propagating malformed input errors. @@ -82,7 +108,10 @@ export class PythonVersion { } /** - * Compares this version with another normalized Python version. + * Compares this version with another Python version. + * + * Prereleases order before the final release of the same numeric release, + * so `3.14.0rc1` is older than `3.14.0`. * * @param other The version to compare against. * @returns A negative number when this version is older, zero when both @@ -90,50 +119,50 @@ export class PythonVersion { */ 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) + this.major - other.major || + this.minor - other.minor || + this.patch - other.patch || + RELEASE_LEVELS.indexOf(this.releaseLevel) - RELEASE_LEVELS.indexOf(other.releaseLevel) || + this.releaseSerial - other.releaseSerial ); } /** - * Tests whether this version satisfies a Python version specifier. + * Tests whether this version is selected by a partially specified version. * - * 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. + * A selector that omits release components matches any version sharing the + * components it does supply, so `3.12` selects `3.12.11`. A fully specified + * or prerelease selector must match exactly, so `3.14.0rc1` does not select + * `3.14.0`. * - * @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. + * @param selector The requested version. + * @returns Whether this version satisfies the request. */ - 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; - } + matchesSelector(selector: PythonVersion): boolean { + return selector.precision < 3 && selector.releaseLevel === 'final' + ? this.matchesReleasePrefix(selector) + : this.compareTo(selector) === 0; + } - let satisfiesAll = true; - for (const clause of clauses) { - const result = this.matchClause(clause); - if (result === undefined) { - return undefined; - } - satisfiesAll &&= result; - } - return satisfiesAll; + /** + * Reports whether this version shares the leading release components of + * another version, ignoring any components beyond the compared count. + * + * @param other The version supplying the components to compare. + * @param count How many leading components to compare, defaulting to the + * number `other` explicitly supplied. + */ + matchesReleasePrefix(other: PythonVersion, count: number = other.precision): boolean { + return ( + (count < 1 || this.major === other.major) && + (count < 2 || this.minor === other.minor) && + (count < 3 || this.patch === other.patch) + ); } - /** Returns the normalized Python version representation. */ + /** Returns the fully normalized version, such as `3.12.4` or `3.14.0rc1`. */ toString(): string { - const release = `${this.major}.${this.minor}.${this.patch}`; + const release = this.toReleaseString(3); switch (this.releaseLevel) { case 'alpha': return `${release}a${this.releaseSerial}`; @@ -146,107 +175,21 @@ export class PythonVersion { } } - 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; + /** + * Returns the numeric release components without any prerelease suffix. + * + * @param count How many components to emit, defaulting to the number that + * was explicitly supplied, so `3.12` renders as `3.12` rather than `3.12.0`. + */ + toReleaseString(count: number = this.precision): string { + return [this.major, this.minor, this.patch].slice(0, count).join('.'); } } -function parseNumericComponent(value: string | undefined, version: string): number { - const parsed = Number(value ?? 0); - if (!Number.isSafeInteger(parsed)) { - throw new TypeError(`Invalid Python version: ${version}`); +function toReleaseLevel(value: string | undefined): PythonReleaseLevel { + if (!value) { + return 'final'; } - return parsed; -} - -function compareNumbers(left: number, right: number): number { - return left === right ? 0 : left < right ? -1 : 1; + const normalized = value.toLowerCase(); + return RELEASE_LEVEL_ALIASES[normalized] ?? RELEASE_LEVELS.find((level) => level === normalized) ?? 'final'; } diff --git a/src/common/pythonVersionSpecifier.ts b/src/common/pythonVersionSpecifier.ts new file mode 100644 index 00000000..eb49d8f3 --- /dev/null +++ b/src/common/pythonVersionSpecifier.ts @@ -0,0 +1,166 @@ +import { PythonVersion } from './pythonVersion'; + +/** + * A single parsed clause of a version specifier. + * + * Clauses are parsed before any of them is evaluated so that operator syntax + * is validated in one place and the prerelease rule can be applied to the + * specifier as a whole. + */ +interface VersionClause { + /** Tests a version against this clause alone. */ + readonly matches: (version: PythonVersion) => boolean; + /** Whether this clause explicitly names a prerelease. */ + readonly allowsPrereleases: boolean; +} + +/** + * Splits a specifier clause into its operator and version literal. + * + * A leading `v` on the literal is dropped, since PEP 440 permits it on a + * specifier but a Python release version never carries one. + * + * @param clause A single clause such as `>=3.11` or `==3.12.*`. + * @returns The clause parts, or `undefined` when the clause is malformed. + */ +export function splitClause(clause: string): { readonly operator: string; readonly literal: string } | undefined { + const match = PythonVersionSpecifier.CLAUSE_PATTERN.exec(clause.trim()); + if (!match) { + return undefined; + } + + const literal = match[2].trim().replace(/^v/i, ''); + return literal ? { operator: match[1], literal } : undefined; +} + +/** The operators that accept any version and are decided purely by ordering. */ +const COMPARISONS: Readonly boolean>> = { + '==': (comparison) => comparison === 0, + '!=': (comparison) => comparison !== 0, + '>=': (comparison) => comparison >= 0, + '<=': (comparison) => comparison <= 0, + '>': (comparison) => comparison > 0, + '<': (comparison) => comparison < 0, +}; + +/** + * A parsed Python version specifier such as `>=3.11,<3.14` or `==3.12.*`. + * + * Supports the `==`, `!=`, `>=`, `<=`, `>`, `<`, `~=`, and `===` operators, + * comma-separated AND clauses, and terminal wildcards with `==` or `!=`. + * + * This models specifiers over Python *interpreter* releases. It deliberately + * omits the PEP 440 packaging features that interpreters never use, such as + * epochs, post releases, dev releases, and local version labels; use a + * dedicated PEP 440 implementation for package requirements. + */ +export class PythonVersionSpecifier { + static readonly CLAUSE_PATTERN = /^(===|~=|==|!=|>=|<=|>|<)\s*(.+)$/; + + private constructor(private readonly clauses: readonly VersionClause[]) {} + + /** + * Parses a version specifier. + * + * @param specifier A specifier such as `>=3.11,<3.14`. + * @returns The parsed specifier, or `undefined` when it is malformed. + */ + static tryParse(specifier: unknown): PythonVersionSpecifier | undefined { + if (typeof specifier !== 'string') { + return undefined; + } + + const clauses: VersionClause[] = []; + for (const text of specifier.split(',')) { + const clause = parseClause(text.trim()); + if (!clause) { + return undefined; + } + clauses.push(clause); + } + return new PythonVersionSpecifier(clauses); + } + + /** + * Tests whether a version satisfies every clause of this specifier. + * + * A prerelease only satisfies a specifier that itself names a prerelease, + * so `3.14.0rc1` does not satisfy `>=3.11` but does satisfy `>=3.14.0rc1`. + * An exclusive upper bound still rejects prereleases of its own release, + * so `3.14.0rc1` does not satisfy `>=3.13.0rc1,<3.14`. + * + * @param version The version to test. + */ + matches(version: PythonVersion): boolean { + if (!this.clauses.every((clause) => clause.matches(version))) { + return false; + } + return version.releaseLevel === 'final' || this.clauses.some((clause) => clause.allowsPrereleases); + } +} + +/** Parses and validates a single clause such as `>=3.11` or `==3.12.*`. */ +function parseClause(clause: string): VersionClause | undefined { + const parts = splitClause(clause); + if (!parts) { + return undefined; + } + + const { operator, literal } = parts; + + // Arbitrary equality compares the versions as written rather than as parsed. + if (operator === '===') { + return { + matches: (version) => version.source === literal, + allowsPrereleases: PythonVersion.tryParse(literal)?.releaseLevel !== 'final', + }; + } + + // A wildcard compares only the release components preceding the `.*`, which + // is meaningless for the ordered operators and for a prerelease prefix. + if (literal.endsWith('.*')) { + if (operator !== '==' && operator !== '!=') { + return undefined; + } + const prefix = PythonVersion.tryParse(literal.slice(0, -2)); + if (!prefix || prefix.releaseLevel !== 'final') { + return undefined; + } + const negated = operator === '!='; + return { matches: (version) => version.matchesReleasePrefix(prefix) !== negated, allowsPrereleases: false }; + } + + const bound = PythonVersion.tryParse(literal); + if (!bound) { + return undefined; + } + const allowsPrereleases = bound.releaseLevel !== 'final'; + + // A compatible release is a lower bound that may not advance the component + // before the last one supplied, so `~=3.11.2` allows 3.11.10 but not 3.12. + if (operator === '~=') { + return bound.precision >= 2 + ? { + matches: (version) => + version.compareTo(bound) >= 0 && version.matchesReleasePrefix(bound, bound.precision - 1), + allowsPrereleases, + } + : undefined; + } + + const comparison = COMPARISONS[operator]; + if (!comparison) { + return undefined; + } + + // An exclusive upper bound never admits a prerelease of the bound itself, + // so `<3.14` rejects `3.14.0rc1` even when another clause names a + // prerelease, while `<3.14.0rc2` still admits it. + const excludesBoundPrereleases = operator === '<' && !allowsPrereleases; + return { + matches: (version) => + comparison(version.compareTo(bound)) && + !(excludesBoundPrereleases && version.releaseLevel !== 'final' && version.matchesReleasePrefix(bound, 3)), + allowsPrereleases, + }; +} diff --git a/src/common/utils/pep440Release.ts b/src/common/utils/pep440Release.ts deleted file mode 100644 index 8f0f59f6..00000000 --- a/src/common/utils/pep440Release.ts +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -import { clean as cleanPep440Version, explain as explainPep440Version } from '@renovatebot/pep440'; - -/** - * Convert a CPython `sys.version_info`-style version string to PEP 440. - * - * The native locator (`pet`) reports interpreter versions as - * `major.minor.micro.releaselevel.serial` — for example `"3.14.3.final.0"` or - * `"3.14.0.candidate.2"`. That shape is **not** valid PEP 440, so the - * `@renovatebot/pep440` helpers (`clean`, `satisfies`, …) reject it. This maps - * it to the PEP 440 equivalent (`"3.14.3"`, `"3.14.0rc2"`). - * - * The numeric release segments are preserved verbatim (no zero-padding, so - * `"3.14"` is never rewritten to `"3.14.0"`), and any string that does not - * match the `sys.version_info` shape is returned unchanged. - */ -export function normalizeCpythonVersionInfo(version: string): string { - const match = /^(\d+(?:\.\d+)*)\.(alpha|beta|candidate|final)\.(\d+)$/i.exec(version.trim()); - if (!match) { - return version; - } - const [, release, level, serial] = match; - switch (level.toLowerCase()) { - case 'alpha': - return `${release}a${serial}`; - case 'beta': - return `${release}b${serial}`; - case 'candidate': - return `${release}rc${serial}`; - case 'final': - default: - return release; - } -} - -/** - * Parse the release segments from a PEP 440 version string. - * - * Release segments are the dotted numeric components of a version, such as - * `[3, 12, 4]` for `3.12.4`. Leading/trailing whitespace, a leading `v`, and - * an epoch prefix are ignored. Pre-release, post-release, development, and - * local-version suffixes are intentionally omitted. CPython `sys.version_info` - * strings (e.g. `"3.14.3.final.0"`) are normalized via - * {@link normalizeCpythonVersionInfo} before parsing. - */ -export function parseReleaseSegments(version: string): number[] | undefined { - const normalized = cleanPep440Version(normalizeCpythonVersionInfo(version)); - return normalized ? (explainPep440Version(normalized)?.release ?? undefined) : undefined; -} - -/** - * Compare two PEP 440 release-segment arrays numerically. - * - * Missing trailing segments are treated as zero, so `3.12` and `3.12.0` - * compare as equal. Returns a negative number when `left` is older, zero when - * they are equal, and a positive number when `left` is newer. - */ -export function compareReleaseSegments(left: readonly number[], right: readonly number[]): number { - const length = Math.max(left.length, right.length); - for (let index = 0; index < length; index++) { - const leftSegment = left[index] ?? 0; - const rightSegment = right[index] ?? 0; - if (leftSegment < rightSegment) { - return -1; - } - if (leftSegment > rightSegment) { - return 1; - } - } - return 0; -} \ No newline at end of file diff --git a/src/managers/builtin/inlineScript/envManager.ts b/src/managers/builtin/inlineScript/envManager.ts index 6b4e10a5..30233c19 100644 --- a/src/managers/builtin/inlineScript/envManager.ts +++ b/src/managers/builtin/inlineScript/envManager.ts @@ -4,8 +4,17 @@ import * as fs from 'fs-extra'; import * as path from 'path'; import type { Stats } from 'fs'; -import { clean as cleanPep440, satisfies as satisfiesPep440 } from '@renovatebot/pep440'; -import { Disposable, Event, EventEmitter, l10n, LogOutputChannel, MarkdownString, Memento, ThemeIcon, Uri } from 'vscode'; +import { + Disposable, + Event, + EventEmitter, + l10n, + LogOutputChannel, + MarkdownString, + Memento, + ThemeIcon, + Uri, +} from 'vscode'; import { CreateEnvironmentOptions, CreateEnvironmentScope, @@ -69,29 +78,17 @@ import { sendTelemetryEvent } from '../../../common/telemetry/sender'; import { createDeferred, Deferred } from '../../../common/utils/deferred'; import { isFileNotFoundError } from '../../../common/utils/filesystem'; import { normalizePath } from '../../../common/utils/pathUtils'; -import { - compareReleaseSegments, - normalizeCpythonVersionInfo, - parseReleaseSegments, -} from '../../../common/utils/pep440Release'; +import { PythonVersion } from '../../../common/pythonVersion'; +import { PythonVersionSpecifier, splitClause } from '../../../common/pythonVersionSpecifier'; import { getVenvPythonPath } from '../../../common/utils/virtualEnvironment'; import { getOpenTextDocuments, onDidDeleteFiles, onDidRenameFiles } from '../../../common/workspace.apis'; import { NativePythonFinder } from '../../common/nativePythonFinder'; import { sortEnvironments } from '../../common/utils'; import { resolveSystemPythonEnvironmentPath } from '../utils'; import * as uvPythonInstaller from '../uvPythonInstaller'; -import { - createWithProgress, - hasMinimumPathDepth, - isDriveRoot, - resolveVenvPythonEnvironmentPath, -} from '../venvUtils'; +import { createWithProgress, hasMinimumPathDepth, isDriveRoot, resolveVenvPythonEnvironmentPath } from '../venvUtils'; -const BASE_INTERPRETER_MANAGER_IDS = new Set([ - SYSTEM_MANAGER_ID, - CONDA_MANAGER_ID, - PYENV_MANAGER_ID, -]); +const BASE_INTERPRETER_MANAGER_IDS = new Set([SYSTEM_MANAGER_ID, CONDA_MANAGER_ID, PYENV_MANAGER_ID]); const CACHE_LOCK_TIMEOUT_MS = 5 * 60 * 1000; const CACHE_LOCK_RETRY_MS = 500; @@ -252,20 +249,22 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { }), onDidDeleteFiles((event) => { void this.clearAssociationsForScripts(event.files).catch((error) => { - this.log.warn(`Failed to clear inline-script associations for deleted files: ${getErrorMessage(error)}`); + this.log.warn( + `Failed to clear inline-script associations for deleted files: ${getErrorMessage(error)}`, + ); }); }), onDidRenameFiles((event) => { void this.clearAssociationsForScripts(event.files.map((file) => file.oldUri)).catch((error) => { - this.log.warn(`Failed to clear inline-script associations for renamed files: ${getErrorMessage(error)}`); + this.log.warn( + `Failed to clear inline-script associations for renamed files: ${getErrorMessage(error)}`, + ); }); }), ); this.persistedAssociationsLoaded = this.loadPersistedAssociations(); void this.initializePersistedAssociations().catch((error) => { - this.log.warn( - `Failed to prime inline-script environment associations: ${getErrorMessage(error)}`, - ); + this.log.warn(`Failed to prime inline-script environment associations: ${getErrorMessage(error)}`); }); } @@ -290,12 +289,13 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return undefined; } - const packages = [ - ...(metadata.dependencies ?? []), - ...(options?.additionalPackages ?? []), - ].map((value) => value.trim()); + const packages = [...(metadata.dependencies ?? []), ...(options?.additionalPackages ?? [])].map( + (value) => value.trim(), + ); if (packages.some((value) => value.length === 0)) { - this.log.warn(`Inline-script dependencies must not contain empty entries: ${scriptUri.fsPath}.`); + this.log.warn( + `Inline-script dependencies must not contain empty entries: ${scriptUri.fsPath}.`, + ); return undefined; } @@ -336,7 +336,9 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { if (baseSelection.errorCategory) { this.sendInlineScriptEnvErrorTelemetry(baseSelection.errorCategory); } - this.log.warn(`No compatible Python is available for inline-script environment creation: ${scriptUri.fsPath}.`); + this.log.warn( + `No compatible Python is available for inline-script environment creation: ${scriptUri.fsPath}.`, + ); return undefined; } const selectedBase = baseSelection.selectedBase; @@ -566,9 +568,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } const pending = this.pendingRefresh; if (pending && pending !== sharedPass) { - return pending.checksForSnapshotChanges - ? pending.promise - : this.startSnapshotRefreshAfter(pending); + return pending.checksForSnapshotChanges ? pending.promise : this.startSnapshotRefreshAfter(pending); } return this.startRefreshPass(true); } @@ -730,10 +730,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return shouldRetry; } - private async inspectDiscoveredCacheEntry( - cacheRoot: Uri, - envDir: Uri, - ): Promise { + private async inspectDiscoveredCacheEntry(cacheRoot: Uri, envDir: Uri): Promise { let fingerprint: string | undefined; try { const stat = await fs.lstat(envDir.fsPath); @@ -742,9 +739,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return { kind: 'skip', fingerprint }; } } catch (error) { - return this.isDefinitivelyStalePathError(error) - ? { kind: 'skip' } - : { kind: 'preserve' }; + return this.isDefinitivelyStalePathError(error) ? { kind: 'skip' } : { kind: 'preserve' }; } if (await this.isCacheEntryBusy(envDir.fsPath)) { @@ -787,9 +782,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { this.baseManager, ); } catch (error) { - this.log.warn( - `Unable to resolve inline-script cache entry ${envDir.fsPath}: ${getErrorMessage(error)}`, - ); + this.log.warn(`Unable to resolve inline-script cache entry ${envDir.fsPath}: ${getErrorMessage(error)}`); return { kind: 'preserve', fingerprint }; } if (!environment) { @@ -814,7 +807,9 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { const previousByKey = new Map( this.collection.map((environment) => [this.getDiscoveredEnvironmentKey(environment), environment]), ); - const nextByKey = new Map(next.map((environment) => [this.getDiscoveredEnvironmentKey(environment), environment])); + const nextByKey = new Map( + next.map((environment) => [this.getDiscoveredEnvironmentKey(environment), environment]), + ); const changes: DidChangeEnvironmentsEventArgs = []; for (const [key, previous] of previousByKey) { @@ -922,7 +917,11 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { ? await this.resolveVerifiedSourceMetadataIdentity(script, environment, savedMetadata) : undefined; const nextPersistedAssociation = environmentPath - ? this.createPersistedAssociationRecord(environmentPath, sourceMetadataIdentity, savedMetadata?.identity) + ? this.createPersistedAssociationRecord( + environmentPath, + sourceMetadataIdentity, + savedMetadata?.identity, + ) : undefined; const needsPersistence = nextPersistedAssociation ? !this.isSamePersistedAssociation(persistedAssociation, nextPersistedAssociation) @@ -1008,11 +1007,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return undefined; } - return this.getAssociationForMetadata( - normalizePath(scope.fsPath), - scope, - metadata, - ); + return this.getAssociationForMetadata(normalizePath(scope.fsPath), scope, metadata); } private getScriptUris(scope: SetEnvironmentScope): ScriptReference[] { @@ -1048,11 +1043,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { const metadataIdentity = getInlineScriptMetadataRoutingIdentity(metadata)!; const forceFreshValidation = this.fsPathToPersistedAssociation.get(scriptPath)?.metadataBinding.kind === 'pending'; - if ( - pending && - pending.metadataIdentity === metadataIdentity && - pending.associationRevision === revision - ) { + if (pending && pending.metadataIdentity === metadataIdentity && pending.associationRevision === revision) { return pending.promise; } if (cached) { @@ -1087,13 +1078,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } } - const rehydration = this.rehydrateAssociation( - scriptPath, - scriptUri, - revision, - metadataIdentity, - metadata, - ); + const rehydration = this.rehydrateAssociation(scriptPath, scriptUri, revision, metadataIdentity, metadata); this.pendingRehydrations.set(scriptPath, { metadataIdentity, associationRevision: revision, @@ -1183,7 +1168,8 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return undefined; } const metadataIdentityProven = - !!sidecar && this.cacheEntryProvesSourceMetadataIdentity(sidecar, resolved, metadataIdentity, metadata); + !!sidecar && + this.cacheEntryProvesSourceMetadataIdentity(sidecar, resolved, metadataIdentity, metadata); if (!this.isCurrentAssociationRevision(scriptPath, revision)) { return this.fsPathToEnv.get(scriptPath); } @@ -1417,11 +1403,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } catch { return 'uncertain'; } - return inspectOwnedCacheEntry( - environment, - cacheRoot, - envDir, - ); + return inspectOwnedCacheEntry(environment, cacheRoot, envDir); } private async handleSavedMetadataChange(event: InlineScriptMetadataChangeEvent): Promise { @@ -1486,7 +1468,9 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { associationRevision: number, ): Promise { const environment = await this.getAssociationForMetadata(scriptPath, uri, metadata); - if (!this.isCurrentMetadataRefreshTask(uri, metadataIdentity, metadataRevision, scriptPath, associationRevision)) { + if ( + !this.isCurrentMetadataRefreshTask(uri, metadataIdentity, metadataRevision, scriptPath, associationRevision) + ) { return; } if (!environment) { @@ -1535,10 +1519,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { if (!this.isCurrentRoutingMetadata(uri, metadataIdentity, metadataRevision)) { return; } - if ( - bindResult === 'stale' && - !this.isCurrentAssociationRevision(scriptPath, associationRevision) - ) { + if (bindResult === 'stale' && !this.isCurrentAssociationRevision(scriptPath, associationRevision)) { const currentAssociation = this.fsPathToPersistedAssociation.get(scriptPath); const currentAssociationRevision = this.associationRevisions.get(scriptPath) ?? 0; if ( @@ -1630,10 +1611,14 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { metadata: InlineScriptMetadata, ): Promise { const sidecar = await this.readCurrentCacheEntrySidecar(environment); - return !!sidecar && this.cacheEntryProvesSourceMetadataIdentity(sidecar, environment, metadataIdentity, metadata); + return ( + !!sidecar && this.cacheEntryProvesSourceMetadataIdentity(sidecar, environment, metadataIdentity, metadata) + ); } - private async readCurrentCacheEntrySidecar(environment: PythonEnvironment): Promise { + private async readCurrentCacheEntrySidecar( + environment: PythonEnvironment, + ): Promise { let sidecarResult; try { sidecarResult = await inspectMetaJson(Uri.file(environment.sysPrefix)); @@ -1710,10 +1695,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { : undefined; } - private sidecarProvesSourceMetadataIdentity( - sidecar: InlineScriptEnvMeta, - metadataIdentity: string, - ): boolean { + private sidecarProvesSourceMetadataIdentity(sidecar: InlineScriptEnvMeta, metadataIdentity: string): boolean { if (sidecar.sourceMetadataIdentityHashes === undefined) { return false; } @@ -1776,7 +1758,9 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { environmentPath, metadataBinding: { kind: 'matched', sourceIdentity: metadataIdentity }, }; - if (!this.isSamePersistedAssociation(this.fsPathToPersistedAssociation.get(scriptPath), expectedAssociation)) { + if ( + !this.isSamePersistedAssociation(this.fsPathToPersistedAssociation.get(scriptPath), expectedAssociation) + ) { return 'stale'; } try { @@ -1797,7 +1781,10 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { ) { return 'stale'; } - return this.isSamePersistedAssociation(this.fsPathToPersistedAssociation.get(scriptPath), matchedAssociation) + return this.isSamePersistedAssociation( + this.fsPathToPersistedAssociation.get(scriptPath), + matchedAssociation, + ) ? 'bound' : 'stale'; }); @@ -1970,7 +1957,8 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { (change.expectedPersistedAssociation === undefined && (change.expectedEnvironmentPath === undefined || (current !== undefined && - normalizePath(current.environmentPath) === normalizePath(change.expectedEnvironmentPath)))) + normalizePath(current.environmentPath) === + normalizePath(change.expectedEnvironmentPath)))) ) { delete associations[change.scriptPath]; delete rawEntries[change.scriptPath]; @@ -2057,7 +2045,9 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return true; } - private parsePersistedAssociationValue(value: unknown): + private parsePersistedAssociationValue( + value: unknown, + ): | { readonly kind: 'valid'; readonly record: PersistedAssociationRecord } | { readonly kind: 'future' } | { readonly kind: 'invalid' } { @@ -2219,7 +2209,10 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { uri, scriptPath: normalizePath(uri.fsPath), })) - .filter((script, index, all) => all.findIndex((candidate) => candidate.scriptPath === script.scriptPath) === index) + .filter( + (script, index, all) => + all.findIndex((candidate) => candidate.scriptPath === script.scriptPath) === index, + ) .filter( (script) => this.fsPathToEnv.has(script.scriptPath) || @@ -2262,10 +2255,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return (this.associationRevisions.get(scriptPath) ?? 0) === revision; } - private isSameEnvironment( - first: PythonEnvironment | undefined, - second: PythonEnvironment | undefined, - ): boolean { + private isSameEnvironment(first: PythonEnvironment | undefined, second: PythonEnvironment | undefined): boolean { if (first === second) { return true; } @@ -2390,8 +2380,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { const installResult = await this.installPythonAndRefresh(requiresPython, versionSelection.version); if (installResult.kind !== 'installed') { return { - errorCategory: - installResult.kind === 'declined' ? 'compatible-python-declined' : 'install-failure', + errorCategory: installResult.kind === 'declined' ? 'compatible-python-declined' : 'install-failure', }; } const installedPath = installResult.installedPath; @@ -2449,15 +2438,15 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { if (prereleaseLowerBound) { return { version: prereleaseLowerBound }; } - const lowerBoundRelease = lowerBound ? parseReleaseSegments(lowerBound) : undefined; + const lowerBoundRelease = PythonVersion.tryParse(lowerBound); let needsCompleteCatalog = false; - if (lowerBound && lowerBoundRelease?.[0] === 3) { + if (lowerBound && lowerBoundRelease?.major === 3) { if (/^>=\s*[^,]+$/.test(requiresPython) && this.matchesInstallConstraint(requiresPython, lowerBound)) { return { version: lowerBound }; } // PEP 440 `==3.13` is exact, while uv treats `3.13` as a broad minor selector. if (/^==\s*[^,*]+$/.test(requiresPython) && this.matchesInstallConstraint(requiresPython, lowerBound)) { - if (lowerBoundRelease.length >= 3) { + if (lowerBoundRelease.precision >= 3) { return { version: lowerBound }; } needsCompleteCatalog = true; @@ -2472,8 +2461,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { ); if (uvLookupResult !== 'available') { return { - errorCategory: - uvLookupResult === 'declined' ? 'compatible-python-declined' : 'install-failure', + errorCategory: uvLookupResult === 'declined' ? 'compatible-python-declined' : 'install-failure', }; } available = needsCompleteCatalog @@ -2487,50 +2475,41 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return { errorCategory: 'install-failure' }; } const version = available - .filter( - (candidate) => + .flatMap((candidate) => { + const parsed = PythonVersion.tryParse(candidate.version); + return parsed && candidate.implementation === 'cpython' && candidate.variant === 'default' && candidate.version_parts.major === 3 && - this.matchesInstallConstraint(requiresPython, candidate.version), - ) - .sort((left, right) => { - const leftRelease = parseReleaseSegments(left.version); - const rightRelease = parseReleaseSegments(right.version); - if (!leftRelease || !rightRelease) { - return 0; - } - return compareReleaseSegments(rightRelease, leftRelease); - })[0]?.version; + this.matchesInstallConstraint(requiresPython, candidate.version) + ? [{ parsed, raw: candidate.version }] + : []; + }) + .sort((left, right) => right.parsed.compareTo(left.parsed))[0]?.raw; return version ? { version } : { errorCategory: 'no-compatible-python' }; } private matchesInstallConstraint(requiresPython: string, version: string): boolean { - try { - return satisfiesPep440(normalizeCpythonVersionInfo(version), requiresPython, { - prereleases: /(?:(?:a|alpha|b|beta|c|rc|pre|preview)[._-]?\d+|dev[._-]?\d+)/i.test( - requiresPython, - ), - }); - } catch (error) { - this.log.warn(`Unable to evaluate requires-python '${requiresPython}': ${getErrorMessage(error)}`); + const candidate = PythonVersion.tryParse(version); + const specifier = PythonVersionSpecifier.tryParse(requiresPython); + if (!candidate || !specifier) { + this.log.warn(`Unable to evaluate requires-python '${requiresPython}' against version '${version}'.`); return false; } + return specifier.matches(candidate); } private extractPrereleaseLowerBound(requiresPython: string): string | undefined { return requiresPython .split(',') - .map((clause) => - clause - .trim() - .match( - /^(?:>=|==|~=)\s*(\d+(?:\.\d+)*(?:(?:a|alpha|b|beta|c|rc|pre|preview)[._-]?\d+|[._-]?dev[._-]?\d+))$/i, - )?.[1], + .map((clause) => splitClause(clause)) + .filter( + (clause) => + clause && (clause.operator === '>=' || clause.operator === '==' || clause.operator === '~='), ) - .map((version) => (version ? cleanPep440(version) : undefined)) - .filter((version): version is string => !!version) - .find((version) => this.matchesInstallConstraint(requiresPython, version)); + .map((clause) => PythonVersion.tryParse(clause?.literal)) + .find((version): version is PythonVersion => !!version && version.releaseLevel !== 'final') + ?.toString(); } private async installPythonAndRefresh( @@ -2574,10 +2553,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { * this callback or reachable only through `createOrReuseEnvironment`, * which invokes it under this lock. */ - private async withCacheEntryLock( - envDir: Uri, - action: (lock: AcquiredFileLock) => Promise, - ): Promise { + private async withCacheEntryLock(envDir: Uri, action: (lock: AcquiredFileLock) => Promise): Promise { const lock = await acquireFileLock(envDir.fsPath, { timeoutMs: CACHE_LOCK_TIMEOUT_MS, retryIntervalMs: CACHE_LOCK_RETRY_MS, @@ -2654,13 +2630,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { try { await fs.ensureDir(cacheRoot.fsPath); return await this.withCacheEntryLock(envDir, async (lock) => { - const cached = await this.inspectCacheEntry( - cacheRoot, - envDir, - metadata, - selectedBase, - pendingCreation, - ); + const cached = await this.inspectCacheEntry(cacheRoot, envDir, metadata, selectedBase, pendingCreation); if (cached.kind === 'reusable') { this.sendInlineScriptEnvReuseHitTelemetry(dependencyCount); return cached.environment; @@ -2680,13 +2650,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } const buildStartAtMs = Date.now(); - const build = await this.buildCacheEntry( - envDir, - cacheRoot, - packages, - selectedBase, - pendingCreation, - ); + const build = await this.buildCacheEntry(envDir, cacheRoot, packages, selectedBase, pendingCreation); if (build.retainLock) { try { await lock.retain(); @@ -2810,7 +2774,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { private matchesSelectedBase(sidecar: InlineScriptEnvMeta, selectedBase: SelectedBaseInterpreter): boolean { return ( normalizePath(sidecar.baseInterpreterPath) === normalizePath(selectedBase.canonicalPath) && - sidecar.baseInterpreterVersion === selectedBase.environment.version + this.areEqualPythonReleases(sidecar.baseInterpreterVersion, selectedBase.environment.version) ); } @@ -3000,20 +2964,14 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } this.replaceDiscoveredEnvironments( - this.collection.filter( - (environment) => !removedCacheEntries.has(normalizePath(environment.sysPrefix)), - ), + this.collection.filter((environment) => !removedCacheEntries.has(normalizePath(environment.sysPrefix))), ); const invalidatedScriptPaths = await this.getInvalidatedAssociationPaths( scriptPaths, persistedAssociations, removedCacheEntries, ); - await this.clearInvalidatedAssociations( - invalidatedScriptPaths, - persistedAssociations, - priorSelections, - ); + await this.clearInvalidatedAssociations(invalidatedScriptPaths, persistedAssociations, priorSelections); } private async isCacheEntryDefinitelyMissing(entryPath: string): Promise { @@ -3024,9 +2982,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { if (isFileNotFoundError(error)) { return true; } - this.log.warn( - `Unable to verify stale inline-script cache entry ${entryPath}: ${getErrorMessage(error)}`, - ); + this.log.warn(`Unable to verify stale inline-script cache entry ${entryPath}: ${getErrorMessage(error)}`); return false; } } @@ -3079,11 +3035,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { for (const entryName of cacheEntryNames) { try { - const removed = await this.removeCacheEntryForClear( - cacheRoot, - physicalCacheRootPath, - entryName, - ); + const removed = await this.removeCacheEntryForClear(cacheRoot, physicalCacheRootPath, entryName); if (removed) { removedCacheEntries.add(normalizePath(removed)); } @@ -3127,23 +3079,16 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { const envDirPath = path.join(originalPhysicalCacheRootPath, entryName); let lock: AcquiredFileLock | undefined; try { - lock = await this.acquireCacheEntryLockForClear( - envDirPath, - options.reclaimRetainedLock !== false, - ); + lock = await this.acquireCacheEntryLockForClear(envDirPath, options.reclaimRetainedLock !== false); const currentPhysicalCacheRootPath = await this.getPhysicalOwnedCacheRootPath(cacheRoot); if (!currentPhysicalCacheRootPath) { return undefined; } - if ( - normalizePath(currentPhysicalCacheRootPath) !== normalizePath(originalPhysicalCacheRootPath) - ) { + if (normalizePath(currentPhysicalCacheRootPath) !== normalizePath(originalPhysicalCacheRootPath)) { const message = l10n.t( 'Refusing to clear the script environment cache because its physical root changed during cleanup.', ); - this.log.error( - `${message} (${originalPhysicalCacheRootPath} -> ${currentPhysicalCacheRootPath})`, - ); + this.log.error(`${message} (${originalPhysicalCacheRootPath} -> ${currentPhysicalCacheRootPath})`); throw new Error(message); } @@ -3198,9 +3143,10 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } private isLockContentionError(error: unknown): boolean { - const code = typeof error === 'object' && error !== null && 'code' in error - ? (error as NodeJS.ErrnoException).code - : undefined; + const code = + typeof error === 'object' && error !== null && 'code' in error + ? (error as NodeJS.ErrnoException).code + : undefined; return code === 'ELOCKED' || code === 'ELOCKRETAINED'; } @@ -3230,7 +3176,10 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { private async getPhysicalOwnedCacheRootPath(cacheRoot: Uri): Promise { const globalStoragePath = path.resolve(this.globalStorageUri.fsPath); const cacheRootPath = path.resolve(cacheRoot.fsPath); - if (path.basename(cacheRootPath) !== INLINE_SCRIPT_CACHE_DIR_NAME || normalizePath(path.dirname(cacheRootPath)) !== normalizePath(globalStoragePath)) { + if ( + path.basename(cacheRootPath) !== INLINE_SCRIPT_CACHE_DIR_NAME || + normalizePath(path.dirname(cacheRootPath)) !== normalizePath(globalStoragePath) + ) { this.log.error(`Refusing to clear inline-script cache from unsafe root: ${cacheRootPath}`); throw new Error(l10n.t('Refusing to clear the script environment cache from an unsafe cache root.')); } @@ -3250,9 +3199,13 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } if (!globalStorageStat.isDirectory() || globalStorageStat.isSymbolicLink()) { - this.log.error(`Refusing to clear inline-script cache from redirected globalStorage root: ${globalStoragePath}`); + this.log.error( + `Refusing to clear inline-script cache from redirected globalStorage root: ${globalStoragePath}`, + ); throw new Error( - l10n.t('Refusing to clear the script environment cache because the global storage root is not a normal directory.'), + l10n.t( + 'Refusing to clear the script environment cache because the global storage root is not a normal directory.', + ), ); } @@ -3269,7 +3222,9 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { if (!cacheRootStat.isDirectory() || cacheRootStat.isSymbolicLink()) { this.log.error(`Refusing to clear inline-script cache from redirected cache root: ${cacheRootPath}`); throw new Error( - l10n.t('Refusing to clear the script environment cache because the cache root is not a normal directory.'), + l10n.t( + 'Refusing to clear the script environment cache because the cache root is not a normal directory.', + ), ); } @@ -3286,7 +3241,9 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } this.log.error(`Failed to resolve inline-script cache root physically: ${getErrorMessage(error)}`); throw new Error( - l10n.t('Refusing to clear the script environment cache because its physical location could not be verified.'), + l10n.t( + 'Refusing to clear the script environment cache because its physical location could not be verified.', + ), ); } @@ -3319,7 +3276,9 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { if (!stat.isDirectory() || stat.isSymbolicLink()) { this.log.error(`Refusing to clear inline-script cache entry from unsafe path: ${entryPath}`); throw new Error( - l10n.t('Refusing to clear the script environment cache because a cache entry is not a normal directory.'), + l10n.t( + 'Refusing to clear the script environment cache because a cache entry is not a normal directory.', + ), ); } @@ -3327,7 +3286,9 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { if (!resolvedEntryPath) { this.log.error(`Refusing to clear inline-script cache entry outside the expected root: ${entryPath}`); throw new Error( - l10n.t('Refusing to clear the script environment cache because a cache entry is outside the expected root.'), + l10n.t( + 'Refusing to clear the script environment cache because a cache entry is outside the expected root.', + ), ); } @@ -3463,9 +3424,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return this.parsePersistedAssociations(await this.associationStore.read())?.records ?? {}; } - private getTrackedScriptPaths( - persistedAssociations: PersistedInlineScriptEnvironments, - ): Set { + private getTrackedScriptPaths(persistedAssociations: PersistedInlineScriptEnvironments): Set { return new Set([ ...Object.keys(persistedAssociations), ...this.associationRevisions.keys(), @@ -3479,12 +3438,8 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { ]); } - private getPriorSelections( - scriptPaths: ReadonlySet, - ): Map { - return new Map( - Array.from(scriptPaths, (scriptPath) => [scriptPath, this.fsPathToEnv.get(scriptPath)]), - ); + private getPriorSelections(scriptPaths: ReadonlySet): Map { + return new Map(Array.from(scriptPaths, (scriptPath) => [scriptPath, this.fsPathToEnv.get(scriptPath)])); } private async removeCacheEntry(envDir: Uri): Promise { @@ -3510,12 +3465,9 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } private areEqualPythonReleases(actual: string, expected: string): boolean { - const actualRelease = parseReleaseSegments(actual); - const expectedRelease = parseReleaseSegments(expected); - if (actualRelease === undefined || expectedRelease === undefined) { - return false; - } - return compareReleaseSegments(actualRelease, expectedRelease) === 0; + const actualVersion = PythonVersion.tryParse(actual); + const expectedVersion = PythonVersion.tryParse(expected); + return !!actualVersion && !!expectedVersion && actualVersion.compareTo(expectedVersion) === 0; } private getTelemetryDependencyCount(packages: ReadonlyArray): number { diff --git a/src/managers/builtin/pipPackageManager.ts b/src/managers/builtin/pipPackageManager.ts index 388526aa..291d452e 100644 --- a/src/managers/builtin/pipPackageManager.ts +++ b/src/managers/builtin/pipPackageManager.ts @@ -1,5 +1,5 @@ import type { Pep440Version } from '@renovatebot/pep440'; -import { compare, explain as parse } from '@renovatebot/pep440'; +import { compare } from '@renovatebot/pep440'; import { CancellationError, CancellationToken, @@ -24,6 +24,7 @@ import { PythonEnvironmentApi, } from '../../api'; import { showErrorMessageWithLogs } from '../../common/errors/utils'; +import { PythonVersion } from '../../common/pythonVersion'; import { showErrorMessage, withProgress } from '../../common/window.apis'; import { CommandConstructorOptions } from '../base/commands/index'; import { updatePackagesAndNotify } from '../common/packageChanges'; @@ -88,7 +89,7 @@ export class PipPackageManager implements PackageManager, Disposable { } } - if (environment.version.startsWith('2.')) { + if (PythonVersion.tryParse(environment.version)?.major === 2) { throw new Error('Python 2.* is not supported (deprecated)'); } @@ -262,14 +263,11 @@ export class PipPackageManager implements PackageManager, Disposable { throw new Error(`Python executable is unavailable for environment: ${environment.envId.id}`); } - // Normalize versions like '3.13.1.final.0' (Python's sys.version_info format) to '3.13.1' - // before parsing, since pep440 only accepts valid PEP 440 version strings. - const versionMatch = (environment.version ?? '').match(/^(\d+(?:\.\d+)*)/); - const normalizedVersion = versionMatch?.[1] ?? ''; - const baseVersion = parse(normalizedVersion)?.base_version; - if (!baseVersion) { + const pythonVersion = PythonVersion.tryParse(environment.version); + if (!pythonVersion) { throw new Error(`Python version is unavailable for environment: ${environment.envId.id}`); } + const baseVersion = pythonVersion.toReleaseString(); const availableVersions = await createPipOrUvCommandWithKind( { pythonExecutable, log: this.log }, diff --git a/src/managers/builtin/uvPythonInstaller.ts b/src/managers/builtin/uvPythonInstaller.ts index e57991dd..bf9654ef 100644 --- a/src/managers/builtin/uvPythonInstaller.ts +++ b/src/managers/builtin/uvPythonInstaller.ts @@ -12,6 +12,7 @@ import { spawnProcess } from '../../common/childProcess.apis'; import { Common, UvInstallStrings } from '../../common/localize'; import { traceError, traceInfo, traceLog, traceWarn } from '../../common/logging'; import { getGlobalPersistentState } from '../../common/persistentState'; +import { PythonVersion } from '../../common/pythonVersion'; import { executeTask, onDidEndTaskProcess } from '../../common/tasks.apis'; import { EventNames } from '../../common/telemetry/constants'; import { sendTelemetryEvent } from '../../common/telemetry/sender'; @@ -258,9 +259,14 @@ export async function getUvPythonPath(version?: string): Promise (v.version === version || v.version.startsWith(`${version}.`)) && v.path, - ); + const requested = PythonVersion.tryParse(version); + const match = requested + ? versions.find( + (candidate) => + candidate.path && + PythonVersion.tryParse(candidate.version)?.matchesSelector(requested), + ) + : undefined; resolve(match?.path ?? undefined); } else { // Return the first (latest) installed Python diff --git a/src/managers/builtin/venvManager.ts b/src/managers/builtin/venvManager.ts index 6dcda4df..3d785962 100644 --- a/src/managers/builtin/venvManager.ts +++ b/src/managers/builtin/venvManager.ts @@ -26,6 +26,7 @@ import { VenvManagerStrings } from '../../common/localize'; import { traceError, traceWarn } from '../../common/logging'; import { createDeferred, Deferred } from '../../common/utils/deferred'; import { normalizePath } from '../../common/utils/pathUtils'; +import { PythonVersion } from '../../common/pythonVersion'; import { showErrorMessage, showInformationMessage, withProgress } from '../../common/window.apis'; import { findParentIfFile } from '../../features/envCommands'; import { getProjectFsPathForScope, tryFastPathGet } from '../common/fastPath'; @@ -103,7 +104,7 @@ export class VenvManager implements EnvironmentManager { * Returns configuration for quick create in the workspace root, undefined if no suitable Python 3 version is found. */ quickCreateConfig(): QuickCreateConfig | undefined { - if (!this.globalEnv || !this.globalEnv.version.startsWith('3.')) { + if (!this.globalEnv || PythonVersion.tryParse(this.globalEnv.version)?.major !== 3) { return undefined; } return { @@ -149,7 +150,7 @@ export class VenvManager implements EnvironmentManager { // Re-fetch environments after refresh globals = await this.api.getEnvironments('global'); // Update globalEnv reference if we found any Python 3.x environments - const python3Envs = globals.filter((e) => e.version.startsWith('3.')); + const python3Envs = globals.filter((e) => PythonVersion.tryParse(e.version)?.major === 3); if (python3Envs.length === 0) { this.log.warn('Python installed via uv but no Python 3.x global environments were detected.'); } else { @@ -166,7 +167,7 @@ export class VenvManager implements EnvironmentManager { showErrorMessage(VenvManagerStrings.venvErrorNoBasePython); throw new Error('No base python found'); } - if (!this.globalEnv.version.startsWith('3.')) { + if (PythonVersion.tryParse(this.globalEnv.version)?.major !== 3) { this.log.error('Did not find any base python 3.*'); globals.forEach((e, i) => { this.log.error(`${i}: ${e.version} : ${e.environmentPath.fsPath}`); @@ -174,7 +175,7 @@ export class VenvManager implements EnvironmentManager { showErrorMessage(VenvManagerStrings.venvErrorNoPython3); throw new Error('Did not find any base python 3.*'); } - if (this.globalEnv && this.globalEnv.version.startsWith('3.')) { + if (this.globalEnv && PythonVersion.tryParse(this.globalEnv.version)?.major === 3) { // quick create given correct information result = await quickCreateVenv( this.nativeFinder, diff --git a/src/managers/builtin/venvUtils.ts b/src/managers/builtin/venvUtils.ts index e3582586..fac901cf 100644 --- a/src/managers/builtin/venvUtils.ts +++ b/src/managers/builtin/venvUtils.ts @@ -22,6 +22,7 @@ import { ENVS_EXTENSION_ID } from '../../common/constants'; import { Common, VenvManagerStrings } from '../../common/localize'; import { traceInfo, traceVerbose } from '../../common/logging'; import { getWorkspacePersistentState } from '../../common/persistentState'; +import { PythonVersion } from '../../common/pythonVersion'; import { EventNames } from '../../common/telemetry/constants'; import { sendTelemetryEvent } from '../../common/telemetry/sender'; import { normalizePath } from '../../common/utils/pathUtils'; @@ -441,7 +442,7 @@ export function ensureGlobalEnv(basePythons: PythonEnvironment[], log: LogOutput throw new Error('No base python found'); } - const filtered = basePythons.filter((e) => e.version.startsWith('3.')); + const filtered = basePythons.filter((e) => PythonVersion.tryParse(e.version)?.major === 3); if (filtered.length === 0) { log.error('Did not find any base python 3.*'); showErrorMessage(VenvManagerStrings.venvErrorNoPython3); diff --git a/src/managers/common/utils.ts b/src/managers/common/utils.ts index 7446779b..ea11d707 100644 --- a/src/managers/common/utils.ts +++ b/src/managers/common/utils.ts @@ -1,4 +1,3 @@ -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'; @@ -25,16 +24,14 @@ export function isNumber(obj: unknown): obj is number { /** * Returns a short display string: "X.Y.Z" if micro is present, otherwise "X.Y.x". - * Returns `input` unchanged if it is not a valid PEP 440 version. + * Returns `input` unchanged if it is not a valid Python interpreter version. */ export function shortenVersionString(input: string): string { - if (!pep440Valid(input)) { + const version = PythonVersion.tryParse(input); + if (!version) { return input; } - const p = patch(input); - return p !== 0 || input.split('.').length >= 3 - ? `${major(input)}.${minor(input)}.${p}` - : `${major(input)}.${minor(input)}.x`; + return version.precision >= 3 ? version.toReleaseString(3) : `${version.toReleaseString(2)}.x`; } export function sortEnvironments(collection: PythonEnvironment[]): PythonEnvironment[] { diff --git a/src/managers/conda/condaStepBasedFlow.ts b/src/managers/conda/condaStepBasedFlow.ts index 60cc4bc3..0a3e5034 100644 --- a/src/managers/conda/condaStepBasedFlow.ts +++ b/src/managers/conda/condaStepBasedFlow.ts @@ -1,4 +1,3 @@ -import { compare as pep440Compare, valid as pep440Valid } from '@renovatebot/pep440'; import * as fse from 'fs-extra'; import * as path from 'path'; import { l10n, LogOutputChannel, QuickInputButtons, QuickPickItem, Uri } from 'vscode'; @@ -8,9 +7,9 @@ import { showInputBoxWithButtons, showQuickPickWithButtons } from '../../common/ import { createNamedCondaEnvironment, createPrefixCondaEnvironment, + getPythonVersionsForCreation, getLocation, getName, - trimVersionToMajorMinor, } from './condaUtils'; // Recommended Python version for Conda environments @@ -105,22 +104,7 @@ async function selectPythonVersion(state: CondaCreationState): Promise env.version) - .filter(Boolean) - .map((v: string) => trimVersionToMajorMinor(v)), // cut to 3 digits - ), - ); - - // Sort versions descending using PEP 440 comparison - versions = versions.sort((a, b) => { - if (!pep440Valid(a as string) || !pep440Valid(b as string)) { - return 0; - } - return pep440Compare(b as string, a as string); // descending - }); + let versions = getPythonVersionsForCreation(envs); if (!versions || versions.length === 0) { versions = ['3.13', '3.12', '3.11', '3.10', '3.9']; diff --git a/src/managers/conda/condaUtils.ts b/src/managers/conda/condaUtils.ts index eee067ce..9034a8e0 100644 --- a/src/managers/conda/condaUtils.ts +++ b/src/managers/conda/condaUtils.ts @@ -1,4 +1,3 @@ -import { compare as pep440Compare, valid as pep440Valid } from '@renovatebot/pep440'; import * as fse from 'fs-extra'; import * as os from 'os'; import * as path from 'path'; @@ -30,6 +29,7 @@ import { Common, CondaStrings, PackageManagement, Pickers } from '../../common/l import { traceError, traceInfo, traceVerbose, traceWarn } from '../../common/logging'; import { getWorkspacePersistentState } from '../../common/persistentState'; import { pickProject } from '../../common/pickers/projects'; +import { PythonVersion } from '../../common/pythonVersion'; import { StopWatch } from '../../common/stopWatch'; import { createDeferred } from '../../common/utils/deferred'; import { untildify } from '../../common/utils/pathUtils'; @@ -952,31 +952,26 @@ export async function getLocation(api: PythonEnvironmentApi, uris: Uri | Uri[]): } const RECOMMENDED_CONDA_PYTHON = '3.11.11'; -export function trimVersionToMajorMinor(version: string): string { - const match = version.match(/^(\d+\.\d+\.\d+)/); - return match ? match[1] : version; +/** + * Returns normalized, unique Python interpreter releases sorted newest first. + * + * @param environments Environments whose interpreter versions should be listed. + * @returns Valid Python releases in descending version order. + */ +export function getPythonVersionsForCreation(environments: ReadonlyArray): string[] { + const versions = environments + .map((environment) => PythonVersion.tryParse(environment.version)) + .filter((version): version is PythonVersion => !!version) + .sort((left, right) => right.compareTo(left)) + .map((version) => (version.releaseLevel === 'final' ? version.toReleaseString() : version.toString())); + return [...new Set(versions)]; } export async function pickPythonVersion( api: PythonEnvironmentApi, token?: CancellationToken, ): Promise { const envs = await api.getEnvironments('global'); - let versions = Array.from( - new Set( - envs - .map((env) => env.version) - .filter(Boolean) - .map((v) => trimVersionToMajorMinor(v)), // cut to 3 digits - ), - ); - - // Sort versions descending using PEP 440 comparison - versions = versions.sort((a, b) => { - if (!pep440Valid(a) || !pep440Valid(b)) { - return 0; - } - return pep440Compare(b, a); // descending - }); + let versions = getPythonVersionsForCreation(envs); if (!versions || versions.length === 0) { versions = ['3.13', '3.12', '3.11', '3.10', '3.9']; diff --git a/src/test/common/inlineScript/interpreter.unit.test.ts b/src/test/common/inlineScript/interpreter.unit.test.ts index c1002ae2..37039d59 100644 --- a/src/test/common/inlineScript/interpreter.unit.test.ts +++ b/src/test/common/inlineScript/interpreter.unit.test.ts @@ -163,13 +163,16 @@ suite('inlineScriptInterpreter', () => { assert.strictEqual(picked.version, '3.12.4'); }); - test('ranks versions with pre-release / dev / local suffixes by release segments only', () => { - // 3.12.0a1 and 3.12.0.dev1 both parse to [3,12,0]; stable sort - // means the first-listed 3.12 entry wins. - const envs = [makeEnv('3.12.0a1'), makeEnv('3.11.0'), makeEnv('3.12.0.dev1')]; + test('ranks supported prereleases and rejects package-only version suffixes', () => { + const envs = [ + makeEnv('3.12.0rc1'), + makeEnv('3.12.0'), + makeEnv('3.13.0.dev1'), + makeEnv('3.14.0+local'), + ]; const picked = pickCompatibleInterpreter(envs, undefined); assert.ok(picked); - assert.strictEqual(picked.version, '3.12.0a1'); + assert.strictEqual(picked.version, '3.12.0'); }); }); diff --git a/src/test/common/inlineScript/metadata.unit.test.ts b/src/test/common/inlineScript/metadata.unit.test.ts index 73310a05..a87a0ad0 100644 --- a/src/test/common/inlineScript/metadata.unit.test.ts +++ b/src/test/common/inlineScript/metadata.unit.test.ts @@ -462,14 +462,15 @@ suite('inlineScriptMetadata', () => { assert.strictEqual(matchesPythonVersion('===3.11.0', '3.11'), false); }); - test('input version with pre/dev suffix is truncated to release', () => { - assert.strictEqual(matchesPythonVersion('>=3.11', '3.11.0rc1'), true); + test('prereleases require an explicitly prerelease-compatible specifier', () => { + assert.strictEqual(matchesPythonVersion('>=3.11', '3.11.0rc1'), false); + assert.strictEqual(matchesPythonVersion('>=3.11.0rc1', '3.11.0rc1'), true); 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); assert.strictEqual(matchesPythonVersion('<3.14', '3.14.0b1'), false); }); diff --git a/src/test/common/pythonVersion.unit.test.ts b/src/test/common/pythonVersion.unit.test.ts index fcee8689..22a7fcea 100644 --- a/src/test/common/pythonVersion.unit.test.ts +++ b/src/test/common/pythonVersion.unit.test.ts @@ -19,6 +19,22 @@ suite('PythonVersion', () => { assert.strictEqual(new PythonVersion('3.15.0rc1').toString(), '3.15.0rc1'); }); + test('normalizes every prerelease spelling and separator', () => { + assert.strictEqual(new PythonVersion('3.14.0alpha1').toString(), '3.14.0a1'); + assert.strictEqual(new PythonVersion('3.14.0beta1').toString(), '3.14.0b1'); + assert.strictEqual(new PythonVersion('3.14.0candidate1').toString(), '3.14.0rc1'); + assert.strictEqual(new PythonVersion('3.14.0-alpha-1').toString(), '3.14.0a1'); + assert.strictEqual(new PythonVersion('3.14.0_alpha_1').toString(), '3.14.0a1'); + assert.strictEqual(new PythonVersion('3.14.0.alpha.1').toString(), '3.14.0a1'); + assert.strictEqual(new PythonVersion('3.14.0ALPHA1').toString(), '3.14.0a1'); + }); + + test('treats every release-candidate alias as the same level', () => { + for (const alias of ['rc1', 'c1', 'pre1', 'preview1', 'candidate1']) { + assert.strictEqual(new PythonVersion(`3.14.0${alias}`).toString(), '3.14.0rc1', alias); + } + }); + 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); @@ -26,86 +42,49 @@ suite('PythonVersion', () => { 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'); + test('orders prereleases before the final release of the same numeric release', () => { + const prerelease = new PythonVersion('3.14.0rc1'); + const final = new PythonVersion('3.14.0'); - 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); + assert.strictEqual(prerelease.compareTo(final), -1); + assert.strictEqual(final.compareTo(prerelease), 1); }); - 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', () => { + test('preserves release precision for formatting and selector matching', () => { 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); + assert.strictEqual(new PythonVersion('3').toReleaseString(), '3'); + assert.strictEqual(new PythonVersion('3.12').toReleaseString(), '3.12'); + assert.strictEqual(version.toReleaseString(), '3.12.4'); + assert.strictEqual(new PythonVersion('3.14.0rc1').toReleaseString(), '3.14.0'); + assert.strictEqual(version.matchesSelector(new PythonVersion('3.12')), true); + assert.strictEqual(new PythonVersion('3.120.1').matchesSelector(new PythonVersion('3.12')), false); + assert.strictEqual(version.matchesSelector(new PythonVersion('3.12.4')), true); + assert.strictEqual(version.matchesSelector(new PythonVersion('3.12.5')), false); + assert.strictEqual(new PythonVersion('3.14.0').matchesSelector(new PythonVersion('3.14.0rc1')), 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('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 malformed specifiers without throwing', () => { - const version = new PythonVersion('3.12.4'); + test('preserves the version as supplied alongside the normalized form', () => { + const version = new PythonVersion(' 3.14.0.beta.1 '); - 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); + assert.strictEqual(version.source, '3.14.0.beta.1'); + assert.strictEqual(version.toString(), '3.14.0b1'); }); - test('distinguishes invalid specifiers from valid non-matches', () => { + test('compares a bounded number of leading release components', () => { 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); + assert.strictEqual(version.matchesReleasePrefix(new PythonVersion('3.12')), true); + assert.strictEqual(version.matchesReleasePrefix(new PythonVersion('3.12.5')), false); + assert.strictEqual(version.matchesReleasePrefix(new PythonVersion('3.12.5'), 2), true); + assert.strictEqual(version.matchesReleasePrefix(new PythonVersion('3.13.4'), 1), true); }); test('rejects versions that cannot be compared safely', () => { diff --git a/src/test/common/pythonVersionSpecifier.unit.test.ts b/src/test/common/pythonVersionSpecifier.unit.test.ts new file mode 100644 index 00000000..c7d3e002 --- /dev/null +++ b/src/test/common/pythonVersionSpecifier.unit.test.ts @@ -0,0 +1,116 @@ +import assert from 'node:assert'; +import { PythonVersion } from '../../common/pythonVersion'; +import { PythonVersionSpecifier } from '../../common/pythonVersionSpecifier'; + +/** + * Evaluates a specifier against a version, yielding `undefined` when the + * specifier is malformed so that invalid input stays distinguishable from a + * valid non-match. + */ +function matches(version: string, specifier: unknown): boolean | undefined { + return PythonVersionSpecifier.tryParse(specifier)?.matches(new PythonVersion(version)); +} + +suite('PythonVersionSpecifier', () => { + test('matches release-prefix wildcard specifiers', () => { + assert.strictEqual(matches('3.14.0', '==3.*'), true); + assert.strictEqual(matches('3.14.0', '==3.14.*'), true); + assert.strictEqual(matches('3.14.0', '==3.14.0.*'), true); + assert.strictEqual(matches('3.14.0', '==3.13.*'), false); + assert.strictEqual(matches('3.14.0', '==4.*'), false); + }); + + test('rejects malformed wildcards without throwing', () => { + assert.strictEqual(matches('3.14.0', '==*'), undefined); + assert.strictEqual(matches('3.14.0', '==3.*.0'), undefined); + assert.strictEqual(matches('3.14.0', '>=3.14.*'), undefined); + assert.strictEqual(matches('3.14.0', '==3.14.0.0.*'), undefined); + assert.strictEqual(matches('3.14.0', `==${Number.MAX_SAFE_INTEGER}0.*`), undefined); + assert.strictEqual(matches('3.14.0', undefined), undefined); + }); + + test('rejects a wildcard over a prerelease prefix', () => { + assert.strictEqual(matches('3.14.0b1', '==3.14.0b1.*'), undefined); + assert.strictEqual(matches('3.14.0b1', '!=3.14.0b1.*'), undefined); + }); + + test('matches ordered and compound specifiers', () => { + assert.strictEqual(matches('3.12.4', '>=3.11'), true); + assert.strictEqual(matches('3.12.4', '>=3.10,<3.13'), true); + assert.strictEqual(matches('3.12.4', '>=3.13'), false); + assert.strictEqual(matches('3.12.4', '<=3.12.4'), true); + assert.strictEqual(matches('3.12.4', '>3.12.4'), false); + }); + + test('matches equality and wildcard specifiers', () => { + assert.strictEqual(matches('3.12.4', '==3.12.4'), true); + assert.strictEqual(matches('3.12.4', '!=3.12.3'), true); + assert.strictEqual(matches('3.12.4', '==3.12.*'), true); + assert.strictEqual(matches('3.12.4', '!=3.12.*'), false); + assert.strictEqual(matches('3.12.4', '==3.11.*'), false); + }); + + test('matches compatible-release specifiers', () => { + assert.strictEqual(matches('3.12.4', '~=3.11'), true); + assert.strictEqual(matches('4.0.0', '~=3.11'), false); + assert.strictEqual(matches('3.11.10', '~=3.11.2'), true); + assert.strictEqual(matches('3.12.0', '~=3.11.2'), false); + }); + + test('supports arbitrary equality and release equality', () => { + assert.strictEqual(matches('3.11', '==3.11.0'), true); + assert.strictEqual(matches('3.11', '===3.11'), true); + assert.strictEqual(matches('3.11', '===3.11.0'), false); + }); + + test('excludes prereleases unless the specifier names one', () => { + assert.strictEqual(matches('3.11.0rc1', '>=3.11'), false); + assert.strictEqual(matches('3.11.0rc1', '>=3.11.0rc1'), true); + assert.strictEqual(matches('3.14.0b1', '==3.14.*'), false); + }); + + test('excludes prereleases of an exclusive upper bound', () => { + assert.strictEqual(matches('3.14.0rc1', '>=3.13.0rc1,<3.14'), false); + assert.strictEqual(matches('3.13.5rc1', '>=3.13.0rc1,<3.14'), true); + assert.strictEqual(matches('3.14.0rc1', '>=3.13.0rc1,<3.14.0rc2'), true); + assert.strictEqual(matches('3.14.0rc1', '>=3.13.0rc1,<=3.14'), true); + assert.strictEqual(matches('3.13.9', '>=3.13,<3.14'), true); + }); + + test('accepts a leading v on the literal', () => { + assert.strictEqual(matches('3.12.4', '>=v3.11'), true); + assert.strictEqual(matches('3.12.4', '==v3.12.*'), true); + assert.strictEqual(matches('3.12.4', '>=v'), undefined); + }); + + test('rejects malformed specifiers without throwing', () => { + assert.strictEqual(matches('3.12.4', ''), undefined); + assert.strictEqual(matches('3.12.4', '3.12'), undefined); + assert.strictEqual(matches('3.12.4', '>=3.12.*'), undefined); + assert.strictEqual(matches('3.12.4', `!=${Number.MAX_SAFE_INTEGER}0.*`), undefined); + assert.strictEqual(matches('3.12.4', '~=3'), undefined); + assert.strictEqual(matches('3.12.4', '>=3.11,'), undefined); + assert.strictEqual(matches('3.12.4', '>=3.11,,<4'), undefined); + assert.strictEqual(matches('3.12.4', undefined), undefined); + }); + + test('distinguishes invalid specifiers from valid non-matches', () => { + assert.strictEqual(matches('3.12.4', '>=3.13'), false); + assert.strictEqual(matches('3.12.4', '>=3.12.*'), undefined); + assert.strictEqual(matches('3.12.4', '>=3.13,invalid'), undefined); + }); + + test('validates every clause before reporting a non-match', () => { + assert.strictEqual(PythonVersionSpecifier.tryParse('>=3.13,invalid'), undefined); + assert.ok(PythonVersionSpecifier.tryParse('>=3.10,<3.13')); + }); + + test('reuses a parsed specifier across versions', () => { + const specifier = PythonVersionSpecifier.tryParse('>=3.11,<3.14'); + assert.ok(specifier); + + assert.strictEqual(specifier.matches(new PythonVersion('3.12.4')), true); + assert.strictEqual(specifier.matches(new PythonVersion('3.10.0')), false); + assert.strictEqual(specifier.matches(new PythonVersion('3.14.0')), false); + }); +}); diff --git a/src/test/common/utils/pep440Release.unit.test.ts b/src/test/common/utils/pep440Release.unit.test.ts deleted file mode 100644 index 2c09e655..00000000 --- a/src/test/common/utils/pep440Release.unit.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -import assert from 'assert'; -import { - compareReleaseSegments, - normalizeCpythonVersionInfo, - parseReleaseSegments, -} from '../../../common/utils/pep440Release'; - -suite('pep440Release', () => { - suite('normalizeCpythonVersionInfo', () => { - test('rewrites a final sys.version_info string to its release', () => { - assert.strictEqual(normalizeCpythonVersionInfo('3.14.3.final.0'), '3.14.3'); - }); - - test('rewrites prerelease sys.version_info strings to PEP 440 prereleases', () => { - assert.strictEqual(normalizeCpythonVersionInfo('3.14.0.alpha.1'), '3.14.0a1'); - assert.strictEqual(normalizeCpythonVersionInfo('3.14.0.beta.2'), '3.14.0b2'); - assert.strictEqual(normalizeCpythonVersionInfo('3.14.0.candidate.3'), '3.14.0rc3'); - }); - - test('trims surrounding whitespace before matching', () => { - assert.strictEqual(normalizeCpythonVersionInfo(' 3.14.3.final.0 '), '3.14.3'); - }); - - test('does not zero-pad the release segments', () => { - assert.strictEqual(normalizeCpythonVersionInfo('3.14.final.0'), '3.14'); - }); - - test('returns non-version_info strings unchanged', () => { - assert.strictEqual(normalizeCpythonVersionInfo('3.14.3'), '3.14.3'); - assert.strictEqual(normalizeCpythonVersionInfo('3.13'), '3.13'); - assert.strictEqual(normalizeCpythonVersionInfo('3.14.0rc2'), '3.14.0rc2'); - assert.strictEqual(normalizeCpythonVersionInfo('>=3.11'), '>=3.11'); - assert.strictEqual(normalizeCpythonVersionInfo('3.12.not-a-version'), '3.12.not-a-version'); - }); - }); - - suite('parseReleaseSegments', () => { - test('parses dotted numeric release segments', () => { - assert.deepStrictEqual(parseReleaseSegments('3.12.4'), [3, 12, 4]); - }); - - test('parses CPython sys.version_info release strings', () => { - assert.deepStrictEqual(parseReleaseSegments('3.14.3.final.0'), [3, 14, 3]); - assert.deepStrictEqual(parseReleaseSegments('3.14.0.candidate.2'), [3, 14, 0]); - }); - - test('does not zero-pad release segments (keeps uv install targets intact)', () => { - assert.deepStrictEqual(parseReleaseSegments('3.13'), [3, 13]); - }); - - test('ignores syntax outside the release segments', () => { - assert.deepStrictEqual(parseReleaseSegments(' v2!3.12.4rc1.post2.dev3+local '), [3, 12, 4]); - }); - - test('returns undefined when no release segment is present', () => { - assert.strictEqual(parseReleaseSegments('not-a-version'), undefined); - }); - - test('returns undefined for an invalid PEP 440 suffix', () => { - assert.strictEqual(parseReleaseSegments('3.12.not-a-version'), undefined); - }); - }); - - suite('compareReleaseSegments', () => { - test('pads missing trailing segments with zero', () => { - assert.strictEqual(compareReleaseSegments([3, 12], [3, 12, 0]), 0); - }); - - test('compares each segment numerically', () => { - assert.ok(compareReleaseSegments([3, 12, 10], [3, 12, 9]) > 0); - assert.ok(compareReleaseSegments([3, 11, 9], [3, 12]) < 0); - }); - }); -}); \ No newline at end of file diff --git a/src/test/managers/builtin/uvPythonInstaller.unit.test.ts b/src/test/managers/builtin/uvPythonInstaller.unit.test.ts index 1c1f2dd5..cb1a4744 100644 --- a/src/test/managers/builtin/uvPythonInstaller.unit.test.ts +++ b/src/test/managers/builtin/uvPythonInstaller.unit.test.ts @@ -631,6 +631,32 @@ suite('uvPythonInstaller - getUvPythonPath', () => { assert.strictEqual(result, '/usr/bin/python3.1', 'Should not mistake Python 3.13 for Python 3.1'); }); + test('should require an exact match for a prerelease version', async () => { + const versions: UvPythonVersion[] = [ + makeUvPythonVersion({ version: '3.14.0', path: '/usr/bin/python3.14' }), + makeUvPythonVersion({ version: '3.14.0rc1', path: '/usr/bin/python3.14-rc1' }), + ]; + + const mockProcess = new MockChildProcess('uv', [ + 'python', + 'list', + '--only-installed', + '--managed-python', + '--output-format', + 'json', + ]); + spawnStub.returns(mockProcess); + + const resultPromise = getUvPythonPath('3.14.0rc1'); + + setTimeout(() => { + mockProcess.stdout?.emit('data', JSON.stringify(versions)); + mockProcess.emit('exit', 0, null); + }, 10); + + assert.strictEqual(await resultPromise, '/usr/bin/python3.14-rc1'); + }); + test('should return undefined when specified version is not found', async () => { const versions: UvPythonVersion[] = [makeUvPythonVersion({ version: '3.13.1', path: '/usr/bin/python3.13' })]; diff --git a/src/test/managers/conda/condaUtils.pythonVersions.unit.test.ts b/src/test/managers/conda/condaUtils.pythonVersions.unit.test.ts new file mode 100644 index 00000000..b659983e --- /dev/null +++ b/src/test/managers/conda/condaUtils.pythonVersions.unit.test.ts @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'node:assert'; +import { Uri } from 'vscode'; +import { PythonEnvironment } from '../../../api'; +import { getPythonVersionsForCreation } from '../../../managers/conda/condaUtils'; + +function makeEnvironment(version: string): PythonEnvironment { + return { + envId: { id: version, managerId: 'ms-python.python:system' }, + name: version, + displayName: version, + displayPath: version, + version, + environmentPath: Uri.file(version), + execInfo: { run: { executable: version } }, + sysPrefix: version, + }; +} + +suite('getPythonVersionsForCreation', () => { + test('normalizes, deduplicates, and sorts valid interpreter versions', () => { + const versions = getPythonVersionsForCreation([ + makeEnvironment('3.9.20'), + makeEnvironment('3.14.0rc1'), + makeEnvironment('3.12.8.final.0'), + makeEnvironment('3.12.8'), + makeEnvironment('not-a-version'), + ]); + + assert.deepStrictEqual(versions, ['3.14.0rc1', '3.12.8', '3.9.20']); + }); +});