diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 616edca22..7ed91fccf 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. +- 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 + +- 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..28fdd2d11 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; @@ -16,8 +20,36 @@ 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. +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 legacyAvailableVersions; +void explicitLegacyAvailableVersions; +void throwingAvailableVersions; +void lookupErrorIsError; +void lookupErrorCodeIsExact; +void guardNarrows; diff --git a/src/api.ts b/src/api.ts index 2779d27b0..13504a2d3 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 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 + * 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,72 @@ 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') + ); +} + +/** + * 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. @@ -1139,17 +1216,26 @@ 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`. + * 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. + * @param options Controls how lookup failures are reported. * @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. + * 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; /** diff --git a/src/features/envCommands.ts b/src/features/envCommands.ts index fafb6fbcd..fabf31702 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, { errorMode: 'throw' }), + ); + } 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..a34524875 100644 --- a/src/features/pythonApi.ts +++ b/src/features/pythonApi.ts @@ -10,12 +10,14 @@ import { EnvironmentManager, GetEnvironmentScope, GetEnvironmentsScope, + GetPackageAvailableVersionsOptions, GetPackagesOptions, Package, PackageId, PackageInfo, PackageManagementOptions, PackageManager, + PackageVersionLookupNotSupportedError, Pep440Version, PythonBackgroundRunOptions, PythonEnvironment, @@ -319,16 +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, + options?: GetPackageAvailableVersionsOptions, ): Promise { await waitForEnvManagerId([context.envId.managerId]); const manager = this.envManagers.getPackageManager(context); if (!manager) { - return Promise.resolve(undefined); + 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 6d41cb5c3..606b6b81c 100644 --- a/src/internal.api.ts +++ b/src/internal.api.ts @@ -10,6 +10,7 @@ import { EnvironmentManager, GetEnvironmentScope, GetEnvironmentsScope, + GetPackageAvailableVersionsOptions, GetPackagesOptions, IconPath, Package, @@ -18,6 +19,7 @@ import { PackageInfo, PackageManagementOptions, PackageManager, + PackageVersionLookupNotSupportedError, PythonEnvironment, PythonEnvironmentExecutionInfo, PythonEnvironmentId, @@ -400,10 +402,42 @@ export class InternalPackageManager implements PackageManager { 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 using the requested error mode. + */ + async getPackageAvailableVersions( + environment: PythonEnvironment, + packageName: string, + options?: GetPackageAvailableVersionsOptions, ): Promise { - return this.manager.getPackageAvailableVersions - ? this.manager.getPackageAvailableVersions(environment, packageName) - : Promise.resolve(undefined); + 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; + } } getDirectPackageNames(environment: PythonEnvironment): Promise | undefined> { diff --git a/src/managers/builtin/pipPackageManager.ts b/src/managers/builtin/pipPackageManager.ts index 836244bb7..faec04a24 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 an 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 { 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), + ); + }); +});