Skip to content

Commit 69697fd

Browse files
committed
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
1 parent 85af228 commit 69697fd

10 files changed

Lines changed: 261 additions & 40 deletions

‎api/CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- 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.
1313
- 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.
14+
- 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.
1415

1516
### Changed
1617

17-
- `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<Pep440Version[]>`.
1818
- 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.
1919

2020
## [1.2.0]

‎api/test/consumer.ts‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,23 @@ type Equal<Left, Right> =
1515
type AvailableVersionsReturn = ReturnType<PythonPackageGetterApi['getPackageAvailableVersions']>;
1616
type RefreshReturn = ReturnType<PackageManager['refresh']>;
1717

18-
const availableVersionsReturnIsExact: Equal<AvailableVersionsReturn, Promise<Pep440Version[]>> = true;
18+
const availableVersionsReturnIsExact: Equal<AvailableVersionsReturn, Promise<Pep440Version[] | undefined>> = true;
1919
const refreshReturnIsExact: Equal<RefreshReturn, Promise<void>> = true;
2020

2121
declare const api: PythonPackageGetterApi;
2222
declare const environment: PythonEnvironment;
23-
const availableVersions: Promise<Pep440Version[]> = api.getPackageAvailableVersions(environment, 'example');
23+
const legacyAvailableVersions: Promise<Pep440Version[] | undefined> = api.getPackageAvailableVersions(
24+
environment,
25+
'example',
26+
);
27+
const explicitLegacyAvailableVersions: Promise<Pep440Version[] | undefined> = api.getPackageAvailableVersions(
28+
environment,
29+
'example',
30+
{ errorMode: 'legacy' },
31+
);
32+
const throwingAvailableVersions: Promise<Pep440Version[]> = api.getPackageAvailableVersions(environment, 'example', {
33+
errorMode: 'throw',
34+
});
2435

2536
// The unsupported-capability error is part of the public contract: it is constructible, extends
2637
// Error, and exposes a stable string-literal `code` discriminator.
@@ -36,7 +47,9 @@ const guardNarrows: boolean = isPackageVersionLookupNotSupportedError(maybeError
3647

3748
void availableVersionsReturnIsExact;
3849
void refreshReturnIsExact;
39-
void availableVersions;
50+
void legacyAvailableVersions;
51+
void explicitLegacyAvailableVersions;
52+
void throwingAvailableVersions;
4053
void lookupErrorIsError;
4154
void lookupErrorCodeIsExact;
4255
void guardNarrows;

‎src/api.ts‎

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1179,6 +1179,22 @@ export function isPackageVersionLookupNotSupportedError(
11791179
);
11801180
}
11811181

