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 39229c00..6a671b7d 100644 --- a/src/common/extVersion.ts +++ b/src/common/extVersion.ts @@ -1,3 +1,4 @@ +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'; @@ -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 = 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/managers/builtin/pipUtils.ts b/src/managers/builtin/pipUtils.ts index 2e7a7f85..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'; @@ -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 (!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 3bab6770..89ff29bb 100644 --- a/src/managers/builtin/utils.ts +++ b/src/managers/builtin/utils.ts @@ -21,7 +21,7 @@ import { NativePythonEnvironmentKind, NativePythonFinder, } from '../common/nativePythonFinder'; -import { shortVersion, sortEnvironments } from '../common/utils'; +import { shortenVersionString, sortEnvironments } from '../common/utils'; import { runPython, runUV, shouldUseUv } from './helpers'; import { parsePipListJson, PipPackage } from './pipListUtils'; @@ -80,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 = shortVersion(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 d481b546..866c87d0 100644 --- a/src/managers/builtin/venvManager.ts +++ b/src/managers/builtin/venvManager.ts @@ -37,7 +37,7 @@ import { showErrorMessage, showInformationMessage, withProgress } from '../../co 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, shortenVersionString, sortEnvironments } from '../common/utils'; import { promptInstallPythonViaUv } from './uvPythonInstaller'; import { clearVenvCache, @@ -117,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.', - shortVersion(this.globalEnv.version), + shortenVersionString(this.globalEnv.version), ), }; } diff --git a/src/managers/builtin/venvUtils.ts b/src/managers/builtin/venvUtils.ts index c2cb037a..4f06b3c3 100644 --- a/src/managers/builtin/venvUtils.ts +++ b/src/managers/builtin/venvUtils.ts @@ -24,7 +24,7 @@ import { NativePythonEnvironmentKind, NativePythonFinder, } from '../common/nativePythonFinder'; -import { getShellActivationCommands, shortVersion, 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'; @@ -164,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 = shortVersion(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 0d084da5..ba7390c4 100644 --- a/src/managers/common/utils.ts +++ b/src/managers/common/utils.ts @@ -1,3 +1,4 @@ +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'; @@ -21,49 +22,18 @@ 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; +/** + * 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; } - return false; + 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[] { @@ -76,7 +46,10 @@ export function sortEnvironments(collection: PythonEnvironment[]): PythonEnviron return -1; } if (a.version !== b.version) { - return isGreater(a.version, b.version) ? -1 : 1; + if (pep440Valid(a.version) && pep440Valid(b.version)) { + return pep440Compare(b.version, a.version); // descending + } + return a.version ? 1 : -1; } const value = a.name.localeCompare(b.name); if (value !== 0) { @@ -96,7 +69,7 @@ export function getLatest(collection: PythonEnvironment[]): PythonEnvironment | let latest = candidates[0]; for (const env of candidates) { - if (isGreater(env.version, latest.version)) { + if (pep440Valid(env.version) && pep440Valid(latest.version) && pep440Compare(env.version, latest.version) > 0) { latest = env; } } @@ -114,31 +87,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..60cc4bc3 100644 --- a/src/managers/conda/condaStepBasedFlow.ts +++ b/src/managers/conda/condaStepBasedFlow.ts @@ -1,3 +1,4 @@ +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'; @@ -113,19 +114,12 @@ 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 + if (!pep440Valid(a as string) || !pep440Valid(b as string)) { + return 0; + } + 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 51b1988e..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'; @@ -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 { shortenVersionString, 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 = shortenVersionString(version); return { name: name, @@ -399,7 +400,7 @@ export async function getPrefixesCondaPythonInfo( conda: string, envManager: EnvironmentManager, ): Promise { - const sv = shortVersion(version); + const sv = shortenVersionString(version); const { shellActivation, shellDeactivation } = await buildShellActivationMapForConda(prefix, envManager); @@ -993,19 +994,12 @@ 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 + if (!pep440Valid(a) || !pep440Valid(b)) { + return 0; + } + 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 36cee2d3..55e375e9 100644 --- a/src/managers/pipenv/pipenvUtils.ts +++ b/src/managers/pipenv/pipenvUtils.ts @@ -25,7 +25,7 @@ import { NativePythonEnvironmentKind, NativePythonFinder, } from '../common/nativePythonFinder'; -import { getShellActivationCommands, shortVersion } 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`; @@ -115,7 +115,7 @@ async function nativeToPythonEnv( return undefined; } - const sv = shortVersion(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 e87b81dd..9d14e1cf 100644 --- a/src/managers/poetry/poetryUtils.ts +++ b/src/managers/poetry/poetryUtils.ts @@ -17,7 +17,7 @@ import { NativePythonEnvironmentKind, NativePythonFinder, } from '../common/nativePythonFinder'; -import { getShellActivationCommands, shortVersion, 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. @@ -341,7 +341,7 @@ export async function nativeToPythonEnv( return undefined; } - const sv = shortVersion(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 ba4194ee..2e49ad0e 100644 --- a/src/managers/pyenv/pyenvUtils.ts +++ b/src/managers/pyenv/pyenvUtils.ts @@ -21,7 +21,7 @@ import { NativePythonEnvironmentKind, NativePythonFinder, } from '../common/nativePythonFinder'; -import { shortVersion, sortEnvironments } from '../common/utils'; +import { shortenVersionString, sortEnvironments } from '../common/utils'; /** * Returns the pyenv root directory from the pyenv executable path. @@ -192,7 +192,7 @@ function nativeToPythonEnv( group = PYENV_ENVIRONMENTS; } - const sv = shortVersion(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 new file mode 100644 index 00000000..3457bbc2 --- /dev/null +++ b/src/test/common/pep440Version.unit.test.ts @@ -0,0 +1,108 @@ +import { compare as pep440Compare, valid as pep440Valid } from '@renovatebot/pep440'; +import assert from 'node:assert'; +import { shortenVersionString } from '../../managers/common/utils'; + +suite('pep440Version', () => { + suite('pep440Valid', () => { + test('accepts simple release', () => { + assert.strictEqual(pep440Valid('1.2.3'), '1.2.3'); + }); + + test('accepts single segment', () => { + assert.strictEqual(pep440Valid('42'), '42'); + }); + + test('accepts epoch', () => { + assert.ok(pep440Valid('2!1.0')); + }); + + 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('accepts post release', () => { + assert.ok(pep440Valid('1.0.post1')); + }); + + test('accepts implicit post release (dash form)', () => { + assert.ok(pep440Valid('1.0-1')); + }); + + test('accepts dev release', () => { + assert.ok(pep440Valid('1.0.dev3')); + }); + + test('accepts local version', () => { + assert.ok(pep440Valid('1.0+ubuntu1')); + }); + + test('accepts leading v', () => { + assert.ok(pep440Valid('v1.0')); + }); + + test('rejects invalid versions', () => { + assert.strictEqual(pep440Valid('not-a-version'), null); + assert.strictEqual(pep440Valid(''), null); + assert.strictEqual(pep440Valid('abc.def'), null); + }); + }); + + 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('epoch takes precedence', () => { + assert.ok(pep440Compare('1!1.0', '2!0.1') < 0); + }); + + 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('final < post', () => { + assert.ok(pep440Compare('1.0', '1.0.post1') < 0); + }); + + test('pre-release number ordering', () => { + assert.ok(pep440Compare('1.0a1', '1.0a2') < 0); + }); + + test('dev on pre-release sorts before pre without dev', () => { + assert.ok(pep440Compare('1.0a1.dev1', '1.0a1') < 0); + }); + + test('equality', () => { + assert.strictEqual(pep440Compare('1.0.0', '1.0'), 0); + }); + }); + + suite('shortenVersionString', () => { + test('returns X.Y.Z for 3-segment version', () => { + assert.strictEqual(shortenVersionString('3.11.4'), '3.11.4'); + }); + + test('returns X.Y.x for 2-segment version', () => { + assert.strictEqual(shortenVersionString('3.11'), '3.11.x'); + }); + + test('returns input for invalid version', () => { + assert.strictEqual(shortenVersionString('not-a-version'), 'not-a-version'); + }); + + test('extracts major.minor.micro from complex version', () => { + assert.strictEqual(shortenVersionString('3.12.1a1'), '3.12.1'); + }); + }); +});