From 153ce02ddaf8e087b350904785467b49b16fc7a7 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 3 Jun 2026 10:38:01 -0700 Subject: [PATCH 1/6] Handle PEP 440 Version PEP 440 describes a scheme for identifying versions of Python software distributions, and declaring dependencies on particular versions. As such, parsing and comparing are done in different parts of the codebase, leading to (potentially) diverging implementations and duplicated code. Introduces classes \PEP440Version\, \VersionConstraint\, \VersionSpecifier\ classes for parsing, representing and comparing versions to have a centralized way of handling those behaviors. --- package-lock.json | 34 ++- package.json | 7 +- src/common/utils/pep440Version.ts | 259 ++++++++++++++++++ src/common/utils/versionSpecifier.ts | 202 ++++++++++++++ src/test/common/pep440Version.unit.test.ts | 246 +++++++++++++++++ src/test/common/versionSpecifier.unit.test.ts | 193 +++++++++++++ 6 files changed, 930 insertions(+), 11 deletions(-) create mode 100644 src/common/utils/pep440Version.ts create mode 100644 src/common/utils/versionSpecifier.ts create mode 100644 src/test/common/pep440Version.unit.test.ts create mode 100644 src/test/common/versionSpecifier.unit.test.ts diff --git a/package-lock.json b/package-lock.json index 7fea10e9..579e67a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "@types/fs-extra": "^11.0.4", "@types/glob": "^8.1.0", "@types/mocha": "^10.0.1", - "@types/node": "^22.15.1", + "@types/node": "^22.19.19", "@types/sinon": "^17.0.3", "@types/stack-trace": "0.0.29", "@types/vscode": "^1.99.0", @@ -716,9 +716,9 @@ "integrity": "sha512-dJvrYWxP/UcXm36Qn36fxhUKu8A/xMRXVT2cliFF1Z7UA9liG5Psj3ezNSZw+5puH2czDXRLcXQxf8JbJt0ejg==" }, "node_modules/@types/node": { - "version": "22.19.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", - "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", "dev": true, "license": "MIT", "dependencies": { @@ -801,6 +801,7 @@ "integrity": "sha512-D7DbgGFtsqIPIFMPJwCad9Gfi/hC0PWErRRHFnaCWoEDYi5tQUDiJCTmGUbBiLzjqAck4KcXt9Ayj0CNlIrF+w==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.16.0", "@typescript-eslint/types": "8.16.0", @@ -1509,6 +1510,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1783,6 +1785,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2521,6 +2524,7 @@ "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4864,6 +4868,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5537,6 +5542,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5663,6 +5669,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -5711,6 +5718,7 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -6453,9 +6461,9 @@ "integrity": "sha512-dJvrYWxP/UcXm36Qn36fxhUKu8A/xMRXVT2cliFF1Z7UA9liG5Psj3ezNSZw+5puH2czDXRLcXQxf8JbJt0ejg==" }, "@types/node": { - "version": "22.19.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", - "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", "dev": true, "requires": { "undici-types": "~6.21.0" @@ -6516,6 +6524,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.16.0.tgz", "integrity": "sha512-D7DbgGFtsqIPIFMPJwCad9Gfi/hC0PWErRRHFnaCWoEDYi5tQUDiJCTmGUbBiLzjqAck4KcXt9Ayj0CNlIrF+w==", "dev": true, + "peer": true, "requires": { "@typescript-eslint/scope-manager": "8.16.0", "@typescript-eslint/types": "8.16.0", @@ -7023,7 +7032,8 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true + "dev": true, + "peer": true }, "acorn-import-phases": { "version": "1.0.4", @@ -7210,6 +7220,7 @@ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, + "peer": true, "requires": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7720,6 +7731,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, + "peer": true, "requires": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9401,6 +9413,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, + "peer": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -9857,7 +9870,8 @@ "version": "5.3.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", - "dev": true + "dev": true, + "peer": true }, "uc.micro": { "version": "1.0.6", @@ -9943,6 +9957,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, + "peer": true, "requires": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -9976,6 +9991,7 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, + "peer": true, "requires": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", diff --git a/package.json b/package.json index df79269f..9f61c380 100644 --- a/package.json +++ b/package.json @@ -124,7 +124,10 @@ "python-envs.workspaceSearchPaths": { "type": "array", "description": "%python-envs.workspaceSearchPaths.description%", - "default": [".venv", "*/.venv"], + "default": [ + ".venv", + "*/.venv" + ], "scope": "resource", "items": { "type": "string" @@ -692,7 +695,7 @@ "@types/fs-extra": "^11.0.4", "@types/glob": "^8.1.0", "@types/mocha": "^10.0.1", - "@types/node": "^22.15.1", + "@types/node": "^22.19.19", "@types/sinon": "^17.0.3", "@types/stack-trace": "0.0.29", "@types/vscode": "^1.99.0", diff --git a/src/common/utils/pep440Version.ts b/src/common/utils/pep440Version.ts new file mode 100644 index 00000000..ad24bbbb --- /dev/null +++ b/src/common/utils/pep440Version.ts @@ -0,0 +1,259 @@ +/** + * Represents a PEP 440 version. + * + * Format: [N!]N(.N)*[{a|b|rc}N][.postN][.devN][+local] + * See https://peps.python.org/pep-0440/ + */ + +/** Normalized pre-release phase. */ +export type PreReleasePhase = 'a' | 'b' | 'rc'; + +/** Raw pre-release labels accepted before normalization. */ +type PreReleaseLabelInput = 'a' | 'alpha' | 'b' | 'beta' | 'c' | 'rc' | 'pre' | 'preview'; + +/** + * PEP 440 version regex adapted from the Python `packaging` library. + * Captures: + * 1: epoch (e.g. "2") + * 2: release (e.g. "1.2.3") + * 3: pre-label (a|b|c|rc|alpha|beta|pre|preview) + * 4: pre-number + * 5: implicit post (e.g. "-1" form) + * 6: post-label (post|rev|r) + * 7: post-number + * 8: dev-label (dev) + * 9: dev-number + * 10: local (e.g. "ubuntu1") + */ +const PEP440_REGEX = + /^v?(?:([0-9]+)!)?([0-9]+(?:\.[0-9]+)*)(?:[-_.]?(a|b|c|rc|alpha|beta|pre|preview)[-_.]?([0-9]+)?)?(?:(?:-([0-9]+))|(?:[-_.]?(post|rev|r)[-_.]?([0-9]+)?))?(?:[-_.]?(dev)[-_.]?([0-9]+)?)?(?:\+([a-z0-9]+(?:[-_.][a-z0-9]+)*))?$/i; + +function normalizePreLabel(label: PreReleaseLabelInput): PreReleasePhase { + switch (label) { + case 'a': + case 'alpha': + return 'a'; + case 'b': + case 'beta': + return 'b'; + case 'c': + case 'rc': + case 'pre': + case 'preview': + return 'rc'; + } +} + +export class PEP440Version { + /** Version epoch, defaults to 0. */ + public readonly epoch: number; + /** Release segment numbers (e.g. [1, 2, 3] for "1.2.3"). */ + public readonly release: readonly number[]; + /** Pre-release phase: 'a', 'b', or 'rc', or undefined. */ + public readonly pre: PreReleasePhase | undefined; + /** Pre-release number (e.g. 2 in "rc2"), or undefined if no pre-release. */ + public readonly preNumber: number | undefined; + /** Post-release number, or undefined. */ + public readonly post: number | undefined; + /** Dev release number, or undefined. */ + public readonly dev: number | undefined; + /** Local version label (e.g. "ubuntu1"), or undefined. */ + public readonly local: string | undefined; + + /** + * @param release Release segment numbers (e.g. `[1, 2, 3]`). + * @param options Optional version segments. All inputs are normalized per PEP 440: + * - `pre`: alternate spellings (alpha, beta, c, preview, pre) are normalized to a/b/rc. + * - `preNumber`: defaults to 0 when `pre` is set. + * - `post`/`dev`: implicit number defaults to 0. + * - `local`: lowercased, separators (`-`, `_`) replaced with `.`. + * - `release`: trailing zero segments are trimmed (e.g. `[1, 0, 0]` → `[1]`). + */ + constructor( + release: readonly number[], + options?: { + epoch?: number; + pre?: PreReleaseLabelInput; + preNumber?: number; + post?: number; + dev?: number; + local?: string; + }, + ) { + this.epoch = options?.epoch ?? 0; + this.release = [...release]; + + // Normalize pre-release label and default number to 0 + this.pre = options?.pre !== undefined ? normalizePreLabel(options.pre) : undefined; + this.preNumber = options?.pre !== undefined ? (options?.preNumber ?? 0) : undefined; + + // Post and dev default to 0 when present (PEP 440: "1.0.post" == "1.0.post0") + this.post = options?.post; + this.dev = options?.dev; + + // Normalize local: lowercase, replace - and _ with . + this.local = options?.local?.toLowerCase().replace(/[-_]/g, '.'); + } + + /** The major version number (first element of release). */ + public get major(): number { + return this.release.length > 0 ? this.release[0] : 0; + } + + /** + * Parse a PEP 440 version string. Returns `undefined` if the string is not valid. + */ + public static parse(input: string): PEP440Version | undefined { + const match = PEP440_REGEX.exec(input.trim()); + if (!match) { + return undefined; + } + + const release = match[2].split('.').map((s) => parseInt(s, 10)); + + let pre: PreReleaseLabelInput | undefined; + let preNumber: number | undefined; + if (match[3] !== undefined) { + pre = match[3].toLowerCase() as PreReleaseLabelInput; + preNumber = match[4] !== undefined ? parseInt(match[4], 10) : 0; + } + + let post: number | undefined; + if (match[5] !== undefined) { + // Implicit post: "1.0-1" form + post = parseInt(match[5], 10); + } else if (match[6] !== undefined) { + post = match[7] !== undefined ? parseInt(match[7], 10) : 0; + } + + const dev = match[8] !== undefined ? (match[9] !== undefined ? parseInt(match[9], 10) : 0) : undefined; + const local = match[10]; + + return new PEP440Version(release, { + epoch: match[1] !== undefined ? parseInt(match[1], 10) : undefined, + pre, + preNumber, + post, + dev, + local, + }); + } + + /** The minor version number (second element of release), or 0 if absent. */ + public get minor(): number { + return this.release.length > 1 ? this.release[1] : 0; + } + + /** The micro/patch version number (third element of release), or 0 if absent. */ + public get micro(): number { + return this.release.length > 2 ? this.release[2] : 0; + } + + /** Whether this version is a pre-release (has pre or dev segment). */ + public get isPreRelease(): boolean { + return this.pre !== undefined || this.dev !== undefined; + } + + /** Whether this version is a post-release. */ + public get isPostRelease(): boolean { + return this.post !== undefined; + } + + /** Whether this version is a dev release. */ + public get isDevRelease(): boolean { + return this.dev !== undefined; + } + + /** Whether this version has a local segment. */ + public get isLocal(): boolean { + return this.local !== undefined; + } + + /** Returns the normalized PEP 440 string representation. */ + public toString(): string { + const parts: string[] = []; + + if (this.epoch !== 0) { + parts.push(`${this.epoch}!`); + } + + parts.push(this.release.join('.')); + + if (this.pre !== undefined) { + parts.push(`${this.pre}${this.preNumber ?? 0}`); + } + + if (this.post !== undefined) { + parts.push(`.post${this.post}`); + } + + if (this.dev !== undefined) { + parts.push(`.dev${this.dev}`); + } + + if (this.local !== undefined) { + parts.push(`+${this.local}`); + } + + return parts.join(''); + } + + /** + * Compare two versions. Returns negative if `a < b`, + * 0 if equal, positive if `a > b`. + * + * Local versions are not considered in ordering per PEP 440. + * + * PEP 440 ordering: .devN < aN < bN < rcN < (final) < .postN + */ + public static compare(a: PEP440Version, b: PEP440Version): number { + // 1. Epoch + if (a.epoch !== b.epoch) { + return a.epoch - b.epoch; + } + + // 2. Release segments (compare element-by-element, pad shorter with 0) + const maxLen = Math.max(a.release.length, b.release.length); + for (let i = 0; i < maxLen; i++) { + const av = i < a.release.length ? a.release[i] : 0; + const bv = i < b.release.length ? b.release[i] : 0; + if (av !== bv) { + return av - bv; + } + } + + // 3. Pre/dev/post sort key comparison + // Sort key is [prePhase, preNum, post, dev] where: + // - prePhase: a=-3, b=-2, rc=-1, absent=0 + // - preNum: number or 0 + // - post: number if present, -1 if absent + // - dev: number if present, Infinity if absent (final sorts after dev) + const aKey = PEP440Version.sortKey(a); + const bKey = PEP440Version.sortKey(b); + for (let i = 0; i < aKey.length; i++) { + if (aKey[i] !== bKey[i]) { + return aKey[i] < bKey[i] ? -1 : 1; + } + } + + return 0; + } + + private static readonly PRE_PHASE_ORDER: Record = { a: -3, b: -2, rc: -1 }; + + private static sortKey(v: PEP440Version): [number, number, number, number] { + // Special case from Python `packaging`: dev-only releases (no pre, no post) + // must sort before all pre-releases. Without this, 1.0.dev0 would sort + // after 1.0a0 because "no pre" normally sorts after all pre phases. + let prePhase: number; + if (v.pre === undefined && v.post === undefined && v.dev !== undefined) { + prePhase = -Infinity; + } else { + prePhase = v.pre !== undefined ? PEP440Version.PRE_PHASE_ORDER[v.pre] : 0; + } + const preNum = v.preNumber ?? 0; + const post = v.post ?? -1; + const dev = v.dev ?? Infinity; + return [prePhase, preNum, post, dev]; + } +} diff --git a/src/common/utils/versionSpecifier.ts b/src/common/utils/versionSpecifier.ts new file mode 100644 index 00000000..e8acbe2c --- /dev/null +++ b/src/common/utils/versionSpecifier.ts @@ -0,0 +1,202 @@ +/** + * PEP 440 version specifiers and constraints. + * + * A `VersionSpecifier` represents a single clause like `>=1.2.3` or `==1.2.*`. + * A `VersionConstraint` represents a comma-separated set like `>=1.2,<2.0`. + * + * See https://peps.python.org/pep-0440/#version-specifiers + */ + +import { PEP440Version } from './pep440Version'; + +/** Operators supported by PEP 440 version specifiers. */ +export type VersionOp = '==' | '!=' | '<=' | '>=' | '<' | '>' | '~=' | '==='; + +const VALID_OPS: readonly VersionOp[] = ['===', '~=', '==', '!=', '<=', '>=', '<', '>']; + +/** + * Regex to parse a single specifier clause. + * Match the operator first (longest-match order), then optional whitespace, + * then the version (with optional trailing `.*` wildcard). + */ +const SPECIFIER_REGEX = new RegExp( + `^(${VALID_OPS.join('|')})\\s*` + + `(v?(?:[0-9]+!)?[0-9]+(?:\\.[0-9]+)*` + + `(?:[-_.]?(?:a|b|c|rc|alpha|beta|pre|preview)[-_.]?[0-9]*)?` + + `(?:(?:-[0-9]+)|(?:[-_.]?(?:post|rev|r)[-_.]?[0-9]*))?` + + `(?:[-_.]?dev[-_.]?[0-9]*)?` + + `(?:\\+[a-z0-9]+(?:[-_.][a-z0-9]+)*)?)` + + `(\\.\\*)?$`, + 'i', +); + +/** + * A single version specifier clause (e.g. `>=1.2.3` or `==1.2.*`). + */ +export class VersionSpecifier { + /** The comparison operator. */ + public readonly op: VersionOp; + /** The version to compare against. */ + public readonly version: PEP440Version; + /** Whether the specifier uses a wildcard (only valid with `==` and `!=`). */ + public readonly wildcard: boolean; + + constructor(op: VersionOp, version: PEP440Version, wildcard: boolean = false) { + this.op = op; + this.version = version; + this.wildcard = wildcard; + } + + /** + * Parse a single specifier clause like `>=1.2.3` or `==1.2.*`. + * Returns `undefined` if the string is not valid. + */ + public static parse(input: string): VersionSpecifier | undefined { + const match = SPECIFIER_REGEX.exec(input.trim()); + if (!match) { + return undefined; + } + + const op = match[1] as VersionOp; + const versionStr = match[2]; + const wildcard = match[3] === '.*'; + + // Wildcards are only valid with == and != + if (wildcard && op !== '==' && op !== '!=') { + return undefined; + } + + const version = PEP440Version.parse(versionStr); + if (!version) { + return undefined; + } + + return new VersionSpecifier(op, version, wildcard); + } + + /** + * Check whether a candidate version satisfies this specifier. + */ + public contains(candidate: PEP440Version): boolean { + switch (this.op) { + case '==': + return this.wildcard + ? this.prefixMatch(candidate) + : PEP440Version.compare(candidate, this.version) === 0; + case '!=': + return this.wildcard + ? !this.prefixMatch(candidate) + : PEP440Version.compare(candidate, this.version) !== 0; + case '<': + return PEP440Version.compare(candidate, this.version) < 0; + case '<=': + return PEP440Version.compare(candidate, this.version) <= 0; + case '>': + return PEP440Version.compare(candidate, this.version) > 0; + case '>=': + return PEP440Version.compare(candidate, this.version) >= 0; + case '~=': + return this.compatibleMatch(candidate); + case '===': + return candidate.toString() === this.version.toString(); + } + } + + /** + * Prefix match for wildcard specifiers (`==1.2.*`). + * Checks that the candidate's release starts with the specifier's release segments + * and the epoch matches. + */ + private prefixMatch(candidate: PEP440Version): boolean { + if (candidate.epoch !== this.version.epoch) { + return false; + } + const prefix = this.version.release; + if (candidate.release.length < prefix.length) { + return false; + } + for (let i = 0; i < prefix.length; i++) { + if (candidate.release[i] !== prefix[i]) { + return false; + } + } + return true; + } + + /** + * Compatible release match (`~=`). + * `~=X.Y.Z` is equivalent to `>=X.Y.Z, ==X.Y.*`. + * `~=X.Y` is equivalent to `>=X.Y, ==X.*`. + */ + private compatibleMatch(candidate: PEP440Version): boolean { + // Must be >= the specified version + if (PEP440Version.compare(candidate, this.version) < 0) { + return false; + } + // Must share the same prefix (all segments except the last) + const release = this.version.release; + const prefix = release.slice(0, release.length - 1); + if (candidate.epoch !== this.version.epoch) { + return false; + } + for (let i = 0; i < prefix.length; i++) { + const cv = i < candidate.release.length ? candidate.release[i] : 0; + if (cv !== prefix[i]) { + return false; + } + } + return true; + } + + /** Returns the string representation (e.g. `>=1.2.3`, `==1.2.*`). */ + public toString(): string { + const suffix = this.wildcard ? '.*' : ''; + return `${this.op}${this.version}${suffix}`; + } +} + +/** + * A set of version specifier clauses joined by commas (e.g. `>=1.2,<2.0`). + * A candidate version must satisfy **all** clauses. + */ +export class VersionConstraint { + public readonly specifiers: readonly VersionSpecifier[]; + + constructor(specifiers: readonly VersionSpecifier[]) { + this.specifiers = specifiers; + } + + /** + * Parse a comma-separated version constraint string like `>=1.2,<2.0`. + * Returns `undefined` if any clause is invalid. + */ + public static parse(input: string): VersionConstraint | undefined { + const parts = input.split(',').map((s) => s.trim()); + if (parts.length === 0 || (parts.length === 1 && parts[0] === '')) { + return undefined; + } + + const specifiers: VersionSpecifier[] = []; + for (const part of parts) { + const spec = VersionSpecifier.parse(part); + if (!spec) { + return undefined; + } + specifiers.push(spec); + } + + return new VersionConstraint(specifiers); + } + + /** + * Check whether a candidate version satisfies all specifiers in this constraint. + */ + public contains(candidate: PEP440Version): boolean { + return this.specifiers.every((s) => s.contains(candidate)); + } + + /** Returns the comma-separated string representation. */ + public toString(): string { + return this.specifiers.map((s) => s.toString()).join(','); + } +} diff --git a/src/test/common/pep440Version.unit.test.ts b/src/test/common/pep440Version.unit.test.ts new file mode 100644 index 00000000..fc521460 --- /dev/null +++ b/src/test/common/pep440Version.unit.test.ts @@ -0,0 +1,246 @@ +import assert from 'node:assert'; +import { PEP440Version } from '../../common/utils/pep440Version'; + +suite('PEP440Version', () => { + suite('parse', () => { + test('simple release', () => { + const v = PEP440Version.parse('1.2.3'); + assert.ok(v); + assert.deepStrictEqual([...v.release], [1, 2, 3]); + assert.strictEqual(v.epoch, 0); + assert.strictEqual(v.pre, undefined); + assert.strictEqual(v.post, undefined); + assert.strictEqual(v.dev, undefined); + assert.strictEqual(v.local, undefined); + }); + + test('single segment release', () => { + const v = PEP440Version.parse('42'); + assert.ok(v); + assert.deepStrictEqual([...v.release], [42]); + }); + + test('epoch', () => { + const v = PEP440Version.parse('2!1.0'); + assert.ok(v); + assert.strictEqual(v.epoch, 2); + }); + + test('alpha pre-release', () => { + const v = PEP440Version.parse('1.0a1'); + assert.ok(v); + assert.strictEqual(v.pre, 'a'); + assert.strictEqual(v.preNumber, 1); + }); + + test('beta pre-release', () => { + const v = PEP440Version.parse('1.0b2'); + assert.ok(v); + assert.strictEqual(v.pre, 'b'); + assert.strictEqual(v.preNumber, 2); + }); + + test('rc pre-release', () => { + const v = PEP440Version.parse('1.0rc3'); + assert.ok(v); + assert.strictEqual(v.pre, 'rc'); + assert.strictEqual(v.preNumber, 3); + }); + + test('normalizes "alpha" to "a"', () => { + const v = PEP440Version.parse('1.0alpha1'); + assert.ok(v); + assert.strictEqual(v.pre, 'a'); + }); + + test('normalizes "beta" to "b"', () => { + const v = PEP440Version.parse('1.0beta2'); + assert.ok(v); + assert.strictEqual(v.pre, 'b'); + }); + + test('normalizes "c" to "rc"', () => { + const v = PEP440Version.parse('1.0c1'); + assert.ok(v); + assert.strictEqual(v.pre, 'rc'); + }); + + test('normalizes "preview" to "rc"', () => { + const v = PEP440Version.parse('1.0preview1'); + assert.ok(v); + assert.strictEqual(v.pre, 'rc'); + }); + + test('post release', () => { + const v = PEP440Version.parse('1.0.post1'); + assert.ok(v); + assert.strictEqual(v.post, 1); + }); + + test('implicit post release (dash form)', () => { + const v = PEP440Version.parse('1.0-1'); + assert.ok(v); + assert.strictEqual(v.post, 1); + }); + + test('dev release', () => { + const v = PEP440Version.parse('1.0.dev3'); + assert.ok(v); + assert.strictEqual(v.dev, 3); + }); + + test('local version', () => { + const v = PEP440Version.parse('1.0+ubuntu1'); + assert.ok(v); + assert.strictEqual(v.local, 'ubuntu1'); + }); + + test('leading v is accepted', () => { + const v = PEP440Version.parse('v1.0'); + assert.ok(v); + assert.strictEqual(v.major, 1); + }); + + test('pre-release without number defaults to 0', () => { + const v = PEP440Version.parse('1.0a'); + assert.ok(v); + assert.strictEqual(v.preNumber, 0); + }); + + test('dev without number defaults to 0', () => { + const v = PEP440Version.parse('1.0.dev'); + assert.ok(v); + assert.strictEqual(v.dev, 0); + }); + + test('returns undefined for invalid version', () => { + assert.strictEqual(PEP440Version.parse('not-a-version'), undefined); + assert.strictEqual(PEP440Version.parse(''), undefined); + assert.strictEqual(PEP440Version.parse('abc.def'), undefined); + }); + }); + + suite('constructor normalization', () => { + test('preserves trailing zeros in release', () => { + const v = new PEP440Version([1, 0, 0]); + assert.deepStrictEqual([...v.release], [1, 0, 0]); + }); + + test('normalizes local separators', () => { + const v = new PEP440Version([1], { local: 'Ubuntu-1_2' }); + assert.strictEqual(v.local, 'ubuntu.1.2'); + }); + + test('normalizes pre label "alpha" to "a"', () => { + const v = new PEP440Version([1], { pre: 'alpha' }); + assert.strictEqual(v.pre, 'a'); + assert.strictEqual(v.preNumber, 0); + }); + }); + + suite('toString', () => { + test('simple version', () => { + assert.strictEqual(PEP440Version.parse('1.2.3')?.toString(), '1.2.3'); + }); + + test('epoch included when non-zero', () => { + assert.strictEqual(PEP440Version.parse('2!1.0')?.toString(), '2!1.0'); + }); + + test('pre-release', () => { + assert.strictEqual(PEP440Version.parse('1.0a1')?.toString(), '1.0a1'); + assert.strictEqual(PEP440Version.parse('1.0rc3')?.toString(), '1.0rc3'); + }); + + test('post-release', () => { + assert.strictEqual(PEP440Version.parse('1.0.post1')?.toString(), '1.0.post1'); + }); + + test('dev release', () => { + assert.strictEqual(PEP440Version.parse('1.0.dev5')?.toString(), '1.0.dev5'); + }); + + test('local version', () => { + assert.strictEqual(PEP440Version.parse('1.0+local1')?.toString(), '1.0+local1'); + }); + + test('normalizes alternate labels in output', () => { + assert.strictEqual(PEP440Version.parse('1.0alpha1')?.toString(), '1.0a1'); + assert.strictEqual(PEP440Version.parse('1.0c3')?.toString(), '1.0rc3'); + }); + }); + + suite('properties', () => { + test('major/minor/micro', () => { + const v = PEP440Version.parse('3.11.4'); + assert.ok(v); + assert.strictEqual(v.major, 3); + assert.strictEqual(v.minor, 11); + assert.strictEqual(v.micro, 4); + }); + + test('minor defaults to 0 for single segment', () => { + const v = PEP440Version.parse('5'); + assert.ok(v); + assert.strictEqual(v.minor, 0); + assert.strictEqual(v.micro, 0); + }); + + test('isPreRelease', () => { + assert.strictEqual(PEP440Version.parse('1.0')?.isPreRelease, false); + assert.strictEqual(PEP440Version.parse('1.0a1')?.isPreRelease, true); + assert.strictEqual(PEP440Version.parse('1.0.dev1')?.isPreRelease, true); + }); + + test('isPostRelease', () => { + assert.strictEqual(PEP440Version.parse('1.0.post1')?.isPostRelease, true); + assert.strictEqual(PEP440Version.parse('1.0')?.isPostRelease, false); + }); + + test('isLocal', () => { + assert.strictEqual(PEP440Version.parse('1.0+local')?.isLocal, true); + assert.strictEqual(PEP440Version.parse('1.0')?.isLocal, false); + }); + }); + + suite('compare', () => { + function v(s: string): PEP440Version { + const parsed = PEP440Version.parse(s); + assert.ok(parsed, `Failed to parse "${s}"`); + return parsed; + } + + test('release ordering', () => { + assert.ok(PEP440Version.compare(v('1.0'), v('1.1')) < 0); + assert.ok(PEP440Version.compare(v('1.0'), v('2.0')) < 0); + assert.ok(PEP440Version.compare(v('1.0.0'), v('1.0.1')) < 0); + }); + + test('epoch takes precedence', () => { + assert.ok(PEP440Version.compare(v('1!1.0'), v('2!0.1')) < 0); + }); + + test('dev < alpha < beta < rc < final', () => { + assert.ok(PEP440Version.compare(v('1.0.dev1'), v('1.0a1')) < 0); + assert.ok(PEP440Version.compare(v('1.0a1'), v('1.0b1')) < 0); + assert.ok(PEP440Version.compare(v('1.0b1'), v('1.0rc1')) < 0); + assert.ok(PEP440Version.compare(v('1.0rc1'), v('1.0')) < 0); + }); + + test('final < post', () => { + assert.ok(PEP440Version.compare(v('1.0'), v('1.0.post1')) < 0); + }); + + test('pre-release number ordering', () => { + assert.ok(PEP440Version.compare(v('1.0a1'), v('1.0a2')) < 0); + }); + + test('dev on pre-release sorts before pre without dev', () => { + assert.ok(PEP440Version.compare(v('1.0a1.dev1'), v('1.0a1')) < 0); + }); + + test('equality', () => { + assert.strictEqual(PEP440Version.compare(v('1.0.0'), v('1.0')), 0); + }); + }); +}); diff --git a/src/test/common/versionSpecifier.unit.test.ts b/src/test/common/versionSpecifier.unit.test.ts new file mode 100644 index 00000000..e94dfd07 --- /dev/null +++ b/src/test/common/versionSpecifier.unit.test.ts @@ -0,0 +1,193 @@ +import assert from 'node:assert'; +import { PEP440Version } from '../../common/utils/pep440Version'; +import { VersionConstraint, VersionSpecifier } from '../../common/utils/versionSpecifier'; + +suite('VersionSpecifier', () => { + function v(s: string): PEP440Version { + const parsed = PEP440Version.parse(s); + assert.ok(parsed, `Failed to parse "${s}"`); + return parsed; + } + + suite('parse', () => { + test('parses >= operator', () => { + const s = VersionSpecifier.parse('>=1.2.3'); + assert.ok(s); + assert.strictEqual(s.op, '>='); + assert.strictEqual(s.version.toString(), '1.2.3'); + assert.strictEqual(s.wildcard, false); + }); + + test('parses == with wildcard', () => { + const s = VersionSpecifier.parse('==1.2.*'); + assert.ok(s); + assert.strictEqual(s.op, '=='); + assert.strictEqual(s.wildcard, true); + }); + + test('parses != with wildcard', () => { + const s = VersionSpecifier.parse('!=1.0.*'); + assert.ok(s); + assert.strictEqual(s.op, '!='); + assert.strictEqual(s.wildcard, true); + }); + + test('parses === (arbitrary equality)', () => { + const s = VersionSpecifier.parse('===1.0'); + assert.ok(s); + assert.strictEqual(s.op, '==='); + }); + + test('parses ~= (compatible release)', () => { + const s = VersionSpecifier.parse('~=1.4.2'); + assert.ok(s); + assert.strictEqual(s.op, '~='); + }); + + test('allows whitespace between operator and version', () => { + const s = VersionSpecifier.parse('>= 1.0'); + assert.ok(s); + assert.strictEqual(s.op, '>='); + }); + + test('rejects wildcard with invalid operator', () => { + assert.strictEqual(VersionSpecifier.parse('>=1.2.*'), undefined); + assert.strictEqual(VersionSpecifier.parse('<1.0.*'), undefined); + }); + + test('returns undefined for invalid input', () => { + assert.strictEqual(VersionSpecifier.parse(''), undefined); + assert.strictEqual(VersionSpecifier.parse('1.0'), undefined); + assert.strictEqual(VersionSpecifier.parse('>>1.0'), undefined); + }); + }); + + suite('contains', () => { + test('== exact match', () => { + const s = VersionSpecifier.parse('==1.2.3'); + assert.ok(s); + assert.strictEqual(s.contains(v('1.2.3')), true); + assert.strictEqual(s.contains(v('1.2.4')), false); + }); + + test('!= excludes exact match', () => { + const s = VersionSpecifier.parse('!=1.0'); + assert.ok(s); + assert.strictEqual(s.contains(v('1.0')), false); + assert.strictEqual(s.contains(v('1.1')), true); + }); + + test('>= includes boundary', () => { + const s = VersionSpecifier.parse('>=1.2'); + assert.ok(s); + assert.strictEqual(s.contains(v('1.2')), true); + assert.strictEqual(s.contains(v('1.3')), true); + assert.strictEqual(s.contains(v('1.1')), false); + }); + + test('< excludes boundary', () => { + const s = VersionSpecifier.parse('<2.0'); + assert.ok(s); + assert.strictEqual(s.contains(v('1.9')), true); + assert.strictEqual(s.contains(v('2.0')), false); + }); + + test('== wildcard matches prefix', () => { + const s = VersionSpecifier.parse('==1.2.*'); + assert.ok(s); + assert.strictEqual(s.contains(v('1.2.0')), true); + assert.strictEqual(s.contains(v('1.2.99')), true); + assert.strictEqual(s.contains(v('1.3.0')), false); + }); + + test('!= wildcard excludes prefix', () => { + const s = VersionSpecifier.parse('!=1.0.*'); + assert.ok(s); + assert.strictEqual(s.contains(v('1.0.5')), false); + assert.strictEqual(s.contains(v('1.1.0')), true); + }); + + test('~= compatible release', () => { + const s = VersionSpecifier.parse('~=1.4.2'); + assert.ok(s); + assert.strictEqual(s.contains(v('1.4.2')), true); + assert.strictEqual(s.contains(v('1.4.5')), true); + assert.strictEqual(s.contains(v('1.5.0')), false); + assert.strictEqual(s.contains(v('1.4.1')), false); + }); + + test('=== arbitrary equality', () => { + const s = VersionSpecifier.parse('===1.0'); + assert.ok(s); + assert.strictEqual(s.contains(v('1.0')), true); + assert.strictEqual(s.contains(v('1.0.0')), false); // string mismatch: "1.0" vs "1.0.0" + }); + }); + + suite('toString', () => { + test('round-trips specifier string', () => { + assert.strictEqual(VersionSpecifier.parse('>=1.2.3')?.toString(), '>=1.2.3'); + assert.strictEqual(VersionSpecifier.parse('==1.2.*')?.toString(), '==1.2.*'); + assert.strictEqual(VersionSpecifier.parse('~=1.4.2')?.toString(), '~=1.4.2'); + }); + }); +}); + +suite('VersionConstraint', () => { + function v(s: string): PEP440Version { + const parsed = PEP440Version.parse(s); + assert.ok(parsed, `Failed to parse "${s}"`); + return parsed; + } + + suite('parse', () => { + test('parses single specifier', () => { + const c = VersionConstraint.parse('>=1.0'); + assert.ok(c); + assert.strictEqual(c.specifiers.length, 1); + }); + + test('parses multiple comma-separated specifiers', () => { + const c = VersionConstraint.parse('>=1.2, <2.0'); + assert.ok(c); + assert.strictEqual(c.specifiers.length, 2); + assert.strictEqual(c.specifiers[0].op, '>='); + assert.strictEqual(c.specifiers[1].op, '<'); + }); + + test('returns undefined for empty string', () => { + assert.strictEqual(VersionConstraint.parse(''), undefined); + }); + + test('returns undefined if any clause is invalid', () => { + assert.strictEqual(VersionConstraint.parse('>=1.0, invalid'), undefined); + }); + }); + + suite('contains', () => { + test('all specifiers must match', () => { + const c = VersionConstraint.parse('>=1.2, <2.0'); + assert.ok(c); + assert.strictEqual(c.contains(v('1.5')), true); + assert.strictEqual(c.contains(v('1.2')), true); + assert.strictEqual(c.contains(v('2.0')), false); + assert.strictEqual(c.contains(v('1.1')), false); + }); + + test('exclusion constraint', () => { + const c = VersionConstraint.parse('>=1.0, !=1.5'); + assert.ok(c); + assert.strictEqual(c.contains(v('1.4')), true); + assert.strictEqual(c.contains(v('1.5')), false); + assert.strictEqual(c.contains(v('1.6')), true); + }); + }); + + suite('toString', () => { + test('round-trips constraint string', () => { + const c = VersionConstraint.parse('>=1.2,<2.0'); + assert.ok(c); + assert.strictEqual(c.toString(), '>=1.2,<2.0'); + }); + }); +}); From 33f8c31accecea1254a2b39df2b3034334a3d733 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 3 Jun 2026 10:40:20 -0700 Subject: [PATCH 2/6] Restore package.json --- package-lock.json | 34 +++++++++------------------------- package.json | 7 ++----- 2 files changed, 11 insertions(+), 30 deletions(-) diff --git a/package-lock.json b/package-lock.json index 579e67a7..7fea10e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "@types/fs-extra": "^11.0.4", "@types/glob": "^8.1.0", "@types/mocha": "^10.0.1", - "@types/node": "^22.19.19", + "@types/node": "^22.15.1", "@types/sinon": "^17.0.3", "@types/stack-trace": "0.0.29", "@types/vscode": "^1.99.0", @@ -716,9 +716,9 @@ "integrity": "sha512-dJvrYWxP/UcXm36Qn36fxhUKu8A/xMRXVT2cliFF1Z7UA9liG5Psj3ezNSZw+5puH2czDXRLcXQxf8JbJt0ejg==" }, "node_modules/@types/node": { - "version": "22.19.19", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", - "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "version": "22.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", + "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", "dev": true, "license": "MIT", "dependencies": { @@ -801,7 +801,6 @@ "integrity": "sha512-D7DbgGFtsqIPIFMPJwCad9Gfi/hC0PWErRRHFnaCWoEDYi5tQUDiJCTmGUbBiLzjqAck4KcXt9Ayj0CNlIrF+w==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.16.0", "@typescript-eslint/types": "8.16.0", @@ -1510,7 +1509,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1785,7 +1783,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2524,7 +2521,6 @@ "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4868,7 +4864,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5542,7 +5537,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5669,7 +5663,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -5718,7 +5711,6 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -6461,9 +6453,9 @@ "integrity": "sha512-dJvrYWxP/UcXm36Qn36fxhUKu8A/xMRXVT2cliFF1Z7UA9liG5Psj3ezNSZw+5puH2czDXRLcXQxf8JbJt0ejg==" }, "@types/node": { - "version": "22.19.19", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", - "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "version": "22.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", + "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", "dev": true, "requires": { "undici-types": "~6.21.0" @@ -6524,7 +6516,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.16.0.tgz", "integrity": "sha512-D7DbgGFtsqIPIFMPJwCad9Gfi/hC0PWErRRHFnaCWoEDYi5tQUDiJCTmGUbBiLzjqAck4KcXt9Ayj0CNlIrF+w==", "dev": true, - "peer": true, "requires": { "@typescript-eslint/scope-manager": "8.16.0", "@typescript-eslint/types": "8.16.0", @@ -7032,8 +7023,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "peer": true + "dev": true }, "acorn-import-phases": { "version": "1.0.4", @@ -7220,7 +7210,6 @@ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, - "peer": true, "requires": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7731,7 +7720,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, - "peer": true, "requires": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9413,7 +9401,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, - "peer": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -9870,8 +9857,7 @@ "version": "5.3.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", - "dev": true, - "peer": true + "dev": true }, "uc.micro": { "version": "1.0.6", @@ -9957,7 +9943,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, - "peer": true, "requires": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -9991,7 +9976,6 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, - "peer": true, "requires": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", diff --git a/package.json b/package.json index 9f61c380..df79269f 100644 --- a/package.json +++ b/package.json @@ -124,10 +124,7 @@ "python-envs.workspaceSearchPaths": { "type": "array", "description": "%python-envs.workspaceSearchPaths.description%", - "default": [ - ".venv", - "*/.venv" - ], + "default": [".venv", "*/.venv"], "scope": "resource", "items": { "type": "string" @@ -695,7 +692,7 @@ "@types/fs-extra": "^11.0.4", "@types/glob": "^8.1.0", "@types/mocha": "^10.0.1", - "@types/node": "^22.19.19", + "@types/node": "^22.15.1", "@types/sinon": "^17.0.3", "@types/stack-trace": "0.0.29", "@types/vscode": "^1.99.0", From fb4b6502b6f0676dcbaaccc2d3877a8fd365c583 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 3 Jun 2026 10:59:00 -0700 Subject: [PATCH 3/6] Add shortenVersionString util method --- src/common/utils/pep440Version.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/common/utils/pep440Version.ts b/src/common/utils/pep440Version.ts index ad24bbbb..b581d183 100644 --- a/src/common/utils/pep440Version.ts +++ b/src/common/utils/pep440Version.ts @@ -149,6 +149,18 @@ export class PEP440Version { return this.release.length > 2 ? this.release[2] : 0; } + /** + * Returns a short display string: "X.Y.Z" if micro is present, otherwise "X.Y.x". + * Parses `input` first; returns it unchanged if not a valid version. + */ + public static shortenVersionString(input: string): string { + const v = PEP440Version.parse(input); + if (!v) { + return input; + } + return v.release.length >= 3 ? `${v.major}.${v.minor}.${v.micro}` : `${v.major}.${v.minor}.x`; + } + /** Whether this version is a pre-release (has pre or dev segment). */ public get isPreRelease(): boolean { return this.pre !== undefined || this.dev !== undefined; From ae3ebb84618130a5dbbee1a684e3a697fbf30e4b Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 3 Jun 2026 11:35:48 -0700 Subject: [PATCH 4/6] Replace old use cases --- src/common/extVersion.ts | 9 ++- src/managers/builtin/pipUtils.ts | 9 +-- src/managers/builtin/utils.ts | 5 +- src/managers/builtin/venvManager.ts | 5 +- src/managers/builtin/venvUtils.ts | 5 +- src/managers/common/utils.ts | 82 +++--------------------- src/managers/conda/condaStepBasedFlow.ts | 20 +++--- src/managers/conda/condaUtils.ts | 26 ++++---- src/managers/pipenv/pipenvUtils.ts | 5 +- src/managers/poetry/poetryUtils.ts | 5 +- src/managers/pyenv/pyenvUtils.ts | 5 +- 11 files changed, 53 insertions(+), 123 deletions(-) diff --git a/src/common/extVersion.ts b/src/common/extVersion.ts index 39229c00..47008e67 100644 --- a/src/common/extVersion.ts +++ b/src/common/extVersion.ts @@ -1,6 +1,7 @@ import { PYTHON_EXTENSION_ID } from './constants'; import { getExtension } from './extension.apis'; import { traceError } from './logging'; +import { PEP440Version } from './utils/pep440Version'; export function ensureCorrectVersion() { const extension = getExtension(PYTHON_EXTENSION_ID); @@ -8,11 +9,9 @@ export function ensureCorrectVersion() { return; } - const version = extension.packageJSON.version; - const parts = version.split('.'); - const major = parseInt(parts[0]); - const minor = parseInt(parts[1]); - if (major >= 2025 || (major === 2024 && minor >= 23)) { + const version = PEP440Version.parse(extension.packageJSON.version); + const minVersion = PEP440Version.parse('2024.23.0'); + if (version && minVersion && PEP440Version.compare(version, minVersion) >= 0) { return; } traceError('Incompatible Python extension. Please update `ms-python.python` to version 2024.23 or later.'); diff --git a/src/managers/builtin/pipUtils.ts b/src/managers/builtin/pipUtils.ts index 2e7a7f85..d2ea1a83 100644 --- a/src/managers/builtin/pipUtils.ts +++ b/src/managers/builtin/pipUtils.ts @@ -6,6 +6,7 @@ import { PackageManagementOptions, PythonEnvironment, PythonEnvironmentApi, Pyth import { EXTENSION_ROOT_DIR } from '../../common/constants'; import { PackageManagement, Pickers, VenvManagerStrings } from '../../common/localize'; import { traceInfo } from '../../common/logging'; +import { PEP440Version } from '../../common/utils/pep440Version'; import { showQuickPickWithButtons, withProgress } from '../../common/window.apis'; import { findFiles } from '../../common/workspace.apis'; import { selectFromCommonPackagesToInstall, selectFromInstallableToInstall } from '../common/pickers'; @@ -56,13 +57,7 @@ export function validatePyprojectToml(toml: PyprojectToml): string | undefined { if (version.length === 0) { return l10n.t('Version cannot be empty in pyproject.toml.'); } - // PEP 440 version regex. Versions must follow PEP 440 format (e.g., "1.0.0", "2.1a3"). - // See https://peps.python.org/pep-0440/ - // This regex is adapted from the official python 'packaging' library: - // https://github.com/pypa/packaging/blob/main/src/packaging/version.py - const versionRegex = - /^v?([0-9]+!)?([0-9]+(?:\.[0-9]+)*)(?:[-_.]?(a|b|c|rc|alpha|beta|pre|preview)[-_.]?([0-9]+)?)?(?:(?:-([0-9]+))|(?:[-_.]?(post|rev|r)[-_.]?([0-9]+)?))?(?:[-_.]?(dev)[-_.]?([0-9]+)?)?(?:\+([a-z0-9]+(?:[-_.][a-z0-9]+)*))?$/i; - if (!versionRegex.test(version)) { + if (!PEP440Version.parse(version)) { return l10n.t('Invalid version "{0}" in pyproject.toml.', version); } } diff --git a/src/managers/builtin/utils.ts b/src/managers/builtin/utils.ts index 3bab6770..20edc3c2 100644 --- a/src/managers/builtin/utils.ts +++ b/src/managers/builtin/utils.ts @@ -13,6 +13,7 @@ import { getExtension } from '../../common/extension.apis'; import { Common, PixiStrings, SysManagerStrings } from '../../common/localize'; import { traceInfo, traceVerbose } from '../../common/logging'; import { getGlobalPersistentState } from '../../common/persistentState'; +import { PEP440Version } from '../../common/utils/pep440Version'; import { showInformationMessage, withProgress } from '../../common/window.apis'; import { openExtension } from '../../common/workbenchCommands'; import { @@ -21,7 +22,7 @@ import { NativePythonEnvironmentKind, NativePythonFinder, } from '../common/nativePythonFinder'; -import { shortVersion, sortEnvironments } from '../common/utils'; +import { sortEnvironments } from '../common/utils'; import { runPython, runUV, shouldUseUv } from './helpers'; import { parsePipListJson, PipPackage } from './pipListUtils'; @@ -80,7 +81,7 @@ function getKindName(kind: NativePythonEnvironmentKind | undefined): string | un function getPythonInfo(env: NativeEnvInfo): PythonEnvironmentInfo { if (env.executable && env.version && env.prefix) { const kindName = getKindName(env.kind); - const sv = shortVersion(env.version); + const sv = PEP440Version.shortenVersionString(env.version); const name = kindName ? `Python ${sv} (${kindName})` : `Python ${sv}`; const displayName = kindName ? `Python ${sv} (${kindName})` : `Python ${sv}`; const shortDisplayName = kindName ? `${sv} (${kindName})` : `${sv}`; diff --git a/src/managers/builtin/venvManager.ts b/src/managers/builtin/venvManager.ts index d481b546..0ee8d1d1 100644 --- a/src/managers/builtin/venvManager.ts +++ b/src/managers/builtin/venvManager.ts @@ -33,11 +33,12 @@ 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 { PEP440Version } from '../../common/utils/pep440Version'; import { showErrorMessage, showInformationMessage, withProgress } from '../../common/window.apis'; import { findParentIfFile } from '../../features/envCommands'; import { getProjectFsPathForScope, tryFastPathGet } from '../common/fastPath'; import { NativePythonFinder } from '../common/nativePythonFinder'; -import { getLatest, shortVersion, sortEnvironments } from '../common/utils'; +import { getLatest, sortEnvironments } from '../common/utils'; import { promptInstallPythonViaUv } from './uvPythonInstaller'; import { clearVenvCache, @@ -117,7 +118,7 @@ export class VenvManager implements EnvironmentManager { description: l10n.t('Create a virtual environment in workspace root'), detail: l10n.t( 'Uses Python version {0} and installs workspace dependencies.', - shortVersion(this.globalEnv.version), + PEP440Version.shortenVersionString(this.globalEnv.version), ), }; } diff --git a/src/managers/builtin/venvUtils.ts b/src/managers/builtin/venvUtils.ts index c2cb037a..6a27062b 100644 --- a/src/managers/builtin/venvUtils.ts +++ b/src/managers/builtin/venvUtils.ts @@ -10,6 +10,7 @@ import { getWorkspacePersistentState } from '../../common/persistentState'; import { EventNames } from '../../common/telemetry/constants'; import { sendTelemetryEvent } from '../../common/telemetry/sender'; import { normalizePath } from '../../common/utils/pathUtils'; +import { PEP440Version } from '../../common/utils/pep440Version'; import { showErrorMessage, showOpenDialog, @@ -24,7 +25,7 @@ import { NativePythonEnvironmentKind, NativePythonFinder, } from '../common/nativePythonFinder'; -import { getShellActivationCommands, shortVersion, sortEnvironments } from '../common/utils'; +import { getShellActivationCommands, sortEnvironments } from '../common/utils'; import { runPython, runUV, shouldUseUv } from './helpers'; import { getProjectInstallable, PipPackages, shouldProceedAfterPyprojectValidation } from './pipUtils'; import { resolveSystemPythonEnvironmentPath } from './utils'; @@ -164,7 +165,7 @@ async function getPythonInfo(env: NativeEnvInfo): Promise if (env.executable && env.version && env.prefix) { const venvName = env.name ?? getName(env.executable); - const sv = shortVersion(env.version); + const sv = PEP440Version.shortenVersionString(env.version); const name = `${venvName} (${sv})`; let description = undefined; if (env.kind === NativePythonEnvironmentKind.venvUv) { diff --git a/src/managers/common/utils.ts b/src/managers/common/utils.ts index 0d084da5..75476b4f 100644 --- a/src/managers/common/utils.ts +++ b/src/managers/common/utils.ts @@ -3,6 +3,7 @@ import path from 'path'; import { commands, ConfigurationTarget, l10n, window, workspace } from 'vscode'; import { PythonCommandRunConfiguration, PythonEnvironment, PythonEnvironmentApi } from '../../api'; import { traceLog, traceVerbose } from '../../common/logging'; +import { PEP440Version } from '../../common/utils/pep440Version'; import { isWindows } from '../../common/utils/platformUtils'; import { ShellConstants } from '../../features/common/shellConstants'; import { getDefaultEnvManagerSetting, setDefaultEnvManagerBroken } from '../../features/settings/settingHelpers'; @@ -21,51 +22,6 @@ export function isNumber(obj: unknown): obj is number { return typeof obj === 'number' && !isNaN(obj); } -export function shortVersion(version: string): string { - const pattern = /(\d)\.(\d+)(?:\.(\d+)?)?/gm; - const match = pattern.exec(version); - if (match) { - if (match[3]) { - return `${match[1]}.${match[2]}.${match[3]}`; - } - return `${match[1]}.${match[2]}.x`; - } - return version; -} - -export function isGreater(a: string | undefined, b: string | undefined): boolean { - if (!a && !b) { - return false; - } - if (!a) { - return false; - } - if (!b) { - return true; - } - - try { - const aParts = a.split('.'); - const bParts = b.split('.'); - for (let i = 0; i < aParts.length; i++) { - if (i >= bParts.length) { - return true; - } - const aPart = parseInt(aParts[i], 10); - const bPart = parseInt(bParts[i], 10); - if (aPart > bPart) { - return true; - } - if (aPart < bPart) { - return false; - } - } - } catch { - return false; - } - return false; -} - export function sortEnvironments(collection: PythonEnvironment[]): PythonEnvironment[] { return collection.sort((a, b) => { // Environments with errors should be sorted to the end @@ -76,7 +32,12 @@ export function sortEnvironments(collection: PythonEnvironment[]): PythonEnviron return -1; } if (a.version !== b.version) { - return isGreater(a.version, b.version) ? -1 : 1; + const av = PEP440Version.parse(a.version); + const bv = PEP440Version.parse(b.version); + if (av && bv) { + return PEP440Version.compare(bv, av); // descending + } + return a.version ? 1 : -1; } const value = a.name.localeCompare(b.name); if (value !== 0) { @@ -96,7 +57,9 @@ export function getLatest(collection: PythonEnvironment[]): PythonEnvironment | let latest = candidates[0]; for (const env of candidates) { - if (isGreater(env.version, latest.version)) { + const av = PEP440Version.parse(env.version); + const bv = PEP440Version.parse(latest.version); + if (av && bv && PEP440Version.compare(av, bv) > 0) { latest = env; } } @@ -114,31 +77,6 @@ export function pathForGitBash(binPath: string): string { return isWindows() ? binPath.replace(/\\/g, '/').replace(/^([a-zA-Z]):/, '/$1') : binPath; } -/** - * Compares two semantic version strings. Support sonly simple 1.1.1 style versions. - * @param version1 First version - * @param version2 Second version - * @returns -1 if version1 < version2, 0 if equal, 1 if version1 > version2 - */ -export function compareVersions(version1: string, version2: string): number { - const v1Parts = version1.split('.').map(Number); - const v2Parts = version2.split('.').map(Number); - - for (let i = 0; i < Math.max(v1Parts.length, v2Parts.length); i++) { - const v1Part = v1Parts[i] || 0; - const v2Part = v2Parts[i] || 0; - - if (v1Part > v2Part) { - return 1; - } - if (v1Part < v2Part) { - return -1; - } - } - - return 0; -} - function buildPwshActivationCommands(ps1Path: string): PythonCommandRunConfiguration[] { const commands: PythonCommandRunConfiguration[] = []; if (isWindows()) { diff --git a/src/managers/conda/condaStepBasedFlow.ts b/src/managers/conda/condaStepBasedFlow.ts index 6cf6ce80..be81acc6 100644 --- a/src/managers/conda/condaStepBasedFlow.ts +++ b/src/managers/conda/condaStepBasedFlow.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import { l10n, LogOutputChannel, QuickInputButtons, QuickPickItem, Uri } from 'vscode'; import { EnvironmentManager, PythonEnvironment, PythonEnvironmentApi } from '../../api'; import { CondaStrings } from '../../common/localize'; +import { PEP440Version } from '../../common/utils/pep440Version'; import { showInputBoxWithButtons, showQuickPickWithButtons } from '../../common/window.apis'; import { createNamedCondaEnvironment, @@ -113,19 +114,14 @@ async function selectPythonVersion(state: CondaCreationState): Promise { - const m = v.match(/^(\\d+)(?:\\.(\\d+))?/); - return { major: m ? Number(m[1]) : 0, minor: m && m[2] ? Number(m[2]) : 0 }; - }; - + // Sort versions descending using PEP 440 comparison versions = versions.sort((a, b) => { - const pa = parseMajorMinor(a as string); - const pb = parseMajorMinor(b as string); - if (pa.major !== pb.major) { - return pb.major - pa.major; - } // desc by major - return pb.minor - pa.minor; // desc by minor + const av = PEP440Version.parse(a as string); + const bv = PEP440Version.parse(b as string); + if (!av || !bv) { + return 0; + } + return PEP440Version.compare(bv, av); // descending }); if (!versions || versions.length === 0) { diff --git a/src/managers/conda/condaUtils.ts b/src/managers/conda/condaUtils.ts index 51b1988e..d75cd89a 100644 --- a/src/managers/conda/condaUtils.ts +++ b/src/managers/conda/condaUtils.ts @@ -34,6 +34,7 @@ import { pickProject } from '../../common/pickers/projects'; import { StopWatch } from '../../common/stopWatch'; import { createDeferred } from '../../common/utils/deferred'; import { untildify } from '../../common/utils/pathUtils'; +import { PEP440Version } from '../../common/utils/pep440Version'; import { isWindows } from '../../common/utils/platformUtils'; import { showErrorMessage, @@ -53,7 +54,7 @@ import { } from '../common/nativePythonFinder'; import { selectFromCommonPackagesToInstall } from '../common/pickers'; import { Installable } from '../common/types'; -import { shortVersion, sortEnvironments } from '../common/utils'; +import { sortEnvironments } from '../common/utils'; import { CondaEnvManager } from './condaEnvManager'; import { getCondaHookPs1Path, getLocalActivationScript, ShellCondaInitStatus } from './condaSourcingUtils'; import { createStepBasedCondaFlow } from './condaStepBasedFlow'; @@ -357,7 +358,7 @@ export async function getNamedCondaPythonInfo( envManager: EnvironmentManager, ): Promise { const { shellActivation, shellDeactivation } = await buildShellActivationMapForConda(prefix, envManager, name); - const sv = shortVersion(version); + const sv = PEP440Version.shortenVersionString(version); return { name: name, @@ -399,7 +400,7 @@ export async function getPrefixesCondaPythonInfo( conda: string, envManager: EnvironmentManager, ): Promise { - const sv = shortVersion(version); + const sv = PEP440Version.shortenVersionString(version); const { shellActivation, shellDeactivation } = await buildShellActivationMapForConda(prefix, envManager); @@ -993,19 +994,14 @@ export async function pickPythonVersion( ), ); - // Sort versions by major version (descending), ignoring minor/patch for simplicity - const parseMajorMinor = (v: string) => { - const m = v.match(/^(\d+)(?:\.(\d+))?/); - return { major: m ? Number(m[1]) : 0, minor: m && m[2] ? Number(m[2]) : 0 }; - }; - + // Sort versions descending using PEP 440 comparison versions = versions.sort((a, b) => { - const pa = parseMajorMinor(a); - const pb = parseMajorMinor(b); - if (pa.major !== pb.major) { - return pb.major - pa.major; - } // desc by major - return pb.minor - pa.minor; // desc by minor + const av = PEP440Version.parse(a); + const bv = PEP440Version.parse(b); + if (!av || !bv) { + return 0; + } + return PEP440Version.compare(bv, av); // descending }); if (!versions || versions.length === 0) { diff --git a/src/managers/pipenv/pipenvUtils.ts b/src/managers/pipenv/pipenvUtils.ts index 36cee2d3..e9c1eb47 100644 --- a/src/managers/pipenv/pipenvUtils.ts +++ b/src/managers/pipenv/pipenvUtils.ts @@ -17,6 +17,7 @@ import { ENVS_EXTENSION_ID } from '../../common/constants'; import { traceError, traceInfo, traceVerbose } from '../../common/logging'; import { getWorkspacePersistentState } from '../../common/persistentState'; import { untildify } from '../../common/utils/pathUtils'; +import { PEP440Version } from '../../common/utils/pep440Version'; import { getSettingWorkspaceScope } from '../../features/settings/settingHelpers'; import { isNativeEnvInfo, @@ -25,7 +26,7 @@ import { NativePythonEnvironmentKind, NativePythonFinder, } from '../common/nativePythonFinder'; -import { getShellActivationCommands, shortVersion } from '../common/utils'; +import { getShellActivationCommands } from '../common/utils'; export const PIPENV_PATH_KEY = `${ENVS_EXTENSION_ID}:pipenv:PIPENV_PATH`; export const PIPENV_WORKSPACE_KEY = `${ENVS_EXTENSION_ID}:pipenv:WORKSPACE_SELECTED`; @@ -115,7 +116,7 @@ async function nativeToPythonEnv( return undefined; } - const sv = shortVersion(info.version); + const sv = PEP440Version.shortenVersionString(info.version); const folderName = path.basename(info.prefix); const name = info.name || info.displayName || folderName; const displayName = info.displayName || `${folderName} (${sv})`; diff --git a/src/managers/poetry/poetryUtils.ts b/src/managers/poetry/poetryUtils.ts index e87b81dd..50267c36 100644 --- a/src/managers/poetry/poetryUtils.ts +++ b/src/managers/poetry/poetryUtils.ts @@ -8,6 +8,7 @@ import { ENVS_EXTENSION_ID } from '../../common/constants'; import { traceError, traceInfo } from '../../common/logging'; import { getWorkspacePersistentState } from '../../common/persistentState'; import { getUserHomeDir, normalizePath, untildify } from '../../common/utils/pathUtils'; +import { PEP440Version } from '../../common/utils/pep440Version'; import { isMac, isWindows } from '../../common/utils/platformUtils'; import { getSettingWorkspaceScope } from '../../features/settings/settingHelpers'; import { @@ -17,7 +18,7 @@ import { NativePythonEnvironmentKind, NativePythonFinder, } from '../common/nativePythonFinder'; -import { getShellActivationCommands, shortVersion, sortEnvironments } from '../common/utils'; +import { getShellActivationCommands, sortEnvironments } from '../common/utils'; /** * Checks if the POETRY_VIRTUALENVS_IN_PROJECT environment variable is set to a truthy value. @@ -341,7 +342,7 @@ export async function nativeToPythonEnv( return undefined; } - const sv = shortVersion(info.version); + const sv = PEP440Version.shortenVersionString(info.version); const name = info.name || info.displayName || path.basename(info.prefix); const displayName = info.displayName || `poetry (${sv})`; diff --git a/src/managers/pyenv/pyenvUtils.ts b/src/managers/pyenv/pyenvUtils.ts index ba4194ee..9af6ec37 100644 --- a/src/managers/pyenv/pyenvUtils.ts +++ b/src/managers/pyenv/pyenvUtils.ts @@ -13,6 +13,7 @@ import { ENVS_EXTENSION_ID } from '../../common/constants'; import { traceError, traceInfo } from '../../common/logging'; import { getWorkspacePersistentState } from '../../common/persistentState'; import { getUserHomeDir, normalizePath, untildify } from '../../common/utils/pathUtils'; +import { PEP440Version } from '../../common/utils/pep440Version'; import { isWindows } from '../../common/utils/platformUtils'; import { isNativeEnvInfo, @@ -21,7 +22,7 @@ import { NativePythonEnvironmentKind, NativePythonFinder, } from '../common/nativePythonFinder'; -import { shortVersion, sortEnvironments } from '../common/utils'; +import { sortEnvironments } from '../common/utils'; /** * Returns the pyenv root directory from the pyenv executable path. @@ -192,7 +193,7 @@ function nativeToPythonEnv( group = PYENV_ENVIRONMENTS; } - const sv = shortVersion(info.version); + const sv = PEP440Version.shortenVersionString(info.version); const name = info.name || info.displayName || path.basename(info.prefix); let displayName = info.displayName || `pyenv (${sv})`; if (info.kind === NativePythonEnvironmentKind.pyenvVirtualEnv) { From 6039cb2e0cbc3dae3191e4765fafe37ae8b6397b Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 3 Jun 2026 11:56:04 -0700 Subject: [PATCH 5/6] Refactor --- src/common/utils/pep440Version.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/common/utils/pep440Version.ts b/src/common/utils/pep440Version.ts index b581d183..3a2dcaf7 100644 --- a/src/common/utils/pep440Version.ts +++ b/src/common/utils/pep440Version.ts @@ -141,12 +141,12 @@ export class PEP440Version { /** The minor version number (second element of release), or 0 if absent. */ public get minor(): number { - return this.release.length > 1 ? this.release[1] : 0; + return this.release.at(1) ?? 0; } /** The micro/patch version number (third element of release), or 0 if absent. */ public get micro(): number { - return this.release.length > 2 ? this.release[2] : 0; + return this.release.at(2) ?? 0; } /** @@ -227,8 +227,8 @@ export class PEP440Version { // 2. Release segments (compare element-by-element, pad shorter with 0) const maxLen = Math.max(a.release.length, b.release.length); for (let i = 0; i < maxLen; i++) { - const av = i < a.release.length ? a.release[i] : 0; - const bv = i < b.release.length ? b.release[i] : 0; + const av = a.release.at(i) ?? 0; + const bv = b.release.at(i) ?? 0; if (av !== bv) { return av - bv; } From 767c6ebf959aa3e22c4300c7917f0564c137dde4 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Thu, 4 Jun 2026 14:32:41 -0700 Subject: [PATCH 6/6] Use @renovatebot/pep440 --- package-lock.json | 36 ++- package.json | 6 +- src/common/extVersion.ts | 8 +- src/common/utils/pep440Version.ts | 271 ------------------ src/common/utils/versionSpecifier.ts | 202 ------------- src/managers/builtin/pipUtils.ts | 4 +- src/managers/builtin/utils.ts | 5 +- src/managers/builtin/venvManager.ts | 5 +- src/managers/builtin/venvUtils.ts | 5 +- src/managers/common/utils.ts | 26 +- src/managers/conda/condaStepBasedFlow.ts | 8 +- src/managers/conda/condaUtils.ts | 14 +- src/managers/pipenv/pipenvUtils.ts | 5 +- src/managers/poetry/poetryUtils.ts | 5 +- src/managers/pyenv/pyenvUtils.ts | 5 +- src/test/common/pep440Version.unit.test.ts | 260 ++++------------- src/test/common/versionSpecifier.unit.test.ts | 193 ------------- 17 files changed, 145 insertions(+), 913 deletions(-) delete mode 100644 src/common/utils/pep440Version.ts delete mode 100644 src/common/utils/versionSpecifier.ts delete mode 100644 src/test/common/versionSpecifier.unit.test.ts diff --git a/package-lock.json b/package-lock.json index 7fea10e9..b3df350e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.33.0", "dependencies": { "@iarna/toml": "^2.2.5", + "@renovatebot/pep440": "^4.2.4", "@vscode/extension-telemetry": "^0.9.7", "@vscode/test-cli": "^0.0.10", "dotenv": "^16.4.5", @@ -589,6 +590,16 @@ "node": ">=14" } }, + "node_modules/@renovatebot/pep440": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@renovatebot/pep440/-/pep440-4.2.4.tgz", + "integrity": "sha512-r+Pwj9ud/5QOOHoSsD7mWzU0Qkj105UPOyPwihGAtbNCl9pqnWkEDNRM5an63QvsFsS9tt+X3o+npT3AKBiTVg==", + "license": "Apache-2.0", + "engines": { + "node": "^20.9.0 || ^22.11.0 || ^24", + "pnpm": ">=10.0.0" + } + }, "node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", @@ -801,6 +812,7 @@ "integrity": "sha512-D7DbgGFtsqIPIFMPJwCad9Gfi/hC0PWErRRHFnaCWoEDYi5tQUDiJCTmGUbBiLzjqAck4KcXt9Ayj0CNlIrF+w==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.16.0", "@typescript-eslint/types": "8.16.0", @@ -1509,6 +1521,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1783,6 +1796,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2521,6 +2535,7 @@ "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4864,6 +4879,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5537,6 +5553,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5663,6 +5680,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -5711,6 +5729,7 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -6332,6 +6351,11 @@ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "optional": true }, + "@renovatebot/pep440": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@renovatebot/pep440/-/pep440-4.2.4.tgz", + "integrity": "sha512-r+Pwj9ud/5QOOHoSsD7mWzU0Qkj105UPOyPwihGAtbNCl9pqnWkEDNRM5an63QvsFsS9tt+X3o+npT3AKBiTVg==" + }, "@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", @@ -6516,6 +6540,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.16.0.tgz", "integrity": "sha512-D7DbgGFtsqIPIFMPJwCad9Gfi/hC0PWErRRHFnaCWoEDYi5tQUDiJCTmGUbBiLzjqAck4KcXt9Ayj0CNlIrF+w==", "dev": true, + "peer": true, "requires": { "@typescript-eslint/scope-manager": "8.16.0", "@typescript-eslint/types": "8.16.0", @@ -7023,7 +7048,8 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true + "dev": true, + "peer": true }, "acorn-import-phases": { "version": "1.0.4", @@ -7210,6 +7236,7 @@ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, + "peer": true, "requires": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7720,6 +7747,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, + "peer": true, "requires": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9401,6 +9429,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, + "peer": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -9857,7 +9886,8 @@ "version": "5.3.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", - "dev": true + "dev": true, + "peer": true }, "uc.micro": { "version": "1.0.6", @@ -9943,6 +9973,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, + "peer": true, "requires": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -9976,6 +10007,7 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, + "peer": true, "requires": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", diff --git a/package.json b/package.json index df79269f..2328973c 100644 --- a/package.json +++ b/package.json @@ -124,7 +124,10 @@ "python-envs.workspaceSearchPaths": { "type": "array", "description": "%python-envs.workspaceSearchPaths.description%", - "default": [".venv", "*/.venv"], + "default": [ + ".venv", + "*/.venv" + ], "scope": "resource", "items": { "type": "string" @@ -714,6 +717,7 @@ }, "dependencies": { "@iarna/toml": "^2.2.5", + "@renovatebot/pep440": "^4.2.4", "@vscode/extension-telemetry": "^0.9.7", "@vscode/test-cli": "^0.0.10", "dotenv": "^16.4.5", diff --git a/src/common/extVersion.ts b/src/common/extVersion.ts index 47008e67..6a671b7d 100644 --- a/src/common/extVersion.ts +++ b/src/common/extVersion.ts @@ -1,7 +1,7 @@ +import { compare as pep440Compare, valid as pep440Valid } from '@renovatebot/pep440'; import { PYTHON_EXTENSION_ID } from './constants'; import { getExtension } from './extension.apis'; import { traceError } from './logging'; -import { PEP440Version } from './utils/pep440Version'; export function ensureCorrectVersion() { const extension = getExtension(PYTHON_EXTENSION_ID); @@ -9,9 +9,9 @@ export function ensureCorrectVersion() { return; } - const version = PEP440Version.parse(extension.packageJSON.version); - const minVersion = PEP440Version.parse('2024.23.0'); - if (version && minVersion && PEP440Version.compare(version, minVersion) >= 0) { + const version = pep440Valid(extension.packageJSON.version); + const minVersion = '2024.23.0'; + if (version && pep440Compare(version, minVersion) >= 0) { return; } traceError('Incompatible Python extension. Please update `ms-python.python` to version 2024.23 or later.'); diff --git a/src/common/utils/pep440Version.ts b/src/common/utils/pep440Version.ts deleted file mode 100644 index 3a2dcaf7..00000000 --- a/src/common/utils/pep440Version.ts +++ /dev/null @@ -1,271 +0,0 @@ -/** - * Represents a PEP 440 version. - * - * Format: [N!]N(.N)*[{a|b|rc}N][.postN][.devN][+local] - * See https://peps.python.org/pep-0440/ - */ - -/** Normalized pre-release phase. */ -export type PreReleasePhase = 'a' | 'b' | 'rc'; - -/** Raw pre-release labels accepted before normalization. */ -type PreReleaseLabelInput = 'a' | 'alpha' | 'b' | 'beta' | 'c' | 'rc' | 'pre' | 'preview'; - -/** - * PEP 440 version regex adapted from the Python `packaging` library. - * Captures: - * 1: epoch (e.g. "2") - * 2: release (e.g. "1.2.3") - * 3: pre-label (a|b|c|rc|alpha|beta|pre|preview) - * 4: pre-number - * 5: implicit post (e.g. "-1" form) - * 6: post-label (post|rev|r) - * 7: post-number - * 8: dev-label (dev) - * 9: dev-number - * 10: local (e.g. "ubuntu1") - */ -const PEP440_REGEX = - /^v?(?:([0-9]+)!)?([0-9]+(?:\.[0-9]+)*)(?:[-_.]?(a|b|c|rc|alpha|beta|pre|preview)[-_.]?([0-9]+)?)?(?:(?:-([0-9]+))|(?:[-_.]?(post|rev|r)[-_.]?([0-9]+)?))?(?:[-_.]?(dev)[-_.]?([0-9]+)?)?(?:\+([a-z0-9]+(?:[-_.][a-z0-9]+)*))?$/i; - -function normalizePreLabel(label: PreReleaseLabelInput): PreReleasePhase { - switch (label) { - case 'a': - case 'alpha': - return 'a'; - case 'b': - case 'beta': - return 'b'; - case 'c': - case 'rc': - case 'pre': - case 'preview': - return 'rc'; - } -} - -export class PEP440Version { - /** Version epoch, defaults to 0. */ - public readonly epoch: number; - /** Release segment numbers (e.g. [1, 2, 3] for "1.2.3"). */ - public readonly release: readonly number[]; - /** Pre-release phase: 'a', 'b', or 'rc', or undefined. */ - public readonly pre: PreReleasePhase | undefined; - /** Pre-release number (e.g. 2 in "rc2"), or undefined if no pre-release. */ - public readonly preNumber: number | undefined; - /** Post-release number, or undefined. */ - public readonly post: number | undefined; - /** Dev release number, or undefined. */ - public readonly dev: number | undefined; - /** Local version label (e.g. "ubuntu1"), or undefined. */ - public readonly local: string | undefined; - - /** - * @param release Release segment numbers (e.g. `[1, 2, 3]`). - * @param options Optional version segments. All inputs are normalized per PEP 440: - * - `pre`: alternate spellings (alpha, beta, c, preview, pre) are normalized to a/b/rc. - * - `preNumber`: defaults to 0 when `pre` is set. - * - `post`/`dev`: implicit number defaults to 0. - * - `local`: lowercased, separators (`-`, `_`) replaced with `.`. - * - `release`: trailing zero segments are trimmed (e.g. `[1, 0, 0]` → `[1]`). - */ - constructor( - release: readonly number[], - options?: { - epoch?: number; - pre?: PreReleaseLabelInput; - preNumber?: number; - post?: number; - dev?: number; - local?: string; - }, - ) { - this.epoch = options?.epoch ?? 0; - this.release = [...release]; - - // Normalize pre-release label and default number to 0 - this.pre = options?.pre !== undefined ? normalizePreLabel(options.pre) : undefined; - this.preNumber = options?.pre !== undefined ? (options?.preNumber ?? 0) : undefined; - - // Post and dev default to 0 when present (PEP 440: "1.0.post" == "1.0.post0") - this.post = options?.post; - this.dev = options?.dev; - - // Normalize local: lowercase, replace - and _ with . - this.local = options?.local?.toLowerCase().replace(/[-_]/g, '.'); - } - - /** The major version number (first element of release). */ - public get major(): number { - return this.release.length > 0 ? this.release[0] : 0; - } - - /** - * Parse a PEP 440 version string. Returns `undefined` if the string is not valid. - */ - public static parse(input: string): PEP440Version | undefined { - const match = PEP440_REGEX.exec(input.trim()); - if (!match) { - return undefined; - } - - const release = match[2].split('.').map((s) => parseInt(s, 10)); - - let pre: PreReleaseLabelInput | undefined; - let preNumber: number | undefined; - if (match[3] !== undefined) { - pre = match[3].toLowerCase() as PreReleaseLabelInput; - preNumber = match[4] !== undefined ? parseInt(match[4], 10) : 0; - } - - let post: number | undefined; - if (match[5] !== undefined) { - // Implicit post: "1.0-1" form - post = parseInt(match[5], 10); - } else if (match[6] !== undefined) { - post = match[7] !== undefined ? parseInt(match[7], 10) : 0; - } - - const dev = match[8] !== undefined ? (match[9] !== undefined ? parseInt(match[9], 10) : 0) : undefined; - const local = match[10]; - - return new PEP440Version(release, { - epoch: match[1] !== undefined ? parseInt(match[1], 10) : undefined, - pre, - preNumber, - post, - dev, - local, - }); - } - - /** The minor version number (second element of release), or 0 if absent. */ - public get minor(): number { - return this.release.at(1) ?? 0; - } - - /** The micro/patch version number (third element of release), or 0 if absent. */ - public get micro(): number { - return this.release.at(2) ?? 0; - } - - /** - * Returns a short display string: "X.Y.Z" if micro is present, otherwise "X.Y.x". - * Parses `input` first; returns it unchanged if not a valid version. - */ - public static shortenVersionString(input: string): string { - const v = PEP440Version.parse(input); - if (!v) { - return input; - } - return v.release.length >= 3 ? `${v.major}.${v.minor}.${v.micro}` : `${v.major}.${v.minor}.x`; - } - - /** Whether this version is a pre-release (has pre or dev segment). */ - public get isPreRelease(): boolean { - return this.pre !== undefined || this.dev !== undefined; - } - - /** Whether this version is a post-release. */ - public get isPostRelease(): boolean { - return this.post !== undefined; - } - - /** Whether this version is a dev release. */ - public get isDevRelease(): boolean { - return this.dev !== undefined; - } - - /** Whether this version has a local segment. */ - public get isLocal(): boolean { - return this.local !== undefined; - } - - /** Returns the normalized PEP 440 string representation. */ - public toString(): string { - const parts: string[] = []; - - if (this.epoch !== 0) { - parts.push(`${this.epoch}!`); - } - - parts.push(this.release.join('.')); - - if (this.pre !== undefined) { - parts.push(`${this.pre}${this.preNumber ?? 0}`); - } - - if (this.post !== undefined) { - parts.push(`.post${this.post}`); - } - - if (this.dev !== undefined) { - parts.push(`.dev${this.dev}`); - } - - if (this.local !== undefined) { - parts.push(`+${this.local}`); - } - - return parts.join(''); - } - - /** - * Compare two versions. Returns negative if `a < b`, - * 0 if equal, positive if `a > b`. - * - * Local versions are not considered in ordering per PEP 440. - * - * PEP 440 ordering: .devN < aN < bN < rcN < (final) < .postN - */ - public static compare(a: PEP440Version, b: PEP440Version): number { - // 1. Epoch - if (a.epoch !== b.epoch) { - return a.epoch - b.epoch; - } - - // 2. Release segments (compare element-by-element, pad shorter with 0) - const maxLen = Math.max(a.release.length, b.release.length); - for (let i = 0; i < maxLen; i++) { - const av = a.release.at(i) ?? 0; - const bv = b.release.at(i) ?? 0; - if (av !== bv) { - return av - bv; - } - } - - // 3. Pre/dev/post sort key comparison - // Sort key is [prePhase, preNum, post, dev] where: - // - prePhase: a=-3, b=-2, rc=-1, absent=0 - // - preNum: number or 0 - // - post: number if present, -1 if absent - // - dev: number if present, Infinity if absent (final sorts after dev) - const aKey = PEP440Version.sortKey(a); - const bKey = PEP440Version.sortKey(b); - for (let i = 0; i < aKey.length; i++) { - if (aKey[i] !== bKey[i]) { - return aKey[i] < bKey[i] ? -1 : 1; - } - } - - return 0; - } - - private static readonly PRE_PHASE_ORDER: Record = { a: -3, b: -2, rc: -1 }; - - private static sortKey(v: PEP440Version): [number, number, number, number] { - // Special case from Python `packaging`: dev-only releases (no pre, no post) - // must sort before all pre-releases. Without this, 1.0.dev0 would sort - // after 1.0a0 because "no pre" normally sorts after all pre phases. - let prePhase: number; - if (v.pre === undefined && v.post === undefined && v.dev !== undefined) { - prePhase = -Infinity; - } else { - prePhase = v.pre !== undefined ? PEP440Version.PRE_PHASE_ORDER[v.pre] : 0; - } - const preNum = v.preNumber ?? 0; - const post = v.post ?? -1; - const dev = v.dev ?? Infinity; - return [prePhase, preNum, post, dev]; - } -} diff --git a/src/common/utils/versionSpecifier.ts b/src/common/utils/versionSpecifier.ts deleted file mode 100644 index e8acbe2c..00000000 --- a/src/common/utils/versionSpecifier.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * PEP 440 version specifiers and constraints. - * - * A `VersionSpecifier` represents a single clause like `>=1.2.3` or `==1.2.*`. - * A `VersionConstraint` represents a comma-separated set like `>=1.2,<2.0`. - * - * See https://peps.python.org/pep-0440/#version-specifiers - */ - -import { PEP440Version } from './pep440Version'; - -/** Operators supported by PEP 440 version specifiers. */ -export type VersionOp = '==' | '!=' | '<=' | '>=' | '<' | '>' | '~=' | '==='; - -const VALID_OPS: readonly VersionOp[] = ['===', '~=', '==', '!=', '<=', '>=', '<', '>']; - -/** - * Regex to parse a single specifier clause. - * Match the operator first (longest-match order), then optional whitespace, - * then the version (with optional trailing `.*` wildcard). - */ -const SPECIFIER_REGEX = new RegExp( - `^(${VALID_OPS.join('|')})\\s*` + - `(v?(?:[0-9]+!)?[0-9]+(?:\\.[0-9]+)*` + - `(?:[-_.]?(?:a|b|c|rc|alpha|beta|pre|preview)[-_.]?[0-9]*)?` + - `(?:(?:-[0-9]+)|(?:[-_.]?(?:post|rev|r)[-_.]?[0-9]*))?` + - `(?:[-_.]?dev[-_.]?[0-9]*)?` + - `(?:\\+[a-z0-9]+(?:[-_.][a-z0-9]+)*)?)` + - `(\\.\\*)?$`, - 'i', -); - -/** - * A single version specifier clause (e.g. `>=1.2.3` or `==1.2.*`). - */ -export class VersionSpecifier { - /** The comparison operator. */ - public readonly op: VersionOp; - /** The version to compare against. */ - public readonly version: PEP440Version; - /** Whether the specifier uses a wildcard (only valid with `==` and `!=`). */ - public readonly wildcard: boolean; - - constructor(op: VersionOp, version: PEP440Version, wildcard: boolean = false) { - this.op = op; - this.version = version; - this.wildcard = wildcard; - } - - /** - * Parse a single specifier clause like `>=1.2.3` or `==1.2.*`. - * Returns `undefined` if the string is not valid. - */ - public static parse(input: string): VersionSpecifier | undefined { - const match = SPECIFIER_REGEX.exec(input.trim()); - if (!match) { - return undefined; - } - - const op = match[1] as VersionOp; - const versionStr = match[2]; - const wildcard = match[3] === '.*'; - - // Wildcards are only valid with == and != - if (wildcard && op !== '==' && op !== '!=') { - return undefined; - } - - const version = PEP440Version.parse(versionStr); - if (!version) { - return undefined; - } - - return new VersionSpecifier(op, version, wildcard); - } - - /** - * Check whether a candidate version satisfies this specifier. - */ - public contains(candidate: PEP440Version): boolean { - switch (this.op) { - case '==': - return this.wildcard - ? this.prefixMatch(candidate) - : PEP440Version.compare(candidate, this.version) === 0; - case '!=': - return this.wildcard - ? !this.prefixMatch(candidate) - : PEP440Version.compare(candidate, this.version) !== 0; - case '<': - return PEP440Version.compare(candidate, this.version) < 0; - case '<=': - return PEP440Version.compare(candidate, this.version) <= 0; - case '>': - return PEP440Version.compare(candidate, this.version) > 0; - case '>=': - return PEP440Version.compare(candidate, this.version) >= 0; - case '~=': - return this.compatibleMatch(candidate); - case '===': - return candidate.toString() === this.version.toString(); - } - } - - /** - * Prefix match for wildcard specifiers (`==1.2.*`). - * Checks that the candidate's release starts with the specifier's release segments - * and the epoch matches. - */ - private prefixMatch(candidate: PEP440Version): boolean { - if (candidate.epoch !== this.version.epoch) { - return false; - } - const prefix = this.version.release; - if (candidate.release.length < prefix.length) { - return false; - } - for (let i = 0; i < prefix.length; i++) { - if (candidate.release[i] !== prefix[i]) { - return false; - } - } - return true; - } - - /** - * Compatible release match (`~=`). - * `~=X.Y.Z` is equivalent to `>=X.Y.Z, ==X.Y.*`. - * `~=X.Y` is equivalent to `>=X.Y, ==X.*`. - */ - private compatibleMatch(candidate: PEP440Version): boolean { - // Must be >= the specified version - if (PEP440Version.compare(candidate, this.version) < 0) { - return false; - } - // Must share the same prefix (all segments except the last) - const release = this.version.release; - const prefix = release.slice(0, release.length - 1); - if (candidate.epoch !== this.version.epoch) { - return false; - } - for (let i = 0; i < prefix.length; i++) { - const cv = i < candidate.release.length ? candidate.release[i] : 0; - if (cv !== prefix[i]) { - return false; - } - } - return true; - } - - /** Returns the string representation (e.g. `>=1.2.3`, `==1.2.*`). */ - public toString(): string { - const suffix = this.wildcard ? '.*' : ''; - return `${this.op}${this.version}${suffix}`; - } -} - -/** - * A set of version specifier clauses joined by commas (e.g. `>=1.2,<2.0`). - * A candidate version must satisfy **all** clauses. - */ -export class VersionConstraint { - public readonly specifiers: readonly VersionSpecifier[]; - - constructor(specifiers: readonly VersionSpecifier[]) { - this.specifiers = specifiers; - } - - /** - * Parse a comma-separated version constraint string like `>=1.2,<2.0`. - * Returns `undefined` if any clause is invalid. - */ - public static parse(input: string): VersionConstraint | undefined { - const parts = input.split(',').map((s) => s.trim()); - if (parts.length === 0 || (parts.length === 1 && parts[0] === '')) { - return undefined; - } - - const specifiers: VersionSpecifier[] = []; - for (const part of parts) { - const spec = VersionSpecifier.parse(part); - if (!spec) { - return undefined; - } - specifiers.push(spec); - } - - return new VersionConstraint(specifiers); - } - - /** - * Check whether a candidate version satisfies all specifiers in this constraint. - */ - public contains(candidate: PEP440Version): boolean { - return this.specifiers.every((s) => s.contains(candidate)); - } - - /** Returns the comma-separated string representation. */ - public toString(): string { - return this.specifiers.map((s) => s.toString()).join(','); - } -} diff --git a/src/managers/builtin/pipUtils.ts b/src/managers/builtin/pipUtils.ts index d2ea1a83..d510ebb4 100644 --- a/src/managers/builtin/pipUtils.ts +++ b/src/managers/builtin/pipUtils.ts @@ -1,4 +1,5 @@ import * as tomljs from '@iarna/toml'; +import { valid as pep440Valid } from '@renovatebot/pep440'; import * as fse from 'fs-extra'; import * as path from 'path'; import { l10n, LogOutputChannel, ProgressLocation, QuickInputButtons, QuickPickItem, Uri, window } from 'vscode'; @@ -6,7 +7,6 @@ import { PackageManagementOptions, PythonEnvironment, PythonEnvironmentApi, Pyth import { EXTENSION_ROOT_DIR } from '../../common/constants'; import { PackageManagement, Pickers, VenvManagerStrings } from '../../common/localize'; import { traceInfo } from '../../common/logging'; -import { PEP440Version } from '../../common/utils/pep440Version'; import { showQuickPickWithButtons, withProgress } from '../../common/window.apis'; import { findFiles } from '../../common/workspace.apis'; import { selectFromCommonPackagesToInstall, selectFromInstallableToInstall } from '../common/pickers'; @@ -57,7 +57,7 @@ export function validatePyprojectToml(toml: PyprojectToml): string | undefined { if (version.length === 0) { return l10n.t('Version cannot be empty in pyproject.toml.'); } - if (!PEP440Version.parse(version)) { + if (!pep440Valid(version)) { return l10n.t('Invalid version "{0}" in pyproject.toml.', version); } } diff --git a/src/managers/builtin/utils.ts b/src/managers/builtin/utils.ts index 20edc3c2..89ff29bb 100644 --- a/src/managers/builtin/utils.ts +++ b/src/managers/builtin/utils.ts @@ -13,7 +13,6 @@ import { getExtension } from '../../common/extension.apis'; import { Common, PixiStrings, SysManagerStrings } from '../../common/localize'; import { traceInfo, traceVerbose } from '../../common/logging'; import { getGlobalPersistentState } from '../../common/persistentState'; -import { PEP440Version } from '../../common/utils/pep440Version'; import { showInformationMessage, withProgress } from '../../common/window.apis'; import { openExtension } from '../../common/workbenchCommands'; import { @@ -22,7 +21,7 @@ import { NativePythonEnvironmentKind, NativePythonFinder, } from '../common/nativePythonFinder'; -import { sortEnvironments } from '../common/utils'; +import { shortenVersionString, sortEnvironments } from '../common/utils'; import { runPython, runUV, shouldUseUv } from './helpers'; import { parsePipListJson, PipPackage } from './pipListUtils'; @@ -81,7 +80,7 @@ function getKindName(kind: NativePythonEnvironmentKind | undefined): string | un function getPythonInfo(env: NativeEnvInfo): PythonEnvironmentInfo { if (env.executable && env.version && env.prefix) { const kindName = getKindName(env.kind); - const sv = PEP440Version.shortenVersionString(env.version); + const sv = shortenVersionString(env.version); const name = kindName ? `Python ${sv} (${kindName})` : `Python ${sv}`; const displayName = kindName ? `Python ${sv} (${kindName})` : `Python ${sv}`; const shortDisplayName = kindName ? `${sv} (${kindName})` : `${sv}`; diff --git a/src/managers/builtin/venvManager.ts b/src/managers/builtin/venvManager.ts index 0ee8d1d1..866c87d0 100644 --- a/src/managers/builtin/venvManager.ts +++ b/src/managers/builtin/venvManager.ts @@ -33,12 +33,11 @@ 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 { PEP440Version } from '../../common/utils/pep440Version'; import { showErrorMessage, showInformationMessage, withProgress } from '../../common/window.apis'; import { findParentIfFile } from '../../features/envCommands'; import { getProjectFsPathForScope, tryFastPathGet } from '../common/fastPath'; import { NativePythonFinder } from '../common/nativePythonFinder'; -import { getLatest, sortEnvironments } from '../common/utils'; +import { getLatest, shortenVersionString, sortEnvironments } from '../common/utils'; import { promptInstallPythonViaUv } from './uvPythonInstaller'; import { clearVenvCache, @@ -118,7 +117,7 @@ export class VenvManager implements EnvironmentManager { description: l10n.t('Create a virtual environment in workspace root'), detail: l10n.t( 'Uses Python version {0} and installs workspace dependencies.', - PEP440Version.shortenVersionString(this.globalEnv.version), + shortenVersionString(this.globalEnv.version), ), }; } diff --git a/src/managers/builtin/venvUtils.ts b/src/managers/builtin/venvUtils.ts index 6a27062b..4f06b3c3 100644 --- a/src/managers/builtin/venvUtils.ts +++ b/src/managers/builtin/venvUtils.ts @@ -10,7 +10,6 @@ import { getWorkspacePersistentState } from '../../common/persistentState'; import { EventNames } from '../../common/telemetry/constants'; import { sendTelemetryEvent } from '../../common/telemetry/sender'; import { normalizePath } from '../../common/utils/pathUtils'; -import { PEP440Version } from '../../common/utils/pep440Version'; import { showErrorMessage, showOpenDialog, @@ -25,7 +24,7 @@ import { NativePythonEnvironmentKind, NativePythonFinder, } from '../common/nativePythonFinder'; -import { getShellActivationCommands, sortEnvironments } from '../common/utils'; +import { getShellActivationCommands, shortenVersionString, sortEnvironments } from '../common/utils'; import { runPython, runUV, shouldUseUv } from './helpers'; import { getProjectInstallable, PipPackages, shouldProceedAfterPyprojectValidation } from './pipUtils'; import { resolveSystemPythonEnvironmentPath } from './utils'; @@ -165,7 +164,7 @@ async function getPythonInfo(env: NativeEnvInfo): Promise if (env.executable && env.version && env.prefix) { const venvName = env.name ?? getName(env.executable); - const sv = PEP440Version.shortenVersionString(env.version); + const sv = shortenVersionString(env.version); const name = `${venvName} (${sv})`; let description = undefined; if (env.kind === NativePythonEnvironmentKind.venvUv) { diff --git a/src/managers/common/utils.ts b/src/managers/common/utils.ts index 75476b4f..ba7390c4 100644 --- a/src/managers/common/utils.ts +++ b/src/managers/common/utils.ts @@ -1,9 +1,9 @@ +import { major, minor, patch, compare as pep440Compare, valid as pep440Valid } from '@renovatebot/pep440'; import * as fs from 'fs-extra'; import path from 'path'; import { commands, ConfigurationTarget, l10n, window, workspace } from 'vscode'; import { PythonCommandRunConfiguration, PythonEnvironment, PythonEnvironmentApi } from '../../api'; import { traceLog, traceVerbose } from '../../common/logging'; -import { PEP440Version } from '../../common/utils/pep440Version'; import { isWindows } from '../../common/utils/platformUtils'; import { ShellConstants } from '../../features/common/shellConstants'; import { getDefaultEnvManagerSetting, setDefaultEnvManagerBroken } from '../../features/settings/settingHelpers'; @@ -22,6 +22,20 @@ export function isNumber(obj: unknown): obj is number { return typeof obj === 'number' && !isNaN(obj); } +/** + * 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. + */ +export function shortenVersionString(input: string): string { + if (!pep440Valid(input)) { + return input; + } + const p = patch(input); + return p !== 0 || input.split('.').length >= 3 + ? `${major(input)}.${minor(input)}.${p}` + : `${major(input)}.${minor(input)}.x`; +} + export function sortEnvironments(collection: PythonEnvironment[]): PythonEnvironment[] { return collection.sort((a, b) => { // Environments with errors should be sorted to the end @@ -32,10 +46,8 @@ export function sortEnvironments(collection: PythonEnvironment[]): PythonEnviron return -1; } if (a.version !== b.version) { - const av = PEP440Version.parse(a.version); - const bv = PEP440Version.parse(b.version); - if (av && bv) { - return PEP440Version.compare(bv, av); // descending + if (pep440Valid(a.version) && pep440Valid(b.version)) { + return pep440Compare(b.version, a.version); // descending } return a.version ? 1 : -1; } @@ -57,9 +69,7 @@ export function getLatest(collection: PythonEnvironment[]): PythonEnvironment | let latest = candidates[0]; for (const env of candidates) { - const av = PEP440Version.parse(env.version); - const bv = PEP440Version.parse(latest.version); - if (av && bv && PEP440Version.compare(av, bv) > 0) { + if (pep440Valid(env.version) && pep440Valid(latest.version) && pep440Compare(env.version, latest.version) > 0) { latest = env; } } diff --git a/src/managers/conda/condaStepBasedFlow.ts b/src/managers/conda/condaStepBasedFlow.ts index be81acc6..60cc4bc3 100644 --- a/src/managers/conda/condaStepBasedFlow.ts +++ b/src/managers/conda/condaStepBasedFlow.ts @@ -1,9 +1,9 @@ +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'; import { EnvironmentManager, PythonEnvironment, PythonEnvironmentApi } from '../../api'; import { CondaStrings } from '../../common/localize'; -import { PEP440Version } from '../../common/utils/pep440Version'; import { showInputBoxWithButtons, showQuickPickWithButtons } from '../../common/window.apis'; import { createNamedCondaEnvironment, @@ -116,12 +116,10 @@ async function selectPythonVersion(state: CondaCreationState): Promise { - const av = PEP440Version.parse(a as string); - const bv = PEP440Version.parse(b as string); - if (!av || !bv) { + if (!pep440Valid(a as string) || !pep440Valid(b as string)) { return 0; } - return PEP440Version.compare(bv, av); // descending + return pep440Compare(b as string, a as string); // descending }); if (!versions || versions.length === 0) { diff --git a/src/managers/conda/condaUtils.ts b/src/managers/conda/condaUtils.ts index d75cd89a..b526b8ed 100644 --- a/src/managers/conda/condaUtils.ts +++ b/src/managers/conda/condaUtils.ts @@ -1,3 +1,4 @@ +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'; @@ -34,7 +35,6 @@ import { pickProject } from '../../common/pickers/projects'; import { StopWatch } from '../../common/stopWatch'; import { createDeferred } from '../../common/utils/deferred'; import { untildify } from '../../common/utils/pathUtils'; -import { PEP440Version } from '../../common/utils/pep440Version'; import { isWindows } from '../../common/utils/platformUtils'; import { showErrorMessage, @@ -54,7 +54,7 @@ import { } from '../common/nativePythonFinder'; import { selectFromCommonPackagesToInstall } from '../common/pickers'; import { Installable } from '../common/types'; -import { sortEnvironments } from '../common/utils'; +import { shortenVersionString, sortEnvironments } from '../common/utils'; import { CondaEnvManager } from './condaEnvManager'; import { getCondaHookPs1Path, getLocalActivationScript, ShellCondaInitStatus } from './condaSourcingUtils'; import { createStepBasedCondaFlow } from './condaStepBasedFlow'; @@ -358,7 +358,7 @@ export async function getNamedCondaPythonInfo( envManager: EnvironmentManager, ): Promise { const { shellActivation, shellDeactivation } = await buildShellActivationMapForConda(prefix, envManager, name); - const sv = PEP440Version.shortenVersionString(version); + const sv = shortenVersionString(version); return { name: name, @@ -400,7 +400,7 @@ export async function getPrefixesCondaPythonInfo( conda: string, envManager: EnvironmentManager, ): Promise { - const sv = PEP440Version.shortenVersionString(version); + const sv = shortenVersionString(version); const { shellActivation, shellDeactivation } = await buildShellActivationMapForConda(prefix, envManager); @@ -996,12 +996,10 @@ export async function pickPythonVersion( // Sort versions descending using PEP 440 comparison versions = versions.sort((a, b) => { - const av = PEP440Version.parse(a); - const bv = PEP440Version.parse(b); - if (!av || !bv) { + if (!pep440Valid(a) || !pep440Valid(b)) { return 0; } - return PEP440Version.compare(bv, av); // descending + return pep440Compare(b, a); // descending }); if (!versions || versions.length === 0) { diff --git a/src/managers/pipenv/pipenvUtils.ts b/src/managers/pipenv/pipenvUtils.ts index e9c1eb47..55e375e9 100644 --- a/src/managers/pipenv/pipenvUtils.ts +++ b/src/managers/pipenv/pipenvUtils.ts @@ -17,7 +17,6 @@ import { ENVS_EXTENSION_ID } from '../../common/constants'; import { traceError, traceInfo, traceVerbose } from '../../common/logging'; import { getWorkspacePersistentState } from '../../common/persistentState'; import { untildify } from '../../common/utils/pathUtils'; -import { PEP440Version } from '../../common/utils/pep440Version'; import { getSettingWorkspaceScope } from '../../features/settings/settingHelpers'; import { isNativeEnvInfo, @@ -26,7 +25,7 @@ import { NativePythonEnvironmentKind, NativePythonFinder, } from '../common/nativePythonFinder'; -import { getShellActivationCommands } from '../common/utils'; +import { getShellActivationCommands, shortenVersionString } from '../common/utils'; export const PIPENV_PATH_KEY = `${ENVS_EXTENSION_ID}:pipenv:PIPENV_PATH`; export const PIPENV_WORKSPACE_KEY = `${ENVS_EXTENSION_ID}:pipenv:WORKSPACE_SELECTED`; @@ -116,7 +115,7 @@ async function nativeToPythonEnv( return undefined; } - const sv = PEP440Version.shortenVersionString(info.version); + const sv = shortenVersionString(info.version); const folderName = path.basename(info.prefix); const name = info.name || info.displayName || folderName; const displayName = info.displayName || `${folderName} (${sv})`; diff --git a/src/managers/poetry/poetryUtils.ts b/src/managers/poetry/poetryUtils.ts index 50267c36..9d14e1cf 100644 --- a/src/managers/poetry/poetryUtils.ts +++ b/src/managers/poetry/poetryUtils.ts @@ -8,7 +8,6 @@ import { ENVS_EXTENSION_ID } from '../../common/constants'; import { traceError, traceInfo } from '../../common/logging'; import { getWorkspacePersistentState } from '../../common/persistentState'; import { getUserHomeDir, normalizePath, untildify } from '../../common/utils/pathUtils'; -import { PEP440Version } from '../../common/utils/pep440Version'; import { isMac, isWindows } from '../../common/utils/platformUtils'; import { getSettingWorkspaceScope } from '../../features/settings/settingHelpers'; import { @@ -18,7 +17,7 @@ import { NativePythonEnvironmentKind, NativePythonFinder, } from '../common/nativePythonFinder'; -import { getShellActivationCommands, sortEnvironments } from '../common/utils'; +import { getShellActivationCommands, shortenVersionString, sortEnvironments } from '../common/utils'; /** * Checks if the POETRY_VIRTUALENVS_IN_PROJECT environment variable is set to a truthy value. @@ -342,7 +341,7 @@ export async function nativeToPythonEnv( return undefined; } - const sv = PEP440Version.shortenVersionString(info.version); + const sv = shortenVersionString(info.version); const name = info.name || info.displayName || path.basename(info.prefix); const displayName = info.displayName || `poetry (${sv})`; diff --git a/src/managers/pyenv/pyenvUtils.ts b/src/managers/pyenv/pyenvUtils.ts index 9af6ec37..2e49ad0e 100644 --- a/src/managers/pyenv/pyenvUtils.ts +++ b/src/managers/pyenv/pyenvUtils.ts @@ -13,7 +13,6 @@ import { ENVS_EXTENSION_ID } from '../../common/constants'; import { traceError, traceInfo } from '../../common/logging'; import { getWorkspacePersistentState } from '../../common/persistentState'; import { getUserHomeDir, normalizePath, untildify } from '../../common/utils/pathUtils'; -import { PEP440Version } from '../../common/utils/pep440Version'; import { isWindows } from '../../common/utils/platformUtils'; import { isNativeEnvInfo, @@ -22,7 +21,7 @@ import { NativePythonEnvironmentKind, NativePythonFinder, } from '../common/nativePythonFinder'; -import { sortEnvironments } from '../common/utils'; +import { shortenVersionString, sortEnvironments } from '../common/utils'; /** * Returns the pyenv root directory from the pyenv executable path. @@ -193,7 +192,7 @@ function nativeToPythonEnv( group = PYENV_ENVIRONMENTS; } - const sv = PEP440Version.shortenVersionString(info.version); + const sv = shortenVersionString(info.version); const name = info.name || info.displayName || path.basename(info.prefix); let displayName = info.displayName || `pyenv (${sv})`; if (info.kind === NativePythonEnvironmentKind.pyenvVirtualEnv) { diff --git a/src/test/common/pep440Version.unit.test.ts b/src/test/common/pep440Version.unit.test.ts index fc521460..3457bbc2 100644 --- a/src/test/common/pep440Version.unit.test.ts +++ b/src/test/common/pep440Version.unit.test.ts @@ -1,246 +1,108 @@ +import { compare as pep440Compare, valid as pep440Valid } from '@renovatebot/pep440'; import assert from 'node:assert'; -import { PEP440Version } from '../../common/utils/pep440Version'; +import { shortenVersionString } from '../../managers/common/utils'; -suite('PEP440Version', () => { - suite('parse', () => { - test('simple release', () => { - const v = PEP440Version.parse('1.2.3'); - assert.ok(v); - assert.deepStrictEqual([...v.release], [1, 2, 3]); - assert.strictEqual(v.epoch, 0); - assert.strictEqual(v.pre, undefined); - assert.strictEqual(v.post, undefined); - assert.strictEqual(v.dev, undefined); - assert.strictEqual(v.local, undefined); +suite('pep440Version', () => { + suite('pep440Valid', () => { + test('accepts simple release', () => { + assert.strictEqual(pep440Valid('1.2.3'), '1.2.3'); }); - test('single segment release', () => { - const v = PEP440Version.parse('42'); - assert.ok(v); - assert.deepStrictEqual([...v.release], [42]); + test('accepts single segment', () => { + assert.strictEqual(pep440Valid('42'), '42'); }); - test('epoch', () => { - const v = PEP440Version.parse('2!1.0'); - assert.ok(v); - assert.strictEqual(v.epoch, 2); + test('accepts epoch', () => { + assert.ok(pep440Valid('2!1.0')); }); - test('alpha pre-release', () => { - const v = PEP440Version.parse('1.0a1'); - assert.ok(v); - assert.strictEqual(v.pre, 'a'); - assert.strictEqual(v.preNumber, 1); + test('accepts pre-release versions', () => { + assert.ok(pep440Valid('1.0a1')); + assert.ok(pep440Valid('1.0b2')); + assert.ok(pep440Valid('1.0rc3')); + assert.ok(pep440Valid('1.0alpha1')); + assert.ok(pep440Valid('1.0beta2')); + assert.ok(pep440Valid('1.0c1')); + assert.ok(pep440Valid('1.0preview1')); }); - test('beta pre-release', () => { - const v = PEP440Version.parse('1.0b2'); - assert.ok(v); - assert.strictEqual(v.pre, 'b'); - assert.strictEqual(v.preNumber, 2); + test('accepts post release', () => { + assert.ok(pep440Valid('1.0.post1')); }); - test('rc pre-release', () => { - const v = PEP440Version.parse('1.0rc3'); - assert.ok(v); - assert.strictEqual(v.pre, 'rc'); - assert.strictEqual(v.preNumber, 3); + test('accepts implicit post release (dash form)', () => { + assert.ok(pep440Valid('1.0-1')); }); - test('normalizes "alpha" to "a"', () => { - const v = PEP440Version.parse('1.0alpha1'); - assert.ok(v); - assert.strictEqual(v.pre, 'a'); + test('accepts dev release', () => { + assert.ok(pep440Valid('1.0.dev3')); }); - test('normalizes "beta" to "b"', () => { - const v = PEP440Version.parse('1.0beta2'); - assert.ok(v); - assert.strictEqual(v.pre, 'b'); + test('accepts local version', () => { + assert.ok(pep440Valid('1.0+ubuntu1')); }); - test('normalizes "c" to "rc"', () => { - const v = PEP440Version.parse('1.0c1'); - assert.ok(v); - assert.strictEqual(v.pre, 'rc'); + test('accepts leading v', () => { + assert.ok(pep440Valid('v1.0')); }); - test('normalizes "preview" to "rc"', () => { - const v = PEP440Version.parse('1.0preview1'); - assert.ok(v); - assert.strictEqual(v.pre, 'rc'); - }); - - test('post release', () => { - const v = PEP440Version.parse('1.0.post1'); - assert.ok(v); - assert.strictEqual(v.post, 1); - }); - - test('implicit post release (dash form)', () => { - const v = PEP440Version.parse('1.0-1'); - assert.ok(v); - assert.strictEqual(v.post, 1); - }); - - test('dev release', () => { - const v = PEP440Version.parse('1.0.dev3'); - assert.ok(v); - assert.strictEqual(v.dev, 3); - }); - - test('local version', () => { - const v = PEP440Version.parse('1.0+ubuntu1'); - assert.ok(v); - assert.strictEqual(v.local, 'ubuntu1'); - }); - - test('leading v is accepted', () => { - const v = PEP440Version.parse('v1.0'); - assert.ok(v); - assert.strictEqual(v.major, 1); - }); - - test('pre-release without number defaults to 0', () => { - const v = PEP440Version.parse('1.0a'); - assert.ok(v); - assert.strictEqual(v.preNumber, 0); - }); - - test('dev without number defaults to 0', () => { - const v = PEP440Version.parse('1.0.dev'); - assert.ok(v); - assert.strictEqual(v.dev, 0); - }); - - test('returns undefined for invalid version', () => { - assert.strictEqual(PEP440Version.parse('not-a-version'), undefined); - assert.strictEqual(PEP440Version.parse(''), undefined); - assert.strictEqual(PEP440Version.parse('abc.def'), undefined); - }); - }); - - suite('constructor normalization', () => { - test('preserves trailing zeros in release', () => { - const v = new PEP440Version([1, 0, 0]); - assert.deepStrictEqual([...v.release], [1, 0, 0]); - }); - - test('normalizes local separators', () => { - const v = new PEP440Version([1], { local: 'Ubuntu-1_2' }); - assert.strictEqual(v.local, 'ubuntu.1.2'); - }); - - test('normalizes pre label "alpha" to "a"', () => { - const v = new PEP440Version([1], { pre: 'alpha' }); - assert.strictEqual(v.pre, 'a'); - assert.strictEqual(v.preNumber, 0); + test('rejects invalid versions', () => { + assert.strictEqual(pep440Valid('not-a-version'), null); + assert.strictEqual(pep440Valid(''), null); + assert.strictEqual(pep440Valid('abc.def'), null); }); }); - suite('toString', () => { - test('simple version', () => { - assert.strictEqual(PEP440Version.parse('1.2.3')?.toString(), '1.2.3'); - }); - - test('epoch included when non-zero', () => { - assert.strictEqual(PEP440Version.parse('2!1.0')?.toString(), '2!1.0'); - }); - - test('pre-release', () => { - assert.strictEqual(PEP440Version.parse('1.0a1')?.toString(), '1.0a1'); - assert.strictEqual(PEP440Version.parse('1.0rc3')?.toString(), '1.0rc3'); - }); - - test('post-release', () => { - assert.strictEqual(PEP440Version.parse('1.0.post1')?.toString(), '1.0.post1'); - }); - - test('dev release', () => { - assert.strictEqual(PEP440Version.parse('1.0.dev5')?.toString(), '1.0.dev5'); - }); - - test('local version', () => { - assert.strictEqual(PEP440Version.parse('1.0+local1')?.toString(), '1.0+local1'); + suite('pep440Compare', () => { + test('release ordering', () => { + assert.ok(pep440Compare('1.0', '1.1') < 0); + assert.ok(pep440Compare('1.0', '2.0') < 0); + assert.ok(pep440Compare('1.0.0', '1.0.1') < 0); }); - test('normalizes alternate labels in output', () => { - assert.strictEqual(PEP440Version.parse('1.0alpha1')?.toString(), '1.0a1'); - assert.strictEqual(PEP440Version.parse('1.0c3')?.toString(), '1.0rc3'); + test('epoch takes precedence', () => { + assert.ok(pep440Compare('1!1.0', '2!0.1') < 0); }); - }); - suite('properties', () => { - test('major/minor/micro', () => { - const v = PEP440Version.parse('3.11.4'); - assert.ok(v); - assert.strictEqual(v.major, 3); - assert.strictEqual(v.minor, 11); - assert.strictEqual(v.micro, 4); + test('dev < alpha < beta < rc < final', () => { + assert.ok(pep440Compare('1.0.dev1', '1.0a1') < 0); + assert.ok(pep440Compare('1.0a1', '1.0b1') < 0); + assert.ok(pep440Compare('1.0b1', '1.0rc1') < 0); + assert.ok(pep440Compare('1.0rc1', '1.0') < 0); }); - test('minor defaults to 0 for single segment', () => { - const v = PEP440Version.parse('5'); - assert.ok(v); - assert.strictEqual(v.minor, 0); - assert.strictEqual(v.micro, 0); + test('final < post', () => { + assert.ok(pep440Compare('1.0', '1.0.post1') < 0); }); - test('isPreRelease', () => { - assert.strictEqual(PEP440Version.parse('1.0')?.isPreRelease, false); - assert.strictEqual(PEP440Version.parse('1.0a1')?.isPreRelease, true); - assert.strictEqual(PEP440Version.parse('1.0.dev1')?.isPreRelease, true); + test('pre-release number ordering', () => { + assert.ok(pep440Compare('1.0a1', '1.0a2') < 0); }); - test('isPostRelease', () => { - assert.strictEqual(PEP440Version.parse('1.0.post1')?.isPostRelease, true); - assert.strictEqual(PEP440Version.parse('1.0')?.isPostRelease, false); + test('dev on pre-release sorts before pre without dev', () => { + assert.ok(pep440Compare('1.0a1.dev1', '1.0a1') < 0); }); - test('isLocal', () => { - assert.strictEqual(PEP440Version.parse('1.0+local')?.isLocal, true); - assert.strictEqual(PEP440Version.parse('1.0')?.isLocal, false); + test('equality', () => { + assert.strictEqual(pep440Compare('1.0.0', '1.0'), 0); }); }); - suite('compare', () => { - function v(s: string): PEP440Version { - const parsed = PEP440Version.parse(s); - assert.ok(parsed, `Failed to parse "${s}"`); - return parsed; - } - - test('release ordering', () => { - assert.ok(PEP440Version.compare(v('1.0'), v('1.1')) < 0); - assert.ok(PEP440Version.compare(v('1.0'), v('2.0')) < 0); - assert.ok(PEP440Version.compare(v('1.0.0'), v('1.0.1')) < 0); + suite('shortenVersionString', () => { + test('returns X.Y.Z for 3-segment version', () => { + assert.strictEqual(shortenVersionString('3.11.4'), '3.11.4'); }); - test('epoch takes precedence', () => { - assert.ok(PEP440Version.compare(v('1!1.0'), v('2!0.1')) < 0); + test('returns X.Y.x for 2-segment version', () => { + assert.strictEqual(shortenVersionString('3.11'), '3.11.x'); }); - test('dev < alpha < beta < rc < final', () => { - assert.ok(PEP440Version.compare(v('1.0.dev1'), v('1.0a1')) < 0); - assert.ok(PEP440Version.compare(v('1.0a1'), v('1.0b1')) < 0); - assert.ok(PEP440Version.compare(v('1.0b1'), v('1.0rc1')) < 0); - assert.ok(PEP440Version.compare(v('1.0rc1'), v('1.0')) < 0); + test('returns input for invalid version', () => { + assert.strictEqual(shortenVersionString('not-a-version'), 'not-a-version'); }); - test('final < post', () => { - assert.ok(PEP440Version.compare(v('1.0'), v('1.0.post1')) < 0); - }); - - test('pre-release number ordering', () => { - assert.ok(PEP440Version.compare(v('1.0a1'), v('1.0a2')) < 0); - }); - - test('dev on pre-release sorts before pre without dev', () => { - assert.ok(PEP440Version.compare(v('1.0a1.dev1'), v('1.0a1')) < 0); - }); - - test('equality', () => { - assert.strictEqual(PEP440Version.compare(v('1.0.0'), v('1.0')), 0); + test('extracts major.minor.micro from complex version', () => { + assert.strictEqual(shortenVersionString('3.12.1a1'), '3.12.1'); }); }); }); diff --git a/src/test/common/versionSpecifier.unit.test.ts b/src/test/common/versionSpecifier.unit.test.ts deleted file mode 100644 index e94dfd07..00000000 --- a/src/test/common/versionSpecifier.unit.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import assert from 'node:assert'; -import { PEP440Version } from '../../common/utils/pep440Version'; -import { VersionConstraint, VersionSpecifier } from '../../common/utils/versionSpecifier'; - -suite('VersionSpecifier', () => { - function v(s: string): PEP440Version { - const parsed = PEP440Version.parse(s); - assert.ok(parsed, `Failed to parse "${s}"`); - return parsed; - } - - suite('parse', () => { - test('parses >= operator', () => { - const s = VersionSpecifier.parse('>=1.2.3'); - assert.ok(s); - assert.strictEqual(s.op, '>='); - assert.strictEqual(s.version.toString(), '1.2.3'); - assert.strictEqual(s.wildcard, false); - }); - - test('parses == with wildcard', () => { - const s = VersionSpecifier.parse('==1.2.*'); - assert.ok(s); - assert.strictEqual(s.op, '=='); - assert.strictEqual(s.wildcard, true); - }); - - test('parses != with wildcard', () => { - const s = VersionSpecifier.parse('!=1.0.*'); - assert.ok(s); - assert.strictEqual(s.op, '!='); - assert.strictEqual(s.wildcard, true); - }); - - test('parses === (arbitrary equality)', () => { - const s = VersionSpecifier.parse('===1.0'); - assert.ok(s); - assert.strictEqual(s.op, '==='); - }); - - test('parses ~= (compatible release)', () => { - const s = VersionSpecifier.parse('~=1.4.2'); - assert.ok(s); - assert.strictEqual(s.op, '~='); - }); - - test('allows whitespace between operator and version', () => { - const s = VersionSpecifier.parse('>= 1.0'); - assert.ok(s); - assert.strictEqual(s.op, '>='); - }); - - test('rejects wildcard with invalid operator', () => { - assert.strictEqual(VersionSpecifier.parse('>=1.2.*'), undefined); - assert.strictEqual(VersionSpecifier.parse('<1.0.*'), undefined); - }); - - test('returns undefined for invalid input', () => { - assert.strictEqual(VersionSpecifier.parse(''), undefined); - assert.strictEqual(VersionSpecifier.parse('1.0'), undefined); - assert.strictEqual(VersionSpecifier.parse('>>1.0'), undefined); - }); - }); - - suite('contains', () => { - test('== exact match', () => { - const s = VersionSpecifier.parse('==1.2.3'); - assert.ok(s); - assert.strictEqual(s.contains(v('1.2.3')), true); - assert.strictEqual(s.contains(v('1.2.4')), false); - }); - - test('!= excludes exact match', () => { - const s = VersionSpecifier.parse('!=1.0'); - assert.ok(s); - assert.strictEqual(s.contains(v('1.0')), false); - assert.strictEqual(s.contains(v('1.1')), true); - }); - - test('>= includes boundary', () => { - const s = VersionSpecifier.parse('>=1.2'); - assert.ok(s); - assert.strictEqual(s.contains(v('1.2')), true); - assert.strictEqual(s.contains(v('1.3')), true); - assert.strictEqual(s.contains(v('1.1')), false); - }); - - test('< excludes boundary', () => { - const s = VersionSpecifier.parse('<2.0'); - assert.ok(s); - assert.strictEqual(s.contains(v('1.9')), true); - assert.strictEqual(s.contains(v('2.0')), false); - }); - - test('== wildcard matches prefix', () => { - const s = VersionSpecifier.parse('==1.2.*'); - assert.ok(s); - assert.strictEqual(s.contains(v('1.2.0')), true); - assert.strictEqual(s.contains(v('1.2.99')), true); - assert.strictEqual(s.contains(v('1.3.0')), false); - }); - - test('!= wildcard excludes prefix', () => { - const s = VersionSpecifier.parse('!=1.0.*'); - assert.ok(s); - assert.strictEqual(s.contains(v('1.0.5')), false); - assert.strictEqual(s.contains(v('1.1.0')), true); - }); - - test('~= compatible release', () => { - const s = VersionSpecifier.parse('~=1.4.2'); - assert.ok(s); - assert.strictEqual(s.contains(v('1.4.2')), true); - assert.strictEqual(s.contains(v('1.4.5')), true); - assert.strictEqual(s.contains(v('1.5.0')), false); - assert.strictEqual(s.contains(v('1.4.1')), false); - }); - - test('=== arbitrary equality', () => { - const s = VersionSpecifier.parse('===1.0'); - assert.ok(s); - assert.strictEqual(s.contains(v('1.0')), true); - assert.strictEqual(s.contains(v('1.0.0')), false); // string mismatch: "1.0" vs "1.0.0" - }); - }); - - suite('toString', () => { - test('round-trips specifier string', () => { - assert.strictEqual(VersionSpecifier.parse('>=1.2.3')?.toString(), '>=1.2.3'); - assert.strictEqual(VersionSpecifier.parse('==1.2.*')?.toString(), '==1.2.*'); - assert.strictEqual(VersionSpecifier.parse('~=1.4.2')?.toString(), '~=1.4.2'); - }); - }); -}); - -suite('VersionConstraint', () => { - function v(s: string): PEP440Version { - const parsed = PEP440Version.parse(s); - assert.ok(parsed, `Failed to parse "${s}"`); - return parsed; - } - - suite('parse', () => { - test('parses single specifier', () => { - const c = VersionConstraint.parse('>=1.0'); - assert.ok(c); - assert.strictEqual(c.specifiers.length, 1); - }); - - test('parses multiple comma-separated specifiers', () => { - const c = VersionConstraint.parse('>=1.2, <2.0'); - assert.ok(c); - assert.strictEqual(c.specifiers.length, 2); - assert.strictEqual(c.specifiers[0].op, '>='); - assert.strictEqual(c.specifiers[1].op, '<'); - }); - - test('returns undefined for empty string', () => { - assert.strictEqual(VersionConstraint.parse(''), undefined); - }); - - test('returns undefined if any clause is invalid', () => { - assert.strictEqual(VersionConstraint.parse('>=1.0, invalid'), undefined); - }); - }); - - suite('contains', () => { - test('all specifiers must match', () => { - const c = VersionConstraint.parse('>=1.2, <2.0'); - assert.ok(c); - assert.strictEqual(c.contains(v('1.5')), true); - assert.strictEqual(c.contains(v('1.2')), true); - assert.strictEqual(c.contains(v('2.0')), false); - assert.strictEqual(c.contains(v('1.1')), false); - }); - - test('exclusion constraint', () => { - const c = VersionConstraint.parse('>=1.0, !=1.5'); - assert.ok(c); - assert.strictEqual(c.contains(v('1.4')), true); - assert.strictEqual(c.contains(v('1.5')), false); - assert.strictEqual(c.contains(v('1.6')), true); - }); - }); - - suite('toString', () => { - test('round-trips constraint string', () => { - const c = VersionConstraint.parse('>=1.2,<2.0'); - assert.ok(c); - assert.strictEqual(c.toString(), '>=1.2,<2.0'); - }); - }); -});