1182+
/**
1183+
* Controls how package version lookup failures are reported.
1184+
*/
1185+
export interface GetPackageAvailableVersionsOptions {
1186+
/**
1187+
* Determines whether lookup failures preserve the legacy `undefined` result or reject.
1188+
*
1189+
* - `legacy` resolves to `undefined` for unsupported lookups and operational failures.
1190+
* This remains the default for backward compatibility, but may be removed in a future
1191+
* major API version.
1192+
* - `throw` rejects with {@link PackageVersionLookupNotSupportedError} for unsupported
1193+
* lookups and propagates operational failures unchanged.
1194+
*/
1195+
errorMode?: 'legacy' | 'throw';
1196+
}
1197+
11821198
export interface PythonPackageGetterApi {
11831199
/**
11841200
* Refresh the list of packages in a Python Environment.
@@ -1200,24 +1216,27 @@ export interface PythonPackageGetterApi {
12001216
/**
12011217
* Get the list of available versions for a package, newest first.
12021218
*
1203-
* The returned promise distinguishes an unsupported capability from an operational failure:
1204-
* - It resolves to a non-empty array of {@link Pep440Version} objects when versions are found.
1205-
* - It rejects with a {@link PackageVersionLookupNotSupportedError} when the environment's
1206-
* package manager does not support version lookup (for example, the default/missing manager,
1207-
* Poetry, or a Pip older than 21.2). Use {@link isPackageVersionLookupNotSupportedError} to
1208-
* detect this reliably across extension bundle boundaries and fall back to manual entry.
1209-
* - It rejects with the original error for any other failure (command, network, or
1210-
* malformed/unparseable output), which callers should handle or surface normally.
1219+
* By default, this preserves the legacy behavior of resolving to `undefined` for unsupported
1220+
* lookups and operational failures. Pass `{ errorMode: 'throw' }` to distinguish unsupported
1221+
* capabilities from operational failures: unsupported lookups reject with
1222+
* {@link PackageVersionLookupNotSupportedError}, while other failures propagate unchanged.
12111223
*
12121224
* @param environment The Python Environment context for the lookup.
12131225
* @param packageName The name of the package to look up.
1214-
* @returns A promise that resolves to an array of {@link Pep440Version} objects (newest first).
1215-
* @throws {@link PackageVersionLookupNotSupportedError} when version lookup is unsupported.
1226+
* @param options Controls how lookup failures are reported.
1227+
* @returns A promise that resolves to an array of {@link Pep440Version} objects (newest first),
1228+
* or `undefined` in legacy mode when lookup is unsupported or fails.
12161229
*/
12171230
getPackageAvailableVersions(
12181231
environment: PythonEnvironment,
12191232
packageName: string,
1233+
options: GetPackageAvailableVersionsOptions & { errorMode: 'throw' },
12201234
): Promise<Pep440Version[]>;
1235+
getPackageAvailableVersions(
1236+
environment: PythonEnvironment,
1237+
packageName: string,
1238+
options?: GetPackageAvailableVersionsOptions,
1239+
): Promise<Pep440Version[] | undefined>;
12211240

12221241
/**
12231242
* Event raised when the list of packages in a Python Environment changes.

‎src/features/envCommands.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,7 @@ export async function managePackageVersion(context: unknown, em: EnvironmentMana
378378
try {
379379
availableVersions = await withProgress(
380380
{ location: ProgressLocation.Window, title: l10n.t('Fetching available versions for {0}...', pkg.name) },
381-
() => packageManager.getPackageAvailableVersions(environment, pkg.name),
381+
() => packageManager.getPackageAvailableVersions(environment, pkg.name, { errorMode: 'throw' }),
382382
);
383383
} catch (error) {
384384
if (!isPackageVersionLookupNotSupportedError(error)) {

‎src/features/pythonApi.ts‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
EnvironmentManager,
1111
GetEnvironmentScope,
1212
GetEnvironmentsScope,
13+
GetPackageAvailableVersionsOptions,
1314
GetPackagesOptions,
1415
Package,
1516
PackageId,
@@ -320,18 +321,32 @@ export class PythonEnvironmentApiImpl implements PythonEnvironmentApi {
320321
}
321322
return manager.getPackages(context, options);
322323
}
324+
getPackageAvailableVersions(
325+
context: PythonEnvironment,
326+
packageName: string,
327+
options: GetPackageAvailableVersionsOptions & { errorMode: 'throw' },
328+
): Promise<Pep440Version[]>;
329+
getPackageAvailableVersions(
330+
context: PythonEnvironment,
331+
packageName: string,
332+
options?: GetPackageAvailableVersionsOptions,
333+
): Promise<Pep440Version[] | undefined>;
323334
async getPackageAvailableVersions(
324335
context: PythonEnvironment,
325336
packageName: string,
326-
): Promise<Pep440Version[]> {
337+
options?: GetPackageAvailableVersionsOptions,
338+
): Promise<Pep440Version[] | undefined> {
327339
await waitForEnvManagerId([context.envId.managerId]);
328340
const manager = this.envManagers.getPackageManager(context);
329341
if (!manager) {
330-
throw new PackageVersionLookupNotSupportedError(
331-
`No package manager is available to look up versions for: ${context.envId.id}`,
332-
);
342+
if (options?.errorMode === 'throw') {
343+
throw new PackageVersionLookupNotSupportedError(
344+
`No package manager is available to look up versions for: ${context.envId.id}`,
345+
);
346+
}
347+
return undefined;
333348
}
334-
return manager.getPackageAvailableVersions(context, packageName);
349+
return manager.getPackageAvailableVersions(context, packageName, options);
335350
}
336351
onDidChangePackages: Event<DidChangePackagesEventArgs> = this._onDidChangePackages.event;
337352

‎src/internal.api.ts‎

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
EnvironmentManager,
1111
GetEnvironmentScope,
1212
GetEnvironmentsScope,
13+
GetPackageAvailableVersionsOptions,
1314
GetPackagesOptions,
1415
IconPath,
1516
Package,
@@ -398,30 +399,45 @@ export class InternalPackageManager implements PackageManager {
398399
return this.manager.getVersion ? this.manager.getVersion(environment) : Promise.resolve(undefined);
399400
}
400401

402+
getPackageAvailableVersions(
403+
environment: PythonEnvironment,
404+
packageName: string,
405+
options: GetPackageAvailableVersionsOptions & { errorMode: 'throw' },
406+
): Promise<Pep440Version[]>;
407+
getPackageAvailableVersions(
408+
environment: PythonEnvironment,
409+
packageName: string,
410+
options?: GetPackageAvailableVersionsOptions,
411+
): Promise<Pep440Version[] | undefined>;
412+
401413
/**
402-
* Delegates version lookup to the underlying package manager.
403-
*
404-
* Managers that do not implement version lookup - or that resolve `undefined` - are treated
405-
* as lacking the capability, so this rejects with {@link PackageVersionLookupNotSupportedError}.
406-
* All other errors from the manager propagate unchanged.
414+
* Delegates version lookup to the underlying package manager using the requested error mode.
407415
*/
408416
async getPackageAvailableVersions(
409417
environment: PythonEnvironment,
410418
packageName: string,
411-
): Promise<Pep440Version[]> {
412-
if (!this.manager.getPackageAvailableVersions) {
413-
throw new PackageVersionLookupNotSupportedError(
414-
`Package version lookup is not supported by package manager: ${this.id}`,
415-
);
416-
}
417-
const versions = await this.manager.getPackageAvailableVersions(environment, packageName);
418-
if (versions === undefined) {
419-
// A manager that resolves `undefined` is signalling the capability is unavailable.
420-
throw new PackageVersionLookupNotSupportedError(
421-
`Package version lookup is not supported by package manager: ${this.id}`,
422-
);
419+
options?: GetPackageAvailableVersionsOptions,
420+
): Promise<Pep440Version[] | undefined> {
421+
const shouldThrow = options?.errorMode === 'throw';
422+
try {
423+
if (!this.manager.getPackageAvailableVersions) {
424+
throw new PackageVersionLookupNotSupportedError(
425+
`Package version lookup is not supported by package manager: ${this.id}`,
426+
);
427+
}
428+
const versions = await this.manager.getPackageAvailableVersions(environment, packageName);
429+
if (versions === undefined && shouldThrow) {
430+
throw new PackageVersionLookupNotSupportedError(
431+
`Package version lookup is not supported by package manager: ${this.id}`,
432+
);
433+
}
434+
return versions;
435+
} catch (error) {
436+
if (shouldThrow) {
437+
throw error;
438+
}
439+
return undefined;
423440
}
424-
return versions;
425441
}
426442

427443
getDirectPackageNames(environment: PythonEnvironment): Promise<Set<string> | undefined> {
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import * as assert from 'assert';
5+
import { isPackageVersionLookupNotSupportedError, PackageVersionLookupNotSupportedError } from '../api';
6+
7+
suite('PackageVersionLookupNotSupportedError', () => {
8+
test('is an Error subclass with a stable code and name', () => {
9+
const error = new PackageVersionLookupNotSupportedError('nope');
10+
assert.ok(error instanceof Error);
11+
assert.strictEqual(error.code, 'PackageVersionLookupNotSupported');
12+
assert.strictEqual(error.name, 'PackageVersionLookupNotSupportedError');
13+
assert.strictEqual(error.message, 'nope');
14+
});
15+
16+
test('type guard recognizes a cross-bundle error via its stable code (without instanceof)', () => {
17+
const crossBundle = {
18+
name: 'PackageVersionLookupNotSupportedError',
19+
code: 'PackageVersionLookupNotSupported',
20+
message: 'from another bundle',
21+
};
22+
assert.strictEqual(crossBundle instanceof PackageVersionLookupNotSupportedError, false);
23+
assert.ok(isPackageVersionLookupNotSupportedError(crossBundle));
24+
});
25+
26+
test('type guard rejects unrelated errors and values', () => {
27+
assert.strictEqual(isPackageVersionLookupNotSupportedError(new Error('other')), false);
28+
assert.strictEqual(isPackageVersionLookupNotSupportedError({ code: 'ENOENT' }), false);
29+
assert.strictEqual(isPackageVersionLookupNotSupportedError(null), false);
30+
});
31+
});

‎src/test/integration/packageManager.integration.test.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import * as vscode from 'vscode';
33
import { compare } from '@renovatebot/pep440';
44
import assert from 'assert';
55
import * as path from 'path';
6-
import { Package, PythonEnvironment, PythonEnvironmentApi, PythonProject } from '../../api';
6+
import {
7+
Package,
8+
PythonEnvironment,
9+
PythonEnvironmentApi,
10+
PythonProject,
11+
isPackageVersionLookupNotSupportedError,
12+
} from '../../api';
713
import { CONDA_MANAGER_ID, DEFAULT_PACKAGE_MANAGER_ID, VENV_MANAGER_ID } from '../../common/constants';
814
import { PythonProjectSettings } from '../../internal.api';
915
import { getConda } from '../../managers/conda/condaUtils';
@@ -210,12 +216,23 @@ for (const profile of profiles) {
210216
test(`${profile.name} Package Manager should list available package versions`, async function () {
211217
const packages = await api.getPackages(environment!, { skipCache: true });
212218
assert.ok(packages, 'Unable to list packages before version lookup');
219+
213220
if (!profile.supportsVersionLookup(packages)) {
221+
// The profile declares that the active manager/tool version does not support
222+
// version lookup, so the API must surface the typed unsupported-capability error
223+
// rather than an operational failure. Assert that contract, then skip.
224+
await assert.rejects(
225+
() => api.getPackageAvailableVersions(environment!, 'requests', { errorMode: 'throw' }),
226+
(error: unknown) => isPackageVersionLookupNotSupportedError(error),
227+
`${profile.name} did not report unsupported version lookup with the typed error`,
228+
);
214229
this.skip();
215230
return;
216231
}
217232

218-
const versions = await api.getPackageAvailableVersions(environment!, 'requests');
233+
// Supported profiles must resolve to a defined, non-empty result; operational failures
234+
// propagate and fail the test instead of silently resolving to undefined.
235+
const versions = await api.getPackageAvailableVersions(environment!, 'requests', { errorMode: 'throw' });
219236
assert.ok(versions, `${profile.name} unexpectedly failed to retrieve package versions`);
220237
assert.ok(versions.length > 0, 'No package versions available');
221238
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import * as assert from 'assert';
5+
import { isPackageVersionLookupNotSupportedError, PackageManager, PythonEnvironment } from '../api';
6+
import { InternalPackageManager } from '../internal.api';
7+
8+
suite('InternalPackageManager.getPackageAvailableVersions', () => {
9+
const environment = { envId: { id: 'env', managerId: 'mgr' } } as PythonEnvironment;
10+
11+
test('legacy mode resolves undefined when the manager does not implement lookup', async () => {
12+
const manager = new InternalPackageManager('test:manager', {} as unknown as PackageManager);
13+
assert.strictEqual(await manager.getPackageAvailableVersions(environment, 'requests'), undefined);
14+
});
15+
16+
test('throw mode rejects with the typed unsupported error when the manager does not implement lookup', async () => {
17+
const manager = new InternalPackageManager('test:manager', {} as unknown as PackageManager);
18+
await assert.rejects(
19+
() => manager.getPackageAvailableVersions(environment, 'requests', { errorMode: 'throw' }),
20+
(error: unknown) => isPackageVersionLookupNotSupportedError(error),
21+
);
22+
});
23+
24+
test('legacy mode resolves undefined for operational errors', async () => {
25+
const manager = new InternalPackageManager('test:manager', {
26+
getPackageAvailableVersions: async () => {
27+
throw new Error('network down');
28+
},
29+
} as unknown as PackageManager);
30+
assert.strictEqual(await manager.getPackageAvailableVersions(environment, 'requests'), undefined);
31+
});
32+
33+
test('throw mode propagates operational errors from the underlying manager', async () => {
34+
const operational = new Error('network down');
35+
const manager = new InternalPackageManager('test:manager', {
36+
getPackageAvailableVersions: async () => {
37+
throw operational;
38+
},
39+
} as unknown as PackageManager);
40+
await assert.rejects(
41+
() => manager.getPackageAvailableVersions(environment, 'requests', { errorMode: 'throw' }),
42+
(error: unknown) => error === operational && !isPackageVersionLookupNotSupportedError(error),
43+
);
44+
});
45+
});

0 commit comments

Comments
 (0)