Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions api/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions api/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion api/package.json
Original file line number Diff line number Diff line change
@@ -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",
Comment thread
edvilme marked this conversation as resolved.
Comment thread
edvilme marked this conversation as resolved.
"author": {
"name": "Microsoft Corporation"
},
Expand Down
36 changes: 34 additions & 2 deletions api/test/consumer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import type {
PythonEnvironment,
PythonPackageGetterApi,
} from '@vscode/python-environments';
import {
isPackageVersionLookupNotSupportedError,
PackageVersionLookupNotSupportedError,
} from '@vscode/python-environments';

type Equal<Left, Right> =
(<Value>() => Value extends Left ? 1 : 2) extends <Value>() => Value extends Right ? 1 : 2 ? true : false;
Expand All @@ -16,8 +20,36 @@ const refreshReturnIsExact: Equal<RefreshReturn, Promise<void>> = true;

declare const api: PythonPackageGetterApi;
declare const environment: PythonEnvironment;
const availableVersions: Promise<Pep440Version[] | undefined> = api.getPackageAvailableVersions(environment, 'example');
const legacyAvailableVersions: Promise<Pep440Version[] | undefined> = api.getPackageAvailableVersions(
environment,
'example',
);
const explicitLegacyAvailableVersions: Promise<Pep440Version[] | undefined> = api.getPackageAvailableVersions(
environment,
'example',
{ errorMode: 'legacy' },
);
const throwingAvailableVersions: Promise<Pep440Version[]> = 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<typeof lookupError.code, 'PackageVersionLookupNotSupported'> = true;

// The type guard narrows unknown values via the stable discriminator (bundle-boundary safe).
declare const maybeError: unknown;
Comment thread
edvilme marked this conversation as resolved.
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;
98 changes: 92 additions & 6 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -746,11 +746,22 @@ export interface PackageManager {
getVersion?(environment: PythonEnvironment): Promise<Pep440Version | undefined>;

/**
* 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,
Expand Down Expand Up @@ -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.
Expand All @@ -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<Pep440Version[]>;
getPackageAvailableVersions(
environment: PythonEnvironment,
packageName: string,
options?: GetPackageAvailableVersionsOptions,
): Promise<Pep440Version[] | undefined>;

/**
Expand Down
21 changes: 16 additions & 5 deletions src/features/envCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) => ({
Expand Down
22 changes: 20 additions & 2 deletions src/features/pythonApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import {
EnvironmentManager,
GetEnvironmentScope,
GetEnvironmentsScope,
GetPackageAvailableVersionsOptions,
GetPackagesOptions,
Package,
PackageId,
PackageInfo,
PackageManagementOptions,
PackageManager,
PackageVersionLookupNotSupportedError,
Pep440Version,
PythonBackgroundRunOptions,
PythonEnvironment,
Expand Down Expand Up @@ -319,16 +321,32 @@ export class PythonEnvironmentApiImpl implements PythonEnvironmentApi {
}
return manager.getPackages(context, options);
}
getPackageAvailableVersions(
context: PythonEnvironment,
packageName: string,
options: GetPackageAvailableVersionsOptions & { errorMode: 'throw' },
): Promise<Pep440Version[]>;
getPackageAvailableVersions(
context: PythonEnvironment,
packageName: string,
options?: GetPackageAvailableVersionsOptions,
): Promise<Pep440Version[] | undefined>;
async getPackageAvailableVersions(
context: PythonEnvironment,
packageName: string,
options?: GetPackageAvailableVersionsOptions,
): Promise<Pep440Version[] | undefined> {
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<DidChangePackagesEventArgs> = this._onDidChangePackages.event;

Expand Down
40 changes: 37 additions & 3 deletions src/internal.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
EnvironmentManager,
GetEnvironmentScope,
GetEnvironmentsScope,
GetPackageAvailableVersionsOptions,
GetPackagesOptions,
IconPath,
Package,
Expand All @@ -18,6 +19,7 @@ import {
PackageInfo,
PackageManagementOptions,
PackageManager,
PackageVersionLookupNotSupportedError,
PythonEnvironment,
PythonEnvironmentExecutionInfo,
PythonEnvironmentId,
Expand Down Expand Up @@ -400,10 +402,42 @@ export class InternalPackageManager implements PackageManager {
getPackageAvailableVersions(
environment: PythonEnvironment,
packageName: string,
options: GetPackageAvailableVersionsOptions & { errorMode: 'throw' },
): Promise<Pep440Version[]>;
getPackageAvailableVersions(
environment: PythonEnvironment,
packageName: string,
options?: GetPackageAvailableVersionsOptions,
): Promise<Pep440Version[] | undefined>;

/**
* Delegates version lookup to the underlying package manager using the requested error mode.
*/
async getPackageAvailableVersions(
environment: PythonEnvironment,
packageName: string,
options?: GetPackageAvailableVersionsOptions,
): Promise<Pep440Version[] | undefined> {
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) {
Comment thread
edvilme marked this conversation as resolved.
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<Set<string> | undefined> {
Expand Down
Loading
Loading