Skip to content
Open
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
6 changes: 6 additions & 0 deletions api/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ 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.2.1]

### Changed

- Documented that `getEnvironments('all' | 'global')` and `refreshEnvironments(undefined)` isolate per-manager failures: they resolve with the successful managers' results and only reject when every manager fails, in which case the promise rejects with an aggregate error whose `errors` array holds each manager's failure (mirroring the standard `AggregateError` shape).

## [1.2.0]

### Added
Expand Down
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.2.1",
"author": {
"name": "Microsoft Corporation"
},
Expand Down
7 changes: 7 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1049,13 +1049,20 @@ export interface PythonEnvironmentsApi {
* Initiates a refresh of Python environments within the specified scope.
* @param scope - The scope within which to search for environments.
* @returns A promise that resolves when the search is complete.
* @throws When the scope spans all managers and every manager fails, the promise rejects with an
* aggregate error whose `errors` array holds each manager's failure (mirroring the standard
* `AggregateError` shape). If at least one manager succeeds, the refresh resolves.
*/
refreshEnvironments(scope: RefreshEnvironmentsScope): Promise<void>;

/**
* Retrieves a list of Python environments within the specified scope.
* @param scope - The scope within which to retrieve environments.
* @returns A promise that resolves to an array of Python environments.
* @throws When the scope spans all managers and every manager fails, the promise rejects with an
* aggregate error whose `errors` array holds each manager's failure (mirroring the standard
* `AggregateError` shape). If at least one manager succeeds, only the successful managers'
* environments are returned.
*/
getEnvironments(scope: GetEnvironmentsScope): Promise<PythonEnvironment[]>;

Expand Down
11 changes: 11 additions & 0 deletions src/common/errors/AggregateEnvironmentError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Minimal stand-in for `AggregateError` (absent from the ES2020 lib the extension targets); carries
// the aggregated `errors` without requiring a tsconfig lib bump.
export class AggregateEnvironmentError extends Error {
public readonly errors: unknown[];

constructor(message: string, errors: unknown[]) {
super(message);
this.name = 'AggregateEnvironmentError';
this.errors = [...errors];
}
}
94 changes: 70 additions & 24 deletions src/common/pickers/environments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { sendTelemetryEvent } from '../telemetry/sender';
import { isWindows } from '../utils/platformUtils';
import { handlePythonPath } from '../utils/pythonPath';
import {
QuickPickController,
showErrorMessage,
showOpenDialog,
showQuickPick,
Expand Down Expand Up @@ -121,15 +122,17 @@ async function createEnvironment(
}

Comment thread
StellaHuang95 marked this conversation as resolved.
async function pickEnvironmentImpl(
items: (QuickPickItem | (QuickPickItem & { result: PythonEnvironment }))[],
items: EnvironmentPickItem[],
managers: InternalEnvironmentManager[],
projectEnvManagers: InternalEnvironmentManager[],
options: EnvironmentPickOptions,
onDidShow?: (controller: QuickPickController<EnvironmentPickItem>) => void,
): Promise<PythonEnvironment | undefined> {
const selected = await showQuickPickWithButtons(items, {
placeHolder: Pickers.Environments.selectEnvironment,
ignoreFocusOut: true,
showBackButton: options?.showBackButton,
onDidShow,
});

if (selected && !Array.isArray(selected)) {
Expand All @@ -152,7 +155,7 @@ export async function pickEnvironment(
projectEnvManagers: InternalEnvironmentManager[],
options: EnvironmentPickOptions,
): Promise<PythonEnvironment | undefined> {
const items: (QuickPickItem | (QuickPickItem & { result: PythonEnvironment }))[] = [
const items: EnvironmentPickItem[] = [
{
label: Interpreter.browsePath,
iconPath: new ThemeIcon('folder'),
Expand Down Expand Up @@ -188,30 +191,71 @@ export async function pickEnvironment(
);
}

for (const manager of managers) {
items.push({
label: manager.displayName,
kind: QuickPickItemKind.Separator,
const onDidShow = (controller: QuickPickController<EnvironmentPickItem>) => {
controller.setBusy(true);
if (managers.length === 0) {
controller.setBusy(false);
return;
}

const sections: (EnvironmentPickItem[] | undefined)[] = managers.map(() => undefined);
let remaining = managers.length;

const publish = () => {
const withEnvironments: EnvironmentPickItem[] = [...items];
for (const section of sections) {
if (section) {
withEnvironments.push(...section);
}
}
controller.setItems(withEnvironments);
};

managers.forEach((manager, index) => {
void (async () => {
try {
const environments = await manager.getEnvironments('all');
const section: EnvironmentPickItem[] = [
{
label: manager.displayName,
kind: QuickPickItemKind.Separator,
},
];
section.push(
...environments.map((e) => {
const pathDescription = e.displayPath;
const description =
e.description && e.description.trim()
? `${e.description} (${pathDescription})`
: pathDescription;

return {
label: e.displayName ?? e.name,
description: description,
result: e,
manager: manager,
iconPath: getIconPath(e.iconPath),
};
}),
);
sections[index] = section;
publish();
Comment thread
StellaHuang95 marked this conversation as resolved.
} catch (reason) {
traceError(
`[pickEnvironment] Failed to load environments for manager "${manager.id}"; section skipped.`,
reason,
);
} finally {
remaining -= 1;
if (remaining === 0) {
controller.setBusy(false);
}
}
})();
});
const envs = await manager.getEnvironments('all');
items.push(
...envs.map((e) => {
const pathDescription = e.displayPath;
const description =
e.description && e.description.trim() ? `${e.description} (${pathDescription})` : pathDescription;

return {
label: e.displayName ?? e.name,
description: description,
result: e,
manager: manager,
iconPath: getIconPath(e.iconPath),
};
}),
);
}
};

return pickEnvironmentImpl(items, managers, projectEnvManagers, options);
return pickEnvironmentImpl(items, managers, projectEnvManagers, options, onDidShow);
}

export async function pickEnvironmentFrom(environments: PythonEnvironment[]): Promise<PythonEnvironment | undefined> {
Expand All @@ -233,3 +277,5 @@ export async function pickEnvironmentFrom(environments: PythonEnvironment[]): Pr
});
return (selected as { e: PythonEnvironment })?.e;
}

type EnvironmentPickItem = QuickPickItem | (QuickPickItem & { result: PythonEnvironment });
41 changes: 40 additions & 1 deletion src/common/window.apis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,12 @@ export interface QuickPickButtonEvent<T extends QuickPickItem> {
readonly button: QuickInputButton;
}

/** Populates items and toggles the busy indicator on a shown quick pick; no-ops once it settles. */
export interface QuickPickController<T extends QuickPickItem> {
setItems(items: readonly T[]): void;
setBusy(busy: boolean): void;
}

export function showQuickPick<T extends QuickPickItem>(
items: readonly T[] | Thenable<readonly T[]>,
options?: QuickPickOptions,
Expand All @@ -167,13 +173,19 @@ export function withProgress<R>(

export async function showQuickPickWithButtons<T extends QuickPickItem>(
items: readonly T[],
options?: QuickPickOptions & { showBackButton?: boolean; buttons?: QuickInputButton[]; selected?: T[] },
options?: QuickPickOptions & {
showBackButton?: boolean;
buttons?: QuickInputButton[];
selected?: T[];
onDidShow?: (controller: QuickPickController<T>) => void;
},
token?: CancellationToken,
itemButtonHandler?: (e: QuickPickItemButtonEvent<T>) => void,
): Promise<T | T[] | undefined> {
const quickPick: QuickPick<T> = window.createQuickPick<T>();
const disposables: Disposable[] = [quickPick];
const deferred = createDeferred<T | T[] | undefined>();
let disposed = false;

quickPick.items = items;
quickPick.canSelectMany = options?.canPickMany ?? false;
Expand Down Expand Up @@ -234,8 +246,35 @@ export async function showQuickPickWithButtons<T extends QuickPickItem>(
quickPick.show();

try {
if (options?.onDidShow) {
const controller: QuickPickController<T> = {
setBusy(busy: boolean) {
if (deferred.completed || disposed) {
return;
}
quickPick.busy = busy;
},
setItems(newItems: readonly T[]) {
if (deferred.completed || disposed) {
return;
}
const activeItems = quickPick.activeItems.filter((item) => newItems.includes(item));
const selectedItems = quickPick.selectedItems.filter((item) => newItems.includes(item));
quickPick.items = newItems;
if (activeItems.length > 0) {
quickPick.activeItems = activeItems;
}
if (selectedItems.length > 0) {
quickPick.selectedItems = selectedItems;
}
},
};
options.onDidShow(controller);
}

return await deferred.promise;
} finally {
disposed = true;
disposables.forEach((d) => d.dispose());
}
}
Expand Down
56 changes: 53 additions & 3 deletions src/features/pythonApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
ResolveEnvironmentContext,
SetEnvironmentScope,
} from '../api';
import { AggregateEnvironmentError } from '../common/errors/AggregateEnvironmentError';
import { traceError, traceInfo } from '../common/logging';
import { pickEnvironmentManager } from '../common/pickers/managers';
import { timeout } from '../common/utils/asyncUtils';
Expand Down Expand Up @@ -60,6 +61,50 @@ import { TerminalManager } from './terminal/terminalManager';
const GET_ENVIRONMENT_TIMEOUT_MS = 1000;
const GET_ENVIRONMENT_TIMED_OUT = Symbol('getEnvironmentTimedOut');

// Runs `operation` on every manager concurrently, returns the successful results in manager order,
// logs each failure, and throws AggregateEnvironmentError only when all fail (empty list -> []).
async function collectFromManagers<T>(
Comment thread
StellaHuang95 marked this conversation as resolved.
Comment thread
StellaHuang95 marked this conversation as resolved.
managers: readonly InternalEnvironmentManager[],
context: string,
operation: (manager: InternalEnvironmentManager) => Promise<T>,
): Promise<T[]> {
if (managers.length === 0) {
return [];
}

// Log each failure inside its own async boundary as the manager settles, so a synchronous throw
// or one slow/never-settling manager cannot hide the others or defer reporting of a failure.
const settled = await Promise.allSettled(
Comment thread
StellaHuang95 marked this conversation as resolved.
managers.map(async (manager) => {
try {
return await operation(manager);
} catch (err) {
traceError(`[${context}] Environment manager "${manager.id}" failed and was skipped.`, err);
throw err;
}
}),
);

const results: T[] = [];
const errors: unknown[] = [];
settled.forEach((outcome) => {
if (outcome.status === 'fulfilled') {
results.push(outcome.value);
} else {
errors.push(outcome.reason);
}
});

if (errors.length === managers.length) {
throw new AggregateEnvironmentError(
`[${context}] All ${managers.length} environment manager(s) failed.`,
errors,
);
Comment thread
StellaHuang95 marked this conversation as resolved.
}

return results;
}

export class PythonEnvironmentApiImpl implements PythonEnvironmentApi {
private readonly _onDidChangeEnvironments = new EventEmitter<DidChangeEnvironmentsEventArgs>();
private readonly _onDidChangeEnvironment = new EventEmitter<DidChangeEnvironmentEventArgs>();
Expand Down Expand Up @@ -209,7 +254,9 @@ export class PythonEnvironmentApiImpl implements PythonEnvironmentApi {

if (currentScope === undefined) {
await waitForAllEnvManagers();
await Promise.all(this.envManagers.managers.map((manager) => manager.refresh(currentScope)));
await collectFromManagers(this.envManagers.managers, 'refreshEnvironments(all)', (manager) =>
manager.refresh(currentScope),
);
return Promise.resolve();
}

Expand All @@ -224,8 +271,11 @@ export class PythonEnvironmentApiImpl implements PythonEnvironmentApi {
const currentScope = checkUri(scope) as GetEnvironmentsScope;
if (currentScope === 'all' || currentScope === 'global') {
await waitForAllEnvManagers();
const promises = this.envManagers.managers.map((manager) => manager.getEnvironments(currentScope));
const items = await Promise.all(promises);
const items = await collectFromManagers(
this.envManagers.managers,
`getEnvironments(${currentScope})`,
(manager) => manager.getEnvironments(currentScope),
);
return items.flat();
}

Expand Down
Loading
Loading