From 761d1bb73116d177d3d87951ba15af4864c27dbb Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 19 Aug 2026 15:51:47 -0700 Subject: [PATCH 1/2] Raise PackageVersionLookupNotSupportedError Fixes #1727 --- api/CHANGELOG.md | 12 ++ api/package-lock.json | 4 +- api/package.json | 2 +- api/test/consumer.ts | 23 ++- src/api.ts | 83 +++++++++-- src/features/envCommands.ts | 21 ++- src/features/pythonApi.ts | 7 +- src/internal.api.ts | 28 +++- src/managers/builtin/pipPackageManager.ts | 148 ++++++++++++++------ src/managers/conda/condaPackageManager.ts | 53 ++++--- src/managers/poetry/poetryPackageManager.ts | 19 ++- 11 files changed, 304 insertions(+), 96 deletions(-) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 616edca22..bd5bb6f2e 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to the `@vscode/python-environments` API package are documen The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.0] + +### Added + +- Added `PackageVersionLookupNotSupportedError`, thrown when a package manager cannot list a package's available versions (an unsupported capability, as distinct from an operational failure). The error exposes a stable `code` (`'PackageVersionLookupNotSupported'`) discriminator. +- Added the `isPackageVersionLookupNotSupportedError(error): error is PackageVersionLookupNotSupportedError` type guard. It recognizes the error via its stable `code`, so it works even when the error crosses an extension bundle boundary and `instanceof` would fail. + +### Changed + +- `PythonPackageGetterApi.getPackageAvailableVersions` now distinguishes an unsupported capability from an operational failure. It rejects with `PackageVersionLookupNotSupportedError` when the environment's package manager does not support version lookup (the default/missing manager, Poetry, or a Pip older than 21.2), and it propagates the original error for operational failures (command, network, or malformed/unparseable output) instead of resolving to `undefined`. On success it resolves to a non-empty array of versions, and its return type is now `Promise`. +- Documented that `PackageManager.getPackageAvailableVersions` implementations should throw `PackageVersionLookupNotSupportedError` when version lookup is unsupported and let operational failures propagate. Resolving to `undefined` continues to be treated by callers as an unsupported capability. + ## [1.2.0] ### Added diff --git a/api/package-lock.json b/api/package-lock.json index 7745eab9a..d32a814e7 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vscode/python-environments", - "version": "1.2.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vscode/python-environments", - "version": "1.2.0", + "version": "1.3.0", "license": "MIT", "dependencies": { "@renovatebot/pep440": "^3.1.0" diff --git a/api/package.json b/api/package.json index 6b1c6e70e..ceb3a808b 100644 --- a/api/package.json +++ b/api/package.json @@ -1,7 +1,7 @@ { "name": "@vscode/python-environments", "description": "An API facade for the Python Environments extension in VS Code", - "version": "1.2.0", + "version": "1.3.0", "author": { "name": "Microsoft Corporation" }, diff --git a/api/test/consumer.ts b/api/test/consumer.ts index e2f9cf4aa..8d054907b 100644 --- a/api/test/consumer.ts +++ b/api/test/consumer.ts @@ -4,6 +4,10 @@ import type { PythonEnvironment, PythonPackageGetterApi, } from '@vscode/python-environments'; +import { + isPackageVersionLookupNotSupportedError, + PackageVersionLookupNotSupportedError, +} from '@vscode/python-environments'; type Equal = (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 ? true : false; @@ -11,13 +15,28 @@ type Equal = type AvailableVersionsReturn = ReturnType; type RefreshReturn = ReturnType; -const availableVersionsReturnIsExact: Equal> = true; +const availableVersionsReturnIsExact: Equal> = true; const refreshReturnIsExact: Equal> = true; declare const api: PythonPackageGetterApi; declare const environment: PythonEnvironment; -const availableVersions: Promise = api.getPackageAvailableVersions(environment, 'example'); +const availableVersions: Promise = api.getPackageAvailableVersions(environment, 'example'); + +// The unsupported-capability error is part of the public contract: it is constructible, extends +// Error, and exposes a stable string-literal `code` discriminator. +const lookupError = new PackageVersionLookupNotSupportedError('unsupported'); +const lookupErrorIsError: Error = lookupError; +const lookupErrorCodeIsExact: Equal = true; + +// The type guard narrows unknown values via the stable discriminator (bundle-boundary safe). +declare const maybeError: unknown; +const guardNarrows: boolean = isPackageVersionLookupNotSupportedError(maybeError) + ? maybeError.code === 'PackageVersionLookupNotSupported' + : false; void availableVersionsReturnIsExact; void refreshReturnIsExact; void availableVersions; +void lookupErrorIsError; +void lookupErrorCodeIsExact; +void guardNarrows; diff --git a/src/api.ts b/src/api.ts index 2779d27b0..f44750336 100644 --- a/src/api.ts +++ b/src/api.ts @@ -746,11 +746,22 @@ export interface PackageManager { getVersion?(environment: PythonEnvironment): Promise; /** - * Retrieves the list of available versions for a given package. + * Retrieves the list of available versions for a given package, newest first. + * + * Implementations should: + * - resolve to a non-empty array of {@link Pep440Version} objects on success; + * - throw a {@link PackageVersionLookupNotSupportedError} when this manager cannot look up + * versions at all (an unsupported capability); + * - let operational failures (command, network, or malformed/unparseable output) propagate + * instead of swallowing them into `undefined`. + * + * Resolving to `undefined` is treated by callers as an unsupported capability, equivalent to + * throwing {@link PackageVersionLookupNotSupportedError}. + * * @param environment - The Python environment context for the lookup. * @param packageName - The name of the package to look up. - * @returns A promise that resolves to an array of {@link Pep440Version} objects (newest first), - * or `undefined` if this manager does not support version listing. + * @returns A promise that resolves to an array of {@link Pep440Version} objects (newest first). + * @throws {@link PackageVersionLookupNotSupportedError} when version lookup is unsupported. */ getPackageAvailableVersions?( environment: PythonEnvironment, @@ -1118,6 +1129,56 @@ export interface PythonPackageManagerRegistrationApi { registerPackageManager(manager: PackageManager, options?: { extensionId?: string }): Disposable; } +/** + * Error thrown when a package manager cannot list available package versions. + * + * This distinguishes an *unsupported capability* from an *operational failure* (such as a + * failed command, a network error, or malformed/unparseable output). Consumers of + * {@link PythonPackageGetterApi.getPackageAvailableVersions} should treat this specific error + * as a signal to fall back to manual version entry, while letting any other error propagate. + * + * The {@link code} property carries a stable, string-literal discriminator so the error can be + * recognized reliably across extension bundle boundaries, where `instanceof` may fail because + * each bundle can load its own copy of this class. Prefer {@link isPackageVersionLookupNotSupportedError} + * over a bare `instanceof` check for that reason. + */ +export class PackageVersionLookupNotSupportedError extends Error { + /** + * Stable discriminator identifying this error type across bundle boundaries. + */ + public readonly code = 'PackageVersionLookupNotSupported'; + + constructor(message?: string) { + super(message ?? 'The package manager does not support looking up available package versions.'); + this.name = 'PackageVersionLookupNotSupportedError'; + // Preserve the prototype chain when this class is transpiled to older targets so that + // `instanceof` continues to work within a single bundle. + Object.setPrototypeOf(this, PackageVersionLookupNotSupportedError.prototype); + } +} + +/** + * Type guard reporting whether an error represents unsupported package version lookup. + * + * Uses the stable {@link PackageVersionLookupNotSupportedError.code} discriminator, so it returns + * `true` even when the error crossed an extension bundle boundary and `instanceof` would fail. + * + * @param error The value to test. + * @returns `true` if `error` is a {@link PackageVersionLookupNotSupportedError} (or a structurally + * equivalent error carrying the same `code`). + */ +export function isPackageVersionLookupNotSupportedError( + error: unknown, +): error is PackageVersionLookupNotSupportedError { + return ( + error instanceof PackageVersionLookupNotSupportedError || + (typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === 'PackageVersionLookupNotSupported') + ); +} + export interface PythonPackageGetterApi { /** * Refresh the list of packages in a Python Environment. @@ -1139,18 +1200,24 @@ export interface PythonPackageGetterApi { /** * Get the list of available versions for a package, newest first. * - * Support depends on the package manager backing the environment. Managers that do - * not implement version lookup resolve to `undefined`. + * The returned promise distinguishes an unsupported capability from an operational failure: + * - It resolves to a non-empty array of {@link Pep440Version} objects when versions are found. + * - It rejects with a {@link PackageVersionLookupNotSupportedError} when the environment's + * package manager does not support version lookup (for example, the default/missing manager, + * Poetry, or a Pip older than 21.2). Use {@link isPackageVersionLookupNotSupportedError} to + * detect this reliably across extension bundle boundaries and fall back to manual entry. + * - It rejects with the original error for any other failure (command, network, or + * malformed/unparseable output), which callers should handle or surface normally. * * @param environment The Python Environment context for the lookup. * @param packageName The name of the package to look up. - * @returns A promise that resolves to an array of {@link Pep440Version} objects (newest first), - * or `undefined` if the package manager does not support version listing. + * @returns A promise that resolves to an array of {@link Pep440Version} objects (newest first). + * @throws {@link PackageVersionLookupNotSupportedError} when version lookup is unsupported. */ getPackageAvailableVersions( environment: PythonEnvironment, packageName: string, - ): Promise; + ): Promise; /** * Event raised when the list of packages in a Python Environment changes. diff --git a/src/features/envCommands.ts b/src/features/envCommands.ts index fafb6fbcd..4ab36a013 100644 --- a/src/features/envCommands.ts +++ b/src/features/envCommands.ts @@ -12,11 +12,13 @@ import { } from 'vscode'; import { CreateEnvironmentOptions, + Pep440Version, PythonEnvironment, PythonEnvironmentApi, PythonProject, PythonProjectCreator, PythonProjectCreatorOptions, + isPackageVersionLookupNotSupportedError, } from '../api'; import { traceError, traceInfo, traceVerbose } from '../common/logging'; import { @@ -369,11 +371,20 @@ export async function managePackageVersion(context: unknown, em: EnvironmentMana let version: string | undefined; - // Try to fetch available versions for a QuickPick experience - const availableVersions = await withProgress( - { location: ProgressLocation.Window, title: l10n.t('Fetching available versions for {0}...', pkg.name) }, - () => packageManager.getPackageAvailableVersions(environment, pkg.name), - ); + // Try to fetch available versions for a QuickPick experience. Only a typed + // unsupported-capability error falls back to manual entry; any other failure + // (command, network, or malformed output) propagates for normal handling. + let availableVersions: Pep440Version[] | undefined; + try { + availableVersions = await withProgress( + { location: ProgressLocation.Window, title: l10n.t('Fetching available versions for {0}...', pkg.name) }, + () => packageManager.getPackageAvailableVersions(environment, pkg.name), + ); + } catch (error) { + if (!isPackageVersionLookupNotSupportedError(error)) { + throw error; + } + } if (availableVersions && availableVersions.length > 0) { const items = availableVersions.map((v) => ({ diff --git a/src/features/pythonApi.ts b/src/features/pythonApi.ts index e93ed0cdb..1f6a0e9f9 100644 --- a/src/features/pythonApi.ts +++ b/src/features/pythonApi.ts @@ -16,6 +16,7 @@ import { PackageInfo, PackageManagementOptions, PackageManager, + PackageVersionLookupNotSupportedError, Pep440Version, PythonBackgroundRunOptions, PythonEnvironment, @@ -322,11 +323,13 @@ export class PythonEnvironmentApiImpl implements PythonEnvironmentApi { async getPackageAvailableVersions( context: PythonEnvironment, packageName: string, - ): Promise { + ): Promise { await waitForEnvManagerId([context.envId.managerId]); const manager = this.envManagers.getPackageManager(context); if (!manager) { - return Promise.resolve(undefined); + throw new PackageVersionLookupNotSupportedError( + `No package manager is available to look up versions for: ${context.envId.id}`, + ); } return manager.getPackageAvailableVersions(context, packageName); } diff --git a/src/internal.api.ts b/src/internal.api.ts index 6d41cb5c3..e40adc330 100644 --- a/src/internal.api.ts +++ b/src/internal.api.ts @@ -18,6 +18,7 @@ import { PackageInfo, PackageManagementOptions, PackageManager, + PackageVersionLookupNotSupportedError, PythonEnvironment, PythonEnvironmentExecutionInfo, PythonEnvironmentId, @@ -397,13 +398,30 @@ export class InternalPackageManager implements PackageManager { return this.manager.getVersion ? this.manager.getVersion(environment) : Promise.resolve(undefined); } - getPackageAvailableVersions( + /** + * Delegates version lookup to the underlying package manager. + * + * Managers that do not implement version lookup - or that resolve `undefined` - are treated + * as lacking the capability, so this rejects with {@link PackageVersionLookupNotSupportedError}. + * All other errors from the manager propagate unchanged. + */ + async getPackageAvailableVersions( environment: PythonEnvironment, packageName: string, - ): Promise { - return this.manager.getPackageAvailableVersions - ? this.manager.getPackageAvailableVersions(environment, packageName) - : Promise.resolve(undefined); + ): Promise { + if (!this.manager.getPackageAvailableVersions) { + throw new PackageVersionLookupNotSupportedError( + `Package version lookup is not supported by package manager: ${this.id}`, + ); + } + const versions = await this.manager.getPackageAvailableVersions(environment, packageName); + if (versions === undefined) { + // A manager that resolves `undefined` is signalling the capability is unavailable. + throw new PackageVersionLookupNotSupportedError( + `Package version lookup is not supported by package manager: ${this.id}`, + ); + } + return versions; } getDirectPackageNames(environment: PythonEnvironment): Promise | undefined> { diff --git a/src/managers/builtin/pipPackageManager.ts b/src/managers/builtin/pipPackageManager.ts index 836244bb7..41cf59d8f 100644 --- a/src/managers/builtin/pipPackageManager.ts +++ b/src/managers/builtin/pipPackageManager.ts @@ -18,6 +18,7 @@ import { Package, PackageManagementOptions, PackageManager, + PackageVersionLookupNotSupportedError, PythonEnvironment, PythonEnvironmentApi, } from '../../api'; @@ -174,57 +175,91 @@ export class PipPackageManager implements PackageManager, Disposable { } } + /** + * Lists available versions for a package, newest first. + * + * Distinguishes an unsupported capability from an operational failure: + * - Throws {@link PackageVersionLookupNotSupportedError} when the environment's pip is older + * than 21.2 (which predates `pip index versions`). + * - Lets operational failures (missing interpreter/version, command, network, or + * malformed/unparseable output) propagate instead of returning `undefined`. + * + * @param environment - The Python environment to query. + * @param packageName - The package whose versions should be listed. + * @returns A promise that resolves to a non-empty array of {@link Pep440Version} objects. + * @throws {@link PackageVersionLookupNotSupportedError} when pip is too old to list versions. + */ async getPackageAvailableVersions( environment: PythonEnvironment, packageName: string, - ): Promise { - try { - const python = environment.execInfo?.run?.executable; - if (!python) { - return undefined; - } + ): Promise { + const python = environment.execInfo?.run?.executable; + if (!python) { + throw new Error(`Python executable is unavailable for environment: ${environment.envId.id}`); + } - const baseVersion = parse(environment.version)?.base_version; - if (!baseVersion) { - return undefined; - } - // uv - Run pip via `uv tool run pip` - const useUv = await shouldUseUv(this.log, environment.environmentPath.fsPath); - if (useUv) { - const output = await runUV( - ['tool', 'run', 'pip', 'index', 'versions', packageName, '--json', '--python-version', baseVersion], - undefined, - this.log, - ); - return parsePipIndexVersionsJson(output); - } + const baseVersion = getPythonVersionForPackageLookup(environment.version); + if (!baseVersion) { + throw new Error(`Python version is unavailable for environment: ${environment.envId.id}`); + } - // pip >= 25.1 - use `pip index versions --json` to get available versions in a machine readable format. - const pipVersion = await this.getVersion(environment); - if (pipVersion && compare(pipVersion.public, '25.1') >= 0) { - const output = await runPython( - python, - ['-m', 'pip', 'index', 'versions', packageName, '--json', '--python-version', baseVersion], - undefined, - this.log, - ); - return parsePipIndexVersionsJson(output); - } + // uv - Run pip via `uv tool run pip`; uv always emits the machine-readable JSON output. + const useUv = await shouldUseUv(this.log, environment.environmentPath.fsPath); + if (useUv) { + const output = await runUV( + ['tool', 'run', 'pip', 'index', 'versions', packageName, '--json', '--python-version', baseVersion], + undefined, + this.log, + ); + return requireParsedVersions(parsePipIndexVersionsJson(output), 'uv'); + } - if (pipVersion && compare(pipVersion.public, '21.2') >= 0) { - const output = await runPython( - python, - ['-m', 'pip', 'index', 'versions', packageName, '--python-version', baseVersion], - undefined, - this.log, - ); - return parsePipIndexVersionsText(output); - } + const pipVersion = await this.resolvePipVersionOrThrow(python); - // pip < 21.2 - version picking is undefined; `pip index versions` is unavailable. - } catch { - return undefined; + // pip >= 25.1 - `pip index versions --json` returns a machine-readable format. + if (compare(pipVersion.public, '25.1') >= 0) { + const output = await runPython( + python, + ['-m', 'pip', 'index', 'versions', packageName, '--json', '--python-version', baseVersion], + undefined, + this.log, + ); + return requireParsedVersions(parsePipIndexVersionsJson(output), 'pip'); + } + + // pip 21.2 - 25.0 - only the human-readable text output is available. + if (compare(pipVersion.public, '21.2') >= 0) { + const output = await runPython( + python, + ['-m', 'pip', 'index', 'versions', packageName, '--python-version', baseVersion], + undefined, + this.log, + ); + return requireParsedVersions(parsePipIndexVersionsText(output), 'pip'); + } + + // pip < 21.2 predates `pip index versions`; version lookup is an unsupported capability. + throw new PackageVersionLookupNotSupportedError( + `Package version lookup requires pip 21.2 or newer; the environment has pip ${pipVersion.public}.`, + ); + } + + /** + * Resolves the environment's pip version, throwing when it cannot be determined. + * + * Unlike {@link getVersion}, failures here propagate so an operational problem (for example, + * pip is missing or the command fails) is surfaced instead of being misreported as an + * unsupported capability. + */ + private async resolvePipVersionOrThrow(python: string): Promise { + const result = await runPython(python, ['-m', 'pip', '--version'], undefined, this.log); + // "pip X.Y.Z from /path/to/pip (python X.Y)" + const match = result.match(/^pip\s+(\d+\.\d+(?:\.\d+)*)/); + const version = match ? parse(match[1]) : null; + if (!version) { + throw new Error(`Unable to determine the pip version from: ${result.trim()}`); } + return version; } dispose(): void { @@ -244,6 +279,33 @@ export class PipPackageManager implements PackageManager, Disposable { } } +/** + * Ensures a parse step produced versions, converting a parsing miss into a propagating + * operational error instead of silently returning `undefined`. + */ +function requireParsedVersions(versions: Pep440Version[] | undefined, tool: 'pip' | 'uv'): Pep440Version[] { + if (!versions) { + throw new Error(`Unable to parse available package versions from ${tool} output.`); + } + return versions; +} + +/** + * Extracts a `major.minor[.micro]` string suitable for pip's `--python-version` flag from a + * Python interpreter version string. + * + * Interpreter versions can include release-level and serial suffixes (for example + * `3.13.14.final.0`) that are not valid PEP 440 versions, so this uses a tolerant numeric-prefix + * match instead of a PEP 440 parse. + * + * @param version - The interpreter version string (e.g. `"3.13.14"` or `"3.13.14.final.0"`). + * @returns The dotted numeric version (e.g. `"3.13.14"`), or `undefined` when there is no numeric prefix. + */ +export function getPythonVersionForPackageLookup(version: string): string | undefined { + const match = version.match(/^\s*(\d+)\.(\d+)(?:\.(\d+))?/); + return match ? [match[1], match[2], match[3]].filter((segment) => segment !== undefined).join('.') : undefined; +} + /** * Parses JSON output from `pip index versions --json`. * Expected format: { "name": "...", "versions": ["1.2.3", "1.2.2", ...] } diff --git a/src/managers/conda/condaPackageManager.ts b/src/managers/conda/condaPackageManager.ts index d395d0ce6..bf7936a84 100644 --- a/src/managers/conda/condaPackageManager.ts +++ b/src/managers/conda/condaPackageManager.ts @@ -178,33 +178,40 @@ export class CondaPackageManager implements PackageManager, Disposable { } } + /** + * Lists available versions for a package via `conda search --json`, newest first. + * + * Conda always supports version lookup, so operational failures (command, network, or + * malformed/unparseable output) propagate instead of being swallowed into `undefined`. + * + * @param _environment - Unused; conda resolves versions from its configured channels. + * @param packageName - The package whose versions should be listed. + * @returns A promise that resolves to an array of {@link Pep440Version} objects (newest first). + */ async getPackageAvailableVersions( _environment: PythonEnvironment, packageName: string, - ): Promise { - try { - const output = await runCondaExecutable(['search', packageName, '--json'], this.log); - const parsed = JSON.parse(output); - if (parsed && typeof parsed === 'object' && Array.isArray(parsed[packageName])) { - const uniqueVersions = new Map(); - parsed[packageName] - .filter((entry: { version?: string }) => !!entry.version?.trim()) - .map((entry: { version?: string }) => parse(entry.version!)) - .filter((v: Pep440Version | null): v is Pep440Version => v !== null) - .forEach((version: Pep440Version) => { - if (!uniqueVersions.has(version.public)) { - uniqueVersions.set(version.public, version); - } - }); - - return Array.from(uniqueVersions.values()).sort((a: Pep440Version, b: Pep440Version) => - rcompare(a.public, b.public), - ); - } - return undefined; - } catch { - return undefined; + ): Promise { + const output = await runCondaExecutable(['search', packageName, '--json'], this.log); + const parsed = JSON.parse(output); + if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed[packageName])) { + throw new Error(`Conda returned unexpected package version data for: ${packageName}`); } + + const uniqueVersions = new Map(); + parsed[packageName] + .filter((entry: { version?: string }) => !!entry.version?.trim()) + .map((entry: { version?: string }) => parse(entry.version!)) + .filter((v: Pep440Version | null): v is Pep440Version => v !== null) + .forEach((version: Pep440Version) => { + if (!uniqueVersions.has(version.public)) { + uniqueVersions.set(version.public, version); + } + }); + + return Array.from(uniqueVersions.values()).sort((a: Pep440Version, b: Pep440Version) => + rcompare(a.public, b.public), + ); } getPackageWatchTargets(environment: PythonEnvironment): RelativePattern[] { diff --git a/src/managers/poetry/poetryPackageManager.ts b/src/managers/poetry/poetryPackageManager.ts index e946f0452..decb2b373 100644 --- a/src/managers/poetry/poetryPackageManager.ts +++ b/src/managers/poetry/poetryPackageManager.ts @@ -21,6 +21,7 @@ import { Package, PackageManagementOptions, PackageManager, + PackageVersionLookupNotSupportedError, PythonEnvironment, PythonEnvironmentApi, } from '../../api'; @@ -166,14 +167,22 @@ export class PoetryPackageManager implements PackageManager, Disposable { return versionStr ? (parse(versionStr) ?? undefined) : undefined; } + /** + * Reports that Poetry cannot list available package versions. + * + * Poetry has no native "list available versions" command. Poetry 2.x exposes `poetry search`, + * but PyPI disabled the backing endpoint, so there is no reliable way to enumerate versions. + * This throws the typed unsupported-capability error so callers can fall back to manual entry. + * + * @param _environment - Unused. + * @param _packageName - Unused. + * @throws {@link PackageVersionLookupNotSupportedError} always. + */ async getPackageAvailableVersions( _environment: PythonEnvironment, _packageName: string, - ): Promise { - // Poetry doesn't have a native "list available versions" command. - // Poetry 2.x supports `poetry search` but it was disabled on PyPI. - // Return undefined to indicate this manager doesn't support version listing. - return undefined; + ): Promise { + throw new PackageVersionLookupNotSupportedError('Poetry does not support listing available package versions.'); } formatInstallSpec(packageName: string, version: string): string { From 2c8db9217cdc3ef7e000ad4de8a3ae9f534c9566 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 19 Aug 2026 18:49:20 -0700 Subject: [PATCH 2/2] Preserve legacy package version lookup behavior Add an opt-in throw mode for callers that need to distinguish unsupported lookups from operational failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4c801599-6aaa-4eb5-b4ed-23362ed54dbd --- api/CHANGELOG.md | 2 +- api/test/consumer.ts | 19 +++++-- src/api.ts | 41 +++++++++++---- src/features/envCommands.ts | 2 +- src/features/pythonApi.ts | 25 +++++++-- src/internal.api.ts | 52 ++++++++++++------- src/managers/builtin/pipPackageManager.ts | 2 +- .../packageManager.integration.test.ts | 21 +++++++- ...lPackageManager.versionLookup.unit.test.ts | 23 ++++++++ 9 files changed, 145 insertions(+), 42 deletions(-) create mode 100644 src/test/internalPackageManager.versionLookup.unit.test.ts diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index bd5bb6f2e..7ed91fccf 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -11,10 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `PackageVersionLookupNotSupportedError`, thrown when a package manager cannot list a package's available versions (an unsupported capability, as distinct from an operational failure). The error exposes a stable `code` (`'PackageVersionLookupNotSupported'`) discriminator. - Added the `isPackageVersionLookupNotSupportedError(error): error is PackageVersionLookupNotSupportedError` type guard. It recognizes the error via its stable `code`, so it works even when the error crosses an extension bundle boundary and `instanceof` would fail. +- Added an optional `errorMode` to `PythonPackageGetterApi.getPackageAvailableVersions`. The default `legacy` mode preserves the existing `undefined` result for unsupported lookups and operational failures. The opt-in `throw` mode rejects with `PackageVersionLookupNotSupportedError` for unsupported capabilities and propagates operational failures unchanged. ### Changed -- `PythonPackageGetterApi.getPackageAvailableVersions` now distinguishes an unsupported capability from an operational failure. It rejects with `PackageVersionLookupNotSupportedError` when the environment's package manager does not support version lookup (the default/missing manager, Poetry, or a Pip older than 21.2), and it propagates the original error for operational failures (command, network, or malformed/unparseable output) instead of resolving to `undefined`. On success it resolves to a non-empty array of versions, and its return type is now `Promise`. - Documented that `PackageManager.getPackageAvailableVersions` implementations should throw `PackageVersionLookupNotSupportedError` when version lookup is unsupported and let operational failures propagate. Resolving to `undefined` continues to be treated by callers as an unsupported capability. ## [1.2.0] diff --git a/api/test/consumer.ts b/api/test/consumer.ts index 8d054907b..28fdd2d11 100644 --- a/api/test/consumer.ts +++ b/api/test/consumer.ts @@ -15,12 +15,23 @@ type Equal = type AvailableVersionsReturn = ReturnType; type RefreshReturn = ReturnType; -const availableVersionsReturnIsExact: Equal> = true; +const availableVersionsReturnIsExact: Equal> = true; const refreshReturnIsExact: Equal> = true; declare const api: PythonPackageGetterApi; declare const environment: PythonEnvironment; -const availableVersions: Promise = api.getPackageAvailableVersions(environment, 'example'); +const legacyAvailableVersions: Promise = api.getPackageAvailableVersions( + environment, + 'example', +); +const explicitLegacyAvailableVersions: Promise = api.getPackageAvailableVersions( + environment, + 'example', + { errorMode: 'legacy' }, +); +const throwingAvailableVersions: Promise = api.getPackageAvailableVersions(environment, 'example', { + errorMode: 'throw', +}); // The unsupported-capability error is part of the public contract: it is constructible, extends // Error, and exposes a stable string-literal `code` discriminator. @@ -36,7 +47,9 @@ const guardNarrows: boolean = isPackageVersionLookupNotSupportedError(maybeError void availableVersionsReturnIsExact; void refreshReturnIsExact; -void availableVersions; +void legacyAvailableVersions; +void explicitLegacyAvailableVersions; +void throwingAvailableVersions; void lookupErrorIsError; void lookupErrorCodeIsExact; void guardNarrows; diff --git a/src/api.ts b/src/api.ts index f44750336..13504a2d3 100644 --- a/src/api.ts +++ b/src/api.ts @@ -749,7 +749,7 @@ export interface PackageManager { * Retrieves the list of available versions for a given package, newest first. * * Implementations should: - * - resolve to a non-empty array of {@link Pep440Version} objects on success; + * - resolve to an array of {@link Pep440Version} objects on success; * - throw a {@link PackageVersionLookupNotSupportedError} when this manager cannot look up * versions at all (an unsupported capability); * - let operational failures (command, network, or malformed/unparseable output) propagate @@ -1179,6 +1179,22 @@ export function isPackageVersionLookupNotSupportedError( ); } +/** + * Controls how package version lookup failures are reported. + */ +export interface GetPackageAvailableVersionsOptions { + /** + * Determines whether lookup failures preserve the legacy `undefined` result or reject. + * + * - `legacy` resolves to `undefined` for unsupported lookups and operational failures. + * This remains the default for backward compatibility, but may be removed in a future + * major API version. + * - `throw` rejects with {@link PackageVersionLookupNotSupportedError} for unsupported + * lookups and propagates operational failures unchanged. + */ + errorMode?: 'legacy' | 'throw'; +} + export interface PythonPackageGetterApi { /** * Refresh the list of packages in a Python Environment. @@ -1200,24 +1216,27 @@ export interface PythonPackageGetterApi { /** * Get the list of available versions for a package, newest first. * - * The returned promise distinguishes an unsupported capability from an operational failure: - * - It resolves to a non-empty array of {@link Pep440Version} objects when versions are found. - * - It rejects with a {@link PackageVersionLookupNotSupportedError} when the environment's - * package manager does not support version lookup (for example, the default/missing manager, - * Poetry, or a Pip older than 21.2). Use {@link isPackageVersionLookupNotSupportedError} to - * detect this reliably across extension bundle boundaries and fall back to manual entry. - * - It rejects with the original error for any other failure (command, network, or - * malformed/unparseable output), which callers should handle or surface normally. + * By default, this preserves the legacy behavior of resolving to `undefined` for unsupported + * lookups and operational failures. Pass `{ errorMode: 'throw' }` to distinguish unsupported + * capabilities from operational failures: unsupported lookups reject with + * {@link PackageVersionLookupNotSupportedError}, while other failures propagate unchanged. * * @param environment The Python Environment context for the lookup. * @param packageName The name of the package to look up. - * @returns A promise that resolves to an array of {@link Pep440Version} objects (newest first). - * @throws {@link PackageVersionLookupNotSupportedError} when version lookup is unsupported. + * @param options Controls how lookup failures are reported. + * @returns A promise that resolves to an array of {@link Pep440Version} objects (newest first), + * or `undefined` in legacy mode when lookup is unsupported or fails. */ getPackageAvailableVersions( environment: PythonEnvironment, packageName: string, + options: GetPackageAvailableVersionsOptions & { errorMode: 'throw' }, ): Promise; + getPackageAvailableVersions( + environment: PythonEnvironment, + packageName: string, + options?: GetPackageAvailableVersionsOptions, + ): Promise; /** * Event raised when the list of packages in a Python Environment changes. diff --git a/src/features/envCommands.ts b/src/features/envCommands.ts index 4ab36a013..fabf31702 100644 --- a/src/features/envCommands.ts +++ b/src/features/envCommands.ts @@ -378,7 +378,7 @@ export async function managePackageVersion(context: unknown, em: EnvironmentMana try { availableVersions = await withProgress( { location: ProgressLocation.Window, title: l10n.t('Fetching available versions for {0}...', pkg.name) }, - () => packageManager.getPackageAvailableVersions(environment, pkg.name), + () => packageManager.getPackageAvailableVersions(environment, pkg.name, { errorMode: 'throw' }), ); } catch (error) { if (!isPackageVersionLookupNotSupportedError(error)) { diff --git a/src/features/pythonApi.ts b/src/features/pythonApi.ts index 1f6a0e9f9..a34524875 100644 --- a/src/features/pythonApi.ts +++ b/src/features/pythonApi.ts @@ -10,6 +10,7 @@ import { EnvironmentManager, GetEnvironmentScope, GetEnvironmentsScope, + GetPackageAvailableVersionsOptions, GetPackagesOptions, Package, PackageId, @@ -320,18 +321,32 @@ export class PythonEnvironmentApiImpl implements PythonEnvironmentApi { } return manager.getPackages(context, options); } + getPackageAvailableVersions( + context: PythonEnvironment, + packageName: string, + options: GetPackageAvailableVersionsOptions & { errorMode: 'throw' }, + ): Promise; + getPackageAvailableVersions( + context: PythonEnvironment, + packageName: string, + options?: GetPackageAvailableVersionsOptions, + ): Promise; async getPackageAvailableVersions( context: PythonEnvironment, packageName: string, - ): Promise { + options?: GetPackageAvailableVersionsOptions, + ): Promise { await waitForEnvManagerId([context.envId.managerId]); const manager = this.envManagers.getPackageManager(context); if (!manager) { - throw new PackageVersionLookupNotSupportedError( - `No package manager is available to look up versions for: ${context.envId.id}`, - ); + if (options?.errorMode === 'throw') { + throw new PackageVersionLookupNotSupportedError( + `No package manager is available to look up versions for: ${context.envId.id}`, + ); + } + return undefined; } - return manager.getPackageAvailableVersions(context, packageName); + return manager.getPackageAvailableVersions(context, packageName, options); } onDidChangePackages: Event = this._onDidChangePackages.event; diff --git a/src/internal.api.ts b/src/internal.api.ts index e40adc330..606b6b81c 100644 --- a/src/internal.api.ts +++ b/src/internal.api.ts @@ -10,6 +10,7 @@ import { EnvironmentManager, GetEnvironmentScope, GetEnvironmentsScope, + GetPackageAvailableVersionsOptions, GetPackagesOptions, IconPath, Package, @@ -398,30 +399,45 @@ export class InternalPackageManager implements PackageManager { return this.manager.getVersion ? this.manager.getVersion(environment) : Promise.resolve(undefined); } + getPackageAvailableVersions( + environment: PythonEnvironment, + packageName: string, + options: GetPackageAvailableVersionsOptions & { errorMode: 'throw' }, + ): Promise; + getPackageAvailableVersions( + environment: PythonEnvironment, + packageName: string, + options?: GetPackageAvailableVersionsOptions, + ): Promise; + /** - * Delegates version lookup to the underlying package manager. - * - * Managers that do not implement version lookup - or that resolve `undefined` - are treated - * as lacking the capability, so this rejects with {@link PackageVersionLookupNotSupportedError}. - * All other errors from the manager propagate unchanged. + * Delegates version lookup to the underlying package manager using the requested error mode. */ async getPackageAvailableVersions( environment: PythonEnvironment, packageName: string, - ): Promise { - if (!this.manager.getPackageAvailableVersions) { - throw new PackageVersionLookupNotSupportedError( - `Package version lookup is not supported by package manager: ${this.id}`, - ); - } - const versions = await this.manager.getPackageAvailableVersions(environment, packageName); - if (versions === undefined) { - // A manager that resolves `undefined` is signalling the capability is unavailable. - throw new PackageVersionLookupNotSupportedError( - `Package version lookup is not supported by package manager: ${this.id}`, - ); + options?: GetPackageAvailableVersionsOptions, + ): Promise { + const shouldThrow = options?.errorMode === 'throw'; + try { + if (!this.manager.getPackageAvailableVersions) { + throw new PackageVersionLookupNotSupportedError( + `Package version lookup is not supported by package manager: ${this.id}`, + ); + } + const versions = await this.manager.getPackageAvailableVersions(environment, packageName); + if (versions === undefined && shouldThrow) { + throw new PackageVersionLookupNotSupportedError( + `Package version lookup is not supported by package manager: ${this.id}`, + ); + } + return versions; + } catch (error) { + if (shouldThrow) { + throw error; + } + return undefined; } - return versions; } getDirectPackageNames(environment: PythonEnvironment): Promise | undefined> { diff --git a/src/managers/builtin/pipPackageManager.ts b/src/managers/builtin/pipPackageManager.ts index 41cf59d8f..faec04a24 100644 --- a/src/managers/builtin/pipPackageManager.ts +++ b/src/managers/builtin/pipPackageManager.ts @@ -186,7 +186,7 @@ export class PipPackageManager implements PackageManager, Disposable { * * @param environment - The Python environment to query. * @param packageName - The package whose versions should be listed. - * @returns A promise that resolves to a non-empty array of {@link Pep440Version} objects. + * @returns A promise that resolves to an array of {@link Pep440Version} objects. * @throws {@link PackageVersionLookupNotSupportedError} when pip is too old to list versions. */ async getPackageAvailableVersions( diff --git a/src/test/integration/packageManager.integration.test.ts b/src/test/integration/packageManager.integration.test.ts index f42df8539..22484bd02 100644 --- a/src/test/integration/packageManager.integration.test.ts +++ b/src/test/integration/packageManager.integration.test.ts @@ -3,7 +3,13 @@ import * as vscode from 'vscode'; import { compare } from '@renovatebot/pep440'; import assert from 'assert'; import * as path from 'path'; -import { Package, PythonEnvironment, PythonEnvironmentApi, PythonProject } from '../../api'; +import { + Package, + PythonEnvironment, + PythonEnvironmentApi, + PythonProject, + isPackageVersionLookupNotSupportedError, +} from '../../api'; import { CONDA_MANAGER_ID, DEFAULT_PACKAGE_MANAGER_ID, VENV_MANAGER_ID } from '../../common/constants'; import { PythonProjectSettings } from '../../internal.api'; import { getConda } from '../../managers/conda/condaUtils'; @@ -210,12 +216,23 @@ for (const profile of profiles) { test(`${profile.name} Package Manager should list available package versions`, async function () { const packages = await api.getPackages(environment!, { skipCache: true }); assert.ok(packages, 'Unable to list packages before version lookup'); + if (!profile.supportsVersionLookup(packages)) { + // The profile declares that the active manager/tool version does not support + // version lookup, so the API must surface the typed unsupported-capability error + // rather than an operational failure. Assert that contract, then skip. + await assert.rejects( + () => api.getPackageAvailableVersions(environment!, 'requests', { errorMode: 'throw' }), + (error: unknown) => isPackageVersionLookupNotSupportedError(error), + `${profile.name} did not report unsupported version lookup with the typed error`, + ); this.skip(); return; } - const versions = await api.getPackageAvailableVersions(environment!, 'requests'); + // Supported profiles must resolve to a defined, non-empty result; operational failures + // propagate and fail the test instead of silently resolving to undefined. + const versions = await api.getPackageAvailableVersions(environment!, 'requests', { errorMode: 'throw' }); assert.ok(versions, `${profile.name} unexpectedly failed to retrieve package versions`); assert.ok(versions.length > 0, 'No package versions available'); }); diff --git a/src/test/internalPackageManager.versionLookup.unit.test.ts b/src/test/internalPackageManager.versionLookup.unit.test.ts new file mode 100644 index 000000000..5c8ba2e92 --- /dev/null +++ b/src/test/internalPackageManager.versionLookup.unit.test.ts @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import { isPackageVersionLookupNotSupportedError, PackageManager, PythonEnvironment } from '../api'; +import { InternalPackageManager } from '../internal.api'; + +suite('InternalPackageManager.getPackageAvailableVersions', () => { + const environment = { envId: { id: 'env', managerId: 'mgr' } } as PythonEnvironment; + + test('resolves undefined when errorMode is omitted', async () => { + const manager = new InternalPackageManager('test:manager', {} as unknown as PackageManager); + assert.strictEqual(await manager.getPackageAvailableVersions(environment, 'requests'), undefined); + }); + + test('rejects when errorMode is throw', async () => { + const manager = new InternalPackageManager('test:manager', {} as unknown as PackageManager); + await assert.rejects( + () => manager.getPackageAvailableVersions(environment, 'requests', { errorMode: 'throw' }), + (error: unknown) => isPackageVersionLookupNotSupportedError(error), + ); + }); +});