;
+}
diff --git a/src/managers/base/commands/packageManagerCommand.ts b/src/managers/base/commands/packageManagerCommand.ts
index 3449e7665..9bba7506c 100644
--- a/src/managers/base/commands/packageManagerCommand.ts
+++ b/src/managers/base/commands/packageManagerCommand.ts
@@ -7,6 +7,8 @@ import { getConfiguration } from '../../../common/workspace.apis';
*/
export interface BaseExecuteArgs {
cancellationToken?: CancellationToken;
+ strict?: boolean;
+ toolExecution?: boolean;
}
/**
@@ -17,6 +19,8 @@ export interface CommandConstructorOptions {
uvExecutable?: string;
cwd?: string;
log?: LogOutputChannel;
+ cancellationToken?: CancellationToken;
+ toolExecution?: boolean;
}
/**
diff --git a/src/managers/builtin/commands/factory.ts b/src/managers/builtin/commands/factory.ts
index cd7fe993d..a0945d4b3 100644
--- a/src/managers/builtin/commands/factory.ts
+++ b/src/managers/builtin/commands/factory.ts
@@ -11,8 +11,9 @@ export async function createPipOrUvCommandWithKind(
PipCommand: CommandConstructor
,
UvCommand: CommandConstructor,
): Promise> {
- if (await shouldUseUv(options.log, environmentPath)) {
- const uvExecutable = await getUvExecutable(options.log, environmentPath);
+ const token = options.toolExecution ? options.cancellationToken : undefined;
+ if (await shouldUseUv(options.log, environmentPath, environmentPath, token)) {
+ const uvExecutable = await getUvExecutable(options.log, environmentPath, token);
if (!uvExecutable) {
throw new Error(`uv became unavailable for environment: ${environmentPath}`);
}
diff --git a/src/managers/builtin/commands/install.ts b/src/managers/builtin/commands/install.ts
index 9ba7c69c2..cbcda7d66 100644
--- a/src/managers/builtin/commands/install.ts
+++ b/src/managers/builtin/commands/install.ts
@@ -22,10 +22,11 @@ export class PipInstallCommand extends InstallCommand {
await runPython(
this.pythonExecutable,
this.buildCommand(executeArgs),
- undefined,
+ executeArgs.toolExecution ? this.cwd : undefined,
this.log,
executeArgs.cancellationToken,
- this.timeout,
+ this.timeout ?? (executeArgs.toolExecution ? 300_000 : undefined),
+ executeArgs.toolExecution,
);
}
}
@@ -47,6 +48,14 @@ export class UvInstallCommand extends InstallCommand {
}
async execute(executeArgs: InstallExecuteArgs): Promise {
- await runUV(this.buildCommand(executeArgs), undefined, this.log, executeArgs.cancellationToken, this.timeout, this.uvExecutable);
+ await runUV(
+ this.buildCommand(executeArgs),
+ executeArgs.toolExecution ? this.cwd : undefined,
+ this.log,
+ executeArgs.cancellationToken,
+ this.timeout ?? (executeArgs.toolExecution ? 300_000 : undefined),
+ this.uvExecutable,
+ executeArgs.toolExecution,
+ );
}
}
diff --git a/src/managers/builtin/commands/list.ts b/src/managers/builtin/commands/list.ts
index afd89ade9..5a3f94fd4 100644
--- a/src/managers/builtin/commands/list.ts
+++ b/src/managers/builtin/commands/list.ts
@@ -20,19 +20,32 @@ export class PipListCommand extends ListCommand {
this.log,
executeArgs?.cancellationToken,
this.timeout,
+ executeArgs?.toolExecution,
);
let json: unknown;
try {
json = JSON.parse(output);
} catch (e) {
this.log?.error(`Failed to parse pip list output: ${e}`);
+ if (executeArgs?.strict) {
+ throw e;
+ }
return [];
}
if (!Array.isArray(json)) {
this.log?.error('Invalid output from pip list command');
+ if (executeArgs?.strict) {
+ throw new Error('Invalid output from pip list command');
+ }
return [];
}
+ if (
+ executeArgs?.strict &&
+ json.some((item) => !item || typeof item.name !== 'string' || typeof item.version !== 'string')
+ ) {
+ throw new Error('Invalid package in pip list output');
+ }
return json
.filter(({ name, version }) => !!name && !!version)
.map(({ name, version }) => ({
@@ -62,19 +75,32 @@ export class UvListCommand extends ListCommand {
executeArgs?.cancellationToken,
this.timeout,
this.uvExecutable,
+ executeArgs?.toolExecution,
);
let json: unknown;
try {
json = JSON.parse(output);
} catch (e) {
this.log?.error(`Failed to parse uv pip list output: ${e}`);
+ if (executeArgs?.strict) {
+ throw e;
+ }
return [];
}
if (!Array.isArray(json)) {
this.log?.error('Invalid output from uv pip list command');
+ if (executeArgs?.strict) {
+ throw new Error('Invalid output from uv pip list command');
+ }
return [];
}
+ if (
+ executeArgs?.strict &&
+ json.some((item) => !item || typeof item.name !== 'string' || typeof item.version !== 'string')
+ ) {
+ throw new Error('Invalid package in uv pip list output');
+ }
return json
.filter(({ name, version }) => !!name && !!version)
.map(({ name, version }) => ({
diff --git a/src/managers/builtin/commands/uninstall.ts b/src/managers/builtin/commands/uninstall.ts
index 8933898f6..7c61aacd8 100644
--- a/src/managers/builtin/commands/uninstall.ts
+++ b/src/managers/builtin/commands/uninstall.ts
@@ -18,7 +18,8 @@ export class PipUninstallCommand extends UninstallCommand {
undefined,
this.log,
executeArgs.cancellationToken,
- this.timeout,
+ this.timeout ?? (executeArgs.toolExecution ? 300_000 : undefined),
+ executeArgs.toolExecution,
);
}
}
@@ -36,6 +37,14 @@ export class UvUninstallCommand extends UninstallCommand {
}
async execute(executeArgs: UninstallExecuteArgs): Promise {
- await runUV(this.buildCommand(executeArgs), undefined, this.log, executeArgs.cancellationToken, this.timeout, this.uvExecutable);
+ await runUV(
+ this.buildCommand(executeArgs),
+ undefined,
+ this.log,
+ executeArgs.cancellationToken,
+ this.timeout ?? (executeArgs.toolExecution ? 300_000 : undefined),
+ this.uvExecutable,
+ executeArgs.toolExecution,
+ );
}
}
diff --git a/src/managers/builtin/helpers.ts b/src/managers/builtin/helpers.ts
index edb64dbe4..9211c5e52 100644
--- a/src/managers/builtin/helpers.ts
+++ b/src/managers/builtin/helpers.ts
@@ -5,6 +5,8 @@ import { EventNames } from '../../common/telemetry/constants';
import { sendTelemetryEvent } from '../../common/telemetry/sender';
import { createDeferred } from '../../common/utils/deferred';
import { getConfiguration, getWorkspaceFolder, isWorkspaceTrusted } from '../../common/workspace.apis';
+import { runLoggedProcess } from '../../common/utils/processRunner';
+import { throwIfCancelled, waitForToolRead } from '../../internal/pythonToolSupport';
import { getUvEnvironments } from './uvEnvironments';
let available = createDeferred();
@@ -51,10 +53,17 @@ export async function isUvInstalled(log?: LogOutputChannel): Promise {
* Resolves uv from this session's installation or PATH, or from pyprojectx in the owning trusted workspace.
* @param log Optional command log.
* @param scope Path inside the workspace whose local uv should be considered.
+ * @param token Optional cancellation token for noninteractive tool requests.
* @returns The command to spawn, or undefined when uv is unavailable.
*/
-export async function getUvExecutable(log?: LogOutputChannel, scope?: string): Promise {
- if (await isUvInstalled(log)) {
+export async function getUvExecutable(
+ log?: LogOutputChannel,
+ scope?: string,
+ token?: CancellationToken,
+): Promise {
+ throwIfCancelled(token);
+ const installed = isUvInstalled(log);
+ if (token ? await waitForToolRead(installed, token) : await installed) {
return uvExecutable;
}
const workspaceFolder = scope && isWorkspaceTrusted() ? getWorkspaceFolder(Uri.file(scope)) : undefined;
@@ -62,12 +71,13 @@ export async function getUvExecutable(log?: LogOutputChannel, scope?: string): P
return undefined;
}
const executable = path.join(workspaceFolder.uri.fsPath, '.pyprojectx', 'main', process.platform === 'win32' ? 'uv.exe' : 'uv');
- return new Promise((resolve) => {
+ const probe = new Promise((resolve) => {
log?.info(`Running: ${executable} --version`);
const proc = spawnProcess(executable, ['--version']);
proc.on('error', () => resolve(undefined));
proc.on('exit', (code) => resolve(code === 0 ? executable : undefined));
});
+ return token ? waitForToolRead(probe, token) : probe;
}
/**
@@ -75,14 +85,21 @@ export async function getUvExecutable(log?: LogOutputChannel, scope?: string): P
* @param log - Optional log output channel for logging operations
* @param envPath - Optional environment path to check against UV environments list
* @param scope - Path used to locate the workspace's uv; defaults to the environment path.
+ * @param token - Optional cancellation token for noninteractive tool requests.
* @returns True if uv should be used, false otherwise. For UV environments, returns true if uv is installed. For other environments, checks the 'python-envs.alwaysUseUv' setting and uv availability.
*/
-export async function shouldUseUv(log?: LogOutputChannel, envPath?: string, scope = envPath): Promise {
+export async function shouldUseUv(
+ log?: LogOutputChannel,
+ envPath?: string,
+ scope = envPath,
+ token?: CancellationToken,
+): Promise {
+ throwIfCancelled(token);
if (envPath) {
// always use uv if the given environment is stored as a uv env
const uvEnvs = await getUvEnvironments();
if (uvEnvs.includes(envPath)) {
- return (await getUvExecutable(log, scope)) !== undefined;
+ return (await getUvExecutable(log, scope, token)) !== undefined;
}
}
@@ -91,7 +108,7 @@ export async function shouldUseUv(log?: LogOutputChannel, envPath?: string, scop
const alwaysUseUv = config.get('alwaysUseUv', true);
if (alwaysUseUv) {
- return (await getUvExecutable(log, scope)) !== undefined;
+ return (await getUvExecutable(log, scope, token)) !== undefined;
}
return false;
}
@@ -103,7 +120,11 @@ export async function runUV(
token?: CancellationToken,
timeout?: number,
executable = uvExecutable,
+ toolExecution = false,
): Promise {
+ if (toolExecution) {
+ return runLoggedProcess(executable, args, { cwd }, log, token, timeout);
+ }
log?.info(`Running: ${executable} ${args.join(' ')}`);
return new Promise((resolve, reject) => {
const spawnOptions: { cwd?: string; timeout?: number } = { cwd };
@@ -158,7 +179,11 @@ export async function runPython(
log?: LogOutputChannel,
token?: CancellationToken,
timeout?: number,
+ toolExecution = false,
): Promise {
+ if (toolExecution) {
+ return runLoggedProcess(python, args, { cwd }, log, token, timeout, true);
+ }
log?.info(`Running: ${python} ${args.join(' ')}`);
return new Promise((resolve, reject) => {
const proc = spawnProcess(python, args, { cwd: cwd, timeout });
diff --git a/src/managers/builtin/inlineScript/envManager.ts b/src/managers/builtin/inlineScript/envManager.ts
index 61eb7ec2d..39dfcded4 100644
--- a/src/managers/builtin/inlineScript/envManager.ts
+++ b/src/managers/builtin/inlineScript/envManager.ts
@@ -5,6 +5,8 @@ import type { Stats } from 'fs';
import * as fs from 'fs-extra';
import * as path from 'path';
import {
+ CancellationError,
+ CancellationToken,
Disposable,
Event,
EventEmitter,
@@ -94,6 +96,14 @@ import { resolveSystemPythonEnvironmentPath } from '../utils';
import * as uvPythonInstaller from '../uvPythonInstaller';
import { createWithProgress, hasMinimumPathDepth, isDriveRoot, resolveVenvPythonEnvironmentPath } from '../venvUtils';
import { InlineAssociationAccessor, InlineScriptAssociationStore } from './associationStore';
+import {
+ EnvironmentToolSupport,
+ PythonToolError,
+ PythonToolOperation,
+ pythonToolSupport,
+ throwIfCancelled,
+ waitForToolRead,
+} from '../../../internal/pythonToolSupport';
const BASE_INTERPRETER_MANAGER_IDS = new Set([SYSTEM_MANAGER_ID, CONDA_MANAGER_ID, PYENV_MANAGER_ID]);
@@ -179,6 +189,7 @@ interface DiscoveryRefreshPass {
}
interface PendingCreationContext {
+ operation?: PythonToolOperation;
promise: Promise;
sourceMetadataIdentityHashes?: readonly string[];
hasStartedRecordingSourceMetadataIdentityHashes: boolean;
@@ -333,6 +344,36 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
true,
);
public readonly iconPath: IconPath = new ThemeIcon('file-code');
+ readonly [pythonToolSupport]: EnvironmentToolSupport = {
+ initialize: async () => {},
+ canConfigure: async (scope) => {
+ const script = this.getScriptUri(scope);
+ if (!script) {
+ return false;
+ }
+ try {
+ return !!(await readInlineScriptMetadataFromFile(script, true));
+ } catch (error) {
+ throw new PythonToolError('INVALID_PROJECT', getErrorMessage(error));
+ }
+ },
+ get: (scope) => this.get(scope),
+ getEnvironments: (scope) => this.getEnvironments(scope),
+ resolve: (scope) => this.resolve(scope),
+ create: async (scope, operation) => {
+ const environment = await this.create(scope, { quickCreate: true }, operation);
+ if (!environment) {
+ throw new PythonToolError(
+ 'CREATION_FAILED',
+ l10n.t(
+ 'Inline-script setup could not produce a usable environment. Check its PEP 723 metadata and the Python Environments output.',
+ ),
+ );
+ }
+ return environment;
+ },
+ set: (scope, environment, token) => this.set(scope, environment, token),
+ };
constructor(
private readonly nativeFinder: NativePythonFinder,
@@ -374,11 +415,14 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
async create(
scope: CreateEnvironmentScope,
options?: CreateEnvironmentOptions,
+ operation?: PythonToolOperation,
): Promise {
+ throwIfCancelled(operation?.token);
this.activeCreateOperations += 1;
try {
return await this.waitForCacheMaintenance(async () => {
try {
+ throwIfCancelled(operation?.token);
const scriptUri = this.getScriptUri(scope);
if (!scriptUri) {
this.log.warn('Inline-script environment creation requires exactly one local file URI.');
@@ -388,6 +432,12 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
const metadata = await readInlineScriptMetadataFromFile(scriptUri);
if (!metadata) {
+ if (operation) {
+ throw new PythonToolError(
+ 'INVALID_PROJECT',
+ l10n.t('No valid PEP 723 metadata was found in {0}.', scriptUri.fsPath),
+ );
+ }
this.log.warn(`No valid PEP 723 metadata found in ${scriptUri.fsPath}.`);
return undefined;
}
@@ -402,13 +452,15 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
return undefined;
}
- const setupKey = this.getPendingSetupKey(scriptUri, metadata, packages, options);
+ const setupKey = `${operation ? 'tool:' : ''}${this.getPendingSetupKey(scriptUri, metadata, packages, options)}`;
const pending = this.pendingSetups.get(setupKey);
if (pending) {
- return await pending;
+ return operation
+ ? await waitForToolRead(pending, operation.token, CACHE_LOCK_TIMEOUT_MS)
+ : await pending;
}
- const setup = this.createForScript(scriptUri, metadata, packages, options);
+ const setup = this.createForScript(scriptUri, metadata, packages, options, operation);
this.pendingSetups.set(setupKey, setup);
try {
return await setup;
@@ -420,6 +472,9 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
} catch (error) {
this.sendInlineScriptEnvErrorTelemetry('setup-failure');
this.log.error(`Failed to set up inline-script environment: ${getErrorMessage(error)}`);
+ if (operation) {
+ throw error;
+ }
return undefined;
}
});
@@ -433,8 +488,14 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
metadata: InlineScriptMetadata,
packages: readonly string[],
options?: CreateEnvironmentOptions,
+ operation?: PythonToolOperation,
): Promise {
- const baseSelection = await this.selectOrInstallBaseInterpreter(metadata, options?.quickCreate === true);
+ throwIfCancelled(operation?.token);
+ const baseSelection = await this.selectOrInstallBaseInterpreter(
+ metadata,
+ options?.quickCreate === true,
+ operation,
+ );
if (!baseSelection.selectedBase) {
if (baseSelection.errorCategory) {
this.sendInlineScriptEnvErrorTelemetry(baseSelection.errorCategory);
@@ -447,6 +508,16 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
this.log.warn(
`No compatible Python is available for inline-script environment creation: ${scriptUri.fsPath}.`,
);
+ if (operation) {
+ throw new PythonToolError(
+ 'MISSING_PYTHON',
+ l10n.t(
+ 'Install a Python interpreter compatible with requires-python {0} before setting up {1}.',
+ metadata.requiresPython ?? '3',
+ scriptUri.fsPath,
+ ),
+ );
+ }
return undefined;
}
const selectedBase = baseSelection.selectedBase;
@@ -459,10 +530,18 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
const sourceMetadataIdentityHash = metadataIdentity ? hashSourceMetadataIdentity(metadataIdentity) : undefined;
const pending = this.pendingCreations.get(cacheKey);
if (pending) {
+ if (operation && !pending.operation) {
+ throw new PythonToolError(
+ 'ENVIRONMENT_BUSY',
+ l10n.t('A human-initiated setup is using this inline-script environment. Retry when it finishes.'),
+ );
+ }
const joinedAfterPendingCreationStartedRecordingSourceMetadataIdentityHashes =
pending.hasStartedRecordingSourceMetadataIdentityHashes;
this.addPendingCreationSourceMetadataIdentityHash(pending, sourceMetadataIdentityHash);
- const environment = await pending.promise;
+ const environment = operation
+ ? await waitForToolRead(pending.promise, operation.token, CACHE_LOCK_TIMEOUT_MS)
+ : await pending.promise;
if (!environment) {
this.notePendingCreationFailure(scriptUri, metadata, pending);
}
@@ -475,6 +554,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
);
}
const pendingCreation: PendingCreationContext = {
+ operation,
promise: Promise.resolve(undefined),
sourceMetadataIdentityHashes: mergeSourceMetadataIdentityHashes(undefined, sourceMetadataIdentityHash),
hasStartedRecordingSourceMetadataIdentityHashes: false,
@@ -551,8 +631,9 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
private async selectOrInstallBaseInterpreter(
metadata: InlineScriptMetadata,
quickCreate: boolean,
+ operation?: PythonToolOperation,
): Promise {
- const selection = await this.selectBaseInterpreter(metadata);
+ const selection = await this.selectBaseInterpreter(metadata, operation);
if (selection.selectedBase) {
return { selectedBase: selection.selectedBase };
}
@@ -618,8 +699,11 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
return [];
}
- async set(scope: SetEnvironmentScope, environment?: PythonEnvironment): Promise {
- return this.waitForCacheMaintenance(() => this.enqueueSelection(() => this.setInternal(scope, environment)));
+ async set(scope: SetEnvironmentScope, environment?: PythonEnvironment, token?: CancellationToken): Promise {
+ throwIfCancelled(token);
+ return this.waitForCacheMaintenance(() =>
+ this.enqueueSelection(() => this.setInternal(scope, environment, token)),
+ );
}
async get(scope: GetEnvironmentScope): Promise {
@@ -1074,7 +1158,12 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
return uri?.scheme === 'file' ? uri : undefined;
}
- private async setInternal(scope: SetEnvironmentScope, environment?: PythonEnvironment): Promise {
+ private async setInternal(
+ scope: SetEnvironmentScope,
+ environment?: PythonEnvironment,
+ token?: CancellationToken,
+ ): Promise {
+ throwIfCancelled(token);
const scripts = this.getScriptUris(scope);
if (scripts.length === 0) {
return;
@@ -1139,6 +1228,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
}
try {
+ throwIfCancelled(token);
const persistenceUpdates = updates.filter((update) => update.needsPersistence);
if (persistenceUpdates.length > 0) {
await this.updatePersistedAssociations(
@@ -3242,12 +3332,20 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
);
}
- private async selectBaseInterpreter(metadata: InlineScriptMetadata): Promise {
+ private async selectBaseInterpreter(
+ metadata: InlineScriptMetadata,
+ operation?: PythonToolOperation,
+ ): Promise {
let globalEnvironments: readonly PythonEnvironment[] = [];
let discoveryFailed = false;
try {
- globalEnvironments = await this.api.getEnvironments('global');
+ globalEnvironments = operation
+ ? await operation.getGlobalEnvironments()
+ : await this.api.getEnvironments('global');
} catch (error) {
+ if (operation) {
+ throw error;
+ }
discoveryFailed = true;
this.log.warn(`Unable to query discovered base interpreters: ${getErrorMessage(error)}`);
}
@@ -3530,10 +3628,17 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
envDir: Uri,
action: (lock: AcquiredFileLock) => Promise,
timeoutMs = CACHE_LOCK_TIMEOUT_MS,
+ token?: CancellationToken,
): Promise {
- const lock = await this.acquireCacheEntryLock(envDir.fsPath, timeoutMs);
+ const lock = await this.acquireCacheEntryLock(envDir.fsPath, timeoutMs, token);
try {
+ throwIfCancelled(token);
return await action(lock);
+ } catch (error) {
+ if (error instanceof PythonToolError && error.code === 'PROCESS_TERMINATION_FAILED') {
+ await lock.retain();
+ }
+ throw error;
} finally {
try {
await lock.release();
@@ -3547,7 +3652,12 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
* Reclaims a lock whose owning process is gone. Doing this *before* waiting is the point:
* otherwise every later setup for the same cache key blocks for the whole timeout, then fails.
*/
- private async acquireCacheEntryLock(envDirPath: string, timeoutMs: number): Promise {
+ private async acquireCacheEntryLock(
+ envDirPath: string,
+ timeoutMs: number,
+ token?: CancellationToken,
+ ): Promise {
+ throwIfCancelled(token);
try {
if ((await inspectFileLock(envDirPath)) === 'stale' && (await reclaimFileLock(envDirPath))) {
this.log.info(
@@ -3564,7 +3674,11 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
code: 'ELOCKED',
});
}
- return acquireFileLock(envDirPath, { timeoutMs, retryIntervalMs: CACHE_LOCK_RETRY_MS });
+ return acquireFileLock(envDirPath, {
+ timeoutMs,
+ retryIntervalMs: CACHE_LOCK_RETRY_MS,
+ ...(token ? { checkCancellation: () => throwIfCancelled(token) } : {}),
+ });
}
private mergePendingCreationSourceMetadataIdentityHashes(
@@ -3627,58 +3741,77 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
const envDir = getScriptEnvDir(this.globalStorageUri, cacheKey);
try {
+ throwIfCancelled(pendingCreation.operation?.token);
await fs.ensureDir(cacheRoot.fsPath);
- return await this.withCacheEntryLock(envDir, async () => {
- const cached = await this.inspectCacheEntry(cacheRoot, envDir, metadata, selectedBase, pendingCreation);
- if (cached.kind === 'reusable') {
- this.sendInlineScriptEnvReuseHitTelemetry(dependencyCount);
- return cached.environment;
- }
- if (cached.kind === 'uncertain') {
- this.log.warn(
- `Preserving an inline-script cache entry that could not be safely inspected: ${envDir.fsPath}`,
+ return await this.withCacheEntryLock(
+ envDir,
+ async () => {
+ const cached = await this.inspectCacheEntry(
+ cacheRoot,
+ envDir,
+ metadata,
+ selectedBase,
+ pendingCreation,
);
- pendingCreation.failure = { category: 'setup-failure' };
- this.sendInlineScriptEnvErrorTelemetry('setup-failure');
- return undefined;
- }
- if (cached.kind === 'stale') {
- if (!(await this.discardCacheEntry(envDir))) {
- // The sidecar is gone, so the entry is inert; a later attempt retries the
- // deletion once the files are released.
+ throwIfCancelled(pendingCreation.operation?.token);
+ if (cached.kind === 'reusable') {
+ this.sendInlineScriptEnvReuseHitTelemetry(dependencyCount);
+ return cached.environment;
+ }
+ if (cached.kind === 'uncertain') {
+ this.log.warn(
+ `Preserving an inline-script cache entry that could not be safely inspected: ${envDir.fsPath}`,
+ );
pendingCreation.failure = { category: 'setup-failure' };
this.sendInlineScriptEnvErrorTelemetry('setup-failure');
return undefined;
}
- }
+ if (cached.kind === 'stale') {
+ if (!(await this.discardCacheEntry(envDir))) {
+ // The sidecar is gone, so the entry is inert; a later attempt retries the
+ // deletion once the files are released.
+ pendingCreation.failure = { category: 'setup-failure' };
+ this.sendInlineScriptEnvErrorTelemetry('setup-failure');
+ return undefined;
+ }
+ }
- const buildStartAtMs = Date.now();
- const build = await this.buildCacheEntry(
- envDir,
- cacheRoot,
- packages,
- selectedBase,
- pendingCreation,
- scriptUri,
- );
- if (build.environment) {
- this.sendInlineScriptEnvCreatedTelemetry(buildStartAtMs, dependencyCount);
- return build.environment;
- }
- if (build.errorCategory) {
- pendingCreation.failure = {
- category: build.errorCategory,
- ...(build.errorCategory === 'package-install-cancelled' ? { cancelled: true } : {}),
- };
- this.sendInlineScriptEnvErrorTelemetry(build.errorCategory);
- }
- return undefined;
- });
+ const buildStartAtMs = Date.now();
+ const build = await this.buildCacheEntry(
+ envDir,
+ cacheRoot,
+ packages,
+ selectedBase,
+ pendingCreation,
+ scriptUri,
+ );
+ if (build.environment) {
+ this.sendInlineScriptEnvCreatedTelemetry(buildStartAtMs, dependencyCount);
+ return build.environment;
+ }
+ if (build.errorCategory) {
+ pendingCreation.failure = {
+ category: build.errorCategory,
+ ...(build.errorCategory === 'package-install-cancelled' ? { cancelled: true } : {}),
+ };
+ this.sendInlineScriptEnvErrorTelemetry(build.errorCategory);
+ }
+ return undefined;
+ },
+ CACHE_LOCK_TIMEOUT_MS,
+ pendingCreation.operation?.token,
+ );
} catch (error) {
+ if (pendingCreation.operation && (error instanceof CancellationError || error instanceof PythonToolError)) {
+ throw error;
+ }
const category = this.getCreateOrReuseErrorCategory(error);
pendingCreation.failure = { category };
this.sendInlineScriptEnvErrorTelemetry(category);
this.log.error(`Failed to create or reuse inline-script cache entry: ${getErrorMessage(error)}`);
+ if (pendingCreation.operation) {
+ throw new PythonToolError('CREATION_FAILED', getErrorMessage(error));
+ }
return undefined;
}
}
@@ -3780,6 +3913,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
}
const stampedAt = Date.now();
try {
+ throwIfCancelled(pendingCreation.operation?.token);
pendingCreation.hasStartedRecordingSourceMetadataIdentityHashes = true;
const sourceMetadataIdentityHashes = this.mergePendingCreationSourceMetadataIdentityHashes(
sidecar.sourceMetadataIdentityHashes,
@@ -3848,11 +3982,27 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
{
progressTitle: l10n.t('Setting up environment for {0}', path.basename(scriptUri.fsPath)),
nameStyle: 'inlineScript',
+ operation: pendingCreation.operation,
},
);
} catch (error) {
this.log.error(`Failed to build inline-script environment: ${getErrorMessage(error)}`);
+ if (error instanceof PythonToolError && error.code === 'PROCESS_TERMINATION_FAILED') {
+ throw error;
+ }
await this.removeCacheEntry(envDir);
+ if (pendingCreation.operation) {
+ if (error instanceof CancellationError) {
+ throw error;
+ }
+ throw new PythonToolError(
+ error instanceof PythonToolError ? error.code : 'CREATION_FAILED',
+ l10n.t(
+ 'Inline-script setup failed and the incomplete cache entry was discarded: {0}',
+ getErrorMessage(error),
+ ),
+ );
+ }
return { errorCategory: 'install-failure' };
}
@@ -3879,6 +4029,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
return { errorCategory: 'setup-failure' };
}
try {
+ throwIfCancelled(pendingCreation.operation?.token);
pendingCreation.hasStartedRecordingSourceMetadataIdentityHashes = true;
const sourceMetadataIdentityHashes = this.mergePendingCreationSourceMetadataIdentityHashes(
undefined,
diff --git a/src/managers/builtin/pipPackageManager.ts b/src/managers/builtin/pipPackageManager.ts
index 291d452e4..67b98415b 100644
--- a/src/managers/builtin/pipPackageManager.ts
+++ b/src/managers/builtin/pipPackageManager.ts
@@ -11,6 +11,7 @@ import {
MarkdownString,
ProgressLocation,
ThemeIcon,
+ Uri,
} from 'vscode';
import {
DidChangePackagesEventArgs,
@@ -47,6 +48,7 @@ import {
} from './commands/index';
import { getWorkspacePackagesToInstall } from './pipUtils';
import { VenvManager } from './venvManager';
+import { PackageToolSupport, pythonToolSupport, throwIfCancelled } from '../../internal/pythonToolSupport';
export class PipPackageManager implements PackageManager, Disposable {
private readonly _onDidChangePackages = new EventEmitter();
@@ -71,7 +73,25 @@ export class PipPackageManager implements PackageManager, Disposable {
readonly tooltip?: string | MarkdownString;
readonly iconPath?: IconPath;
- async manage(environment: PythonEnvironment, options: PackageManagementOptions): Promise {
+ readonly [pythonToolSupport]: PackageToolSupport = {
+ manage: (environment, options, token, scope) =>
+ this.manage(environment, { ...options, runHeadless: true }, token, scope),
+ getPackages: async (environment, token) => {
+ const packages = await this.fetchPackages(environment, false, token);
+ if (!packages) {
+ throw new Error('Package listing returned no result.');
+ }
+ return packages;
+ },
+ };
+
+ async manage(
+ environment: PythonEnvironment,
+ options: PackageManagementOptions,
+ toolToken?: CancellationToken,
+ scope?: Uri,
+ ): Promise {
+ throwIfCancelled(toolToken);
let toInstall: string[] = [...(options.install ?? [])];
let toUninstall: string[] = [...(options.uninstall ?? [])];
@@ -102,6 +122,9 @@ export class PipPackageManager implements PackageManager, Disposable {
const manageCommandOptions: CommandConstructorOptions = {
pythonExecutable,
log: this.log,
+ cwd: scope?.fsPath,
+ cancellationToken: token,
+ toolExecution: !!toolToken,
};
if (toUninstall.length > 0) {
@@ -114,6 +137,7 @@ export class PipPackageManager implements PackageManager, Disposable {
await command.execute({
packages: parsePackageSpecs(toUninstall),
cancellationToken: token,
+ toolExecution: !!toolToken,
});
}
@@ -128,6 +152,7 @@ export class PipPackageManager implements PackageManager, Disposable {
packages: parsePackageSpecs(toInstall),
upgrade: options.upgrade,
cancellationToken: token,
+ toolExecution: !!toolToken,
});
}
@@ -138,7 +163,8 @@ export class PipPackageManager implements PackageManager, Disposable {
(changes) => {
this._onDidChangePackages.fire({ environment, manager: this, changes });
},
- () => this.fetchPackages(environment, !options.runHeadless),
+ () => this.fetchPackages(environment, !options.runHeadless, toolToken),
+ !!toolToken,
);
} catch (e) {
if (e instanceof CancellationError) {
@@ -159,7 +185,7 @@ export class PipPackageManager implements PackageManager, Disposable {
};
if (options.runHeadless) {
- await execute();
+ await execute(toolToken);
return;
}
@@ -206,8 +232,13 @@ export class PipPackageManager implements PackageManager, Disposable {
return this.packages.get(environment.envId.id);
}
- private async fetchPackages(environment: PythonEnvironment, showErrors = true): Promise {
+ private async fetchPackages(
+ environment: PythonEnvironment,
+ showErrors = true,
+ token?: CancellationToken,
+ ): Promise {
try {
+ throwIfCancelled(token);
const pythonExecutable = environment.execInfo?.run?.executable;
if (!pythonExecutable) {
throw new Error('Unable to determine Python executable path');
@@ -216,17 +247,25 @@ export class PipPackageManager implements PackageManager, Disposable {
{
pythonExecutable,
log: this.log,
+ cancellationToken: token,
+ toolExecution: !!token,
},
environment.environmentPath.fsPath,
PipListCommand,
UvListCommand,
);
- const data = await listCmd.execute();
+ const data = await listCmd.execute(
+ token ? { cancellationToken: token, strict: true, toolExecution: true } : undefined,
+ );
+ throwIfCancelled(token);
const packages = data.map((pkg) => this.api.createPackageItem(pkg, environment, this));
this.packages.set(environment.envId.id, packages);
return packages;
} catch (error) {
this.log.error('Error refreshing packages', error);
+ if (token) {
+ throw error;
+ }
if (showErrors) {
setImmediate(async () => {
await showErrorMessageWithLogs(l10n.t('Error refreshing packages'), this.log);
diff --git a/src/managers/builtin/pipUtils.ts b/src/managers/builtin/pipUtils.ts
index a99a6a2ad..f6bdd8a7d 100644
--- a/src/managers/builtin/pipUtils.ts
+++ b/src/managers/builtin/pipUtils.ts
@@ -2,14 +2,24 @@ import * as tomljs from '@iarna/toml';
import { valid as pep440Valid } from '@renovatebot/pep440';
import * as fse from 'fs-extra';
import * as path from 'path';
-import { l10n, LogOutputChannel, ProgressLocation, QuickInputButtons, QuickPickItem, Uri, window } from 'vscode';
+import {
+ CancellationToken,
+ l10n,
+ LogOutputChannel,
+ ProgressLocation,
+ QuickInputButtons,
+ QuickPickItem,
+ Uri,
+ window,
+} from 'vscode';
import { PackageManagementOptions, PythonEnvironment, PythonEnvironmentApi, PythonProject } from '../../api';
import { EXTENSION_ROOT_DIR } from '../../common/constants';
import { PackageManagement, Pickers, VenvManagerStrings } from '../../common/localize';
import { traceInfo } from '../../common/logging';
-import { normalizePath } from '../../common/utils/pathUtils';
+import { isSameOrParentPath, normalizePath } from '../../common/utils/pathUtils';
import { showQuickPickWithButtons, withProgress } from '../../common/window.apis';
import { findFiles } from '../../common/workspace.apis';
+import { throwIfCancelled } from '../../internal/pythonToolSupport';
import { selectFromCommonPackagesToInstall, selectFromInstallableToInstall } from '../common/pickers';
import { normalizePackageName } from '../common/packageUtils';
import { Installable } from '../common/types';
@@ -69,11 +79,16 @@ export function validatePyprojectToml(toml: PyprojectToml): string | undefined {
return undefined;
}
-async function tomlParse(fsPath: string, log?: LogOutputChannel): Promise {
+async function tomlParse(fsPath: string, log?: LogOutputChannel, strict = false): Promise {
try {
const content = await fse.readFile(fsPath, 'utf-8');
return tomljs.parse(content);
} catch (err) {
+ if (strict) {
+ throw new Error(
+ l10n.t('Could not read or parse {0}: {1}', fsPath, err instanceof Error ? err.message : String(err)),
+ );
+ }
log?.error('Failed to parse `pyproject.toml`:', err);
}
return {};
@@ -263,6 +278,8 @@ export interface ProjectInstallableOptions {
* Creation root used to prefer the pyproject.toml that owns the environment.
*/
preferredRoot?: Uri;
+ token?: CancellationToken;
+ runHeadless?: boolean;
}
export async function getWorkspacePackagesToInstall(
@@ -309,168 +326,179 @@ export async function getProjectInstallable(
const installable: Installable[] = [];
let validationError: { message: string; fileUri: Uri } | undefined;
- await withProgress(
- {
- location: ProgressLocation.Notification,
- title: VenvManagerStrings.searchingDependencies,
- },
- async (_progress, token) => {
- const results: Uri[] = (
- await Promise.all([
- findFiles('**/*requirements*.txt', exclude, undefined, token),
- findFiles('*requirements*.txt', exclude, undefined, token),
- findFiles('**/requirements/*.txt', exclude, undefined, token),
- findFiles('**/pyproject.toml', exclude, undefined, token),
- ])
- ).flat();
-
- // Deduplicate by fsPath
- const uniqueResults = Array.from(new Map(results.map((uri) => [uri.fsPath, uri])).values());
-
- const fsPaths = projects.map((p) => p.uri.fsPath);
- // Compute depth relative to the owning project root so ordering reflects
- // "shallower within the project", independent of where the project lives on disk.
- const depthFromProject = (uri: Uri): number => {
- const projectRoot = api.getPythonProject(uri)?.uri.fsPath;
- if (!projectRoot) {
- return Number.MAX_SAFE_INTEGER;
+ const search = async (token?: CancellationToken) => {
+ throwIfCancelled(options?.token);
+ const results: Uri[] = (
+ await Promise.all([
+ findFiles('**/*requirements*.txt', exclude, undefined, token),
+ findFiles('*requirements*.txt', exclude, undefined, token),
+ findFiles('**/requirements/*.txt', exclude, undefined, token),
+ findFiles('**/pyproject.toml', exclude, undefined, token),
+ ])
+ ).flat();
+
+ // Deduplicate by fsPath
+ const uniqueResults = Array.from(new Map(results.map((uri) => [uri.fsPath, uri])).values());
+
+ const fsPaths = projects.map((p) => p.uri.fsPath);
+ // Compute depth relative to the owning project root so ordering reflects
+ // "shallower within the project", independent of where the project lives on disk.
+ const depthFromProject = (uri: Uri): number => {
+ const projectRoot = api.getPythonProject(uri)?.uri.fsPath;
+ if (!projectRoot) {
+ return Number.MAX_SAFE_INTEGER;
+ }
+ const rel = path.relative(projectRoot, uri.fsPath);
+ if (!rel) {
+ return 0;
+ }
+ return rel.split(path.sep).filter((segment) => segment.length > 0).length;
+ };
+ const filtered = uniqueResults
+ .filter((uri) => {
+ if (
+ options?.runHeadless &&
+ options.preferredRoot &&
+ !isSameOrParentPath(options.preferredRoot.fsPath, uri.fsPath)
+ ) {
+ return false;
}
- const rel = path.relative(projectRoot, uri.fsPath);
- if (!rel) {
- return 0;
+ const p = api.getPythonProject(uri)?.uri.fsPath;
+ return p && fsPaths.includes(p);
+ })
+ .sort((a, b) => {
+ // Sort by path depth relative to the project root (shallowest first) so
+ // top-level files like requirements.txt appear before deeply nested ones.
+ const depthA = depthFromProject(a);
+ const depthB = depthFromProject(b);
+ if (depthA !== depthB) {
+ return depthA - depthB;
}
- return rel.split(path.sep).filter((segment) => segment.length > 0).length;
- };
- const filtered = uniqueResults
- .filter((uri) => {
- const p = api.getPythonProject(uri)?.uri.fsPath;
- return p && fsPaths.includes(p);
- })
- .sort((a, b) => {
- // Sort by path depth relative to the project root (shallowest first) so
- // top-level files like requirements.txt appear before deeply nested ones.
- const depthA = depthFromProject(a);
- const depthB = depthFromProject(b);
- if (depthA !== depthB) {
- return depthA - depthB;
- }
- const pathA = normalizePath(a.fsPath);
- const pathB = normalizePath(b.fsPath);
- return pathA < pathB ? -1 : pathA > pathB ? 1 : 0;
- });
+ const pathA = normalizePath(a.fsPath);
+ const pathB = normalizePath(b.fsPath);
+ return pathA < pathB ? -1 : pathA > pathB ? 1 : 0;
+ });
- // Parse all TOML files first (in parallel), keyed by fsPath, so the
- // subsequent duplicate-detection pass can run sequentially and
- // deterministically over the depth-sorted `filtered` list.
- const parsedTomls = new Map();
- await Promise.all(
- filtered.map(async (uri) => {
- if (uri.fsPath.endsWith('.toml')) {
- const toml = await tomlParse(uri.fsPath);
- parsedTomls.set(uri.fsPath, toml);
- }
- }),
- );
+ // Parse all TOML files first (in parallel), keyed by fsPath, so the
+ // subsequent duplicate-detection pass can run sequentially and
+ // deterministically over the depth-sorted `filtered` list.
+ const parsedTomls = new Map();
+ await Promise.all(
+ filtered.map(async (uri) => {
+ if (uri.fsPath.endsWith('.toml')) {
+ const toml = await tomlParse(uri.fsPath, undefined, options?.runHeadless);
+ parsedTomls.set(uri.fsPath, toml);
+ }
+ }),
+ );
- const selectedTomls = new Map();
- const ambiguousProjectNames = new Set();
- if (options?.deduplicateProjectPackages) {
- const tomlsByProjectName = new Map();
- for (const uri of filtered) {
- if (!uri.fsPath.endsWith('.toml')) {
- continue;
- }
- const toml = parsedTomls.get(uri.fsPath) ?? {};
- const projectName = getTomlProjectName(toml);
- if (!projectName || getTomlInstallable(toml, uri).length === 0) {
- continue;
- }
- const normalizedName = normalizePackageName(projectName);
- const candidates = tomlsByProjectName.get(normalizedName) ?? [];
- candidates.push(uri);
- tomlsByProjectName.set(normalizedName, candidates);
+ const selectedTomls = new Map();
+ const ambiguousProjectNames = new Set();
+ if (options?.deduplicateProjectPackages) {
+ const tomlsByProjectName = new Map();
+ for (const uri of filtered) {
+ if (!uri.fsPath.endsWith('.toml')) {
+ continue;
+ }
+ const toml = parsedTomls.get(uri.fsPath) ?? {};
+ const projectName = getTomlProjectName(toml);
+ if (!projectName || getTomlInstallable(toml, uri).length === 0) {
+ continue;
}
+ const normalizedName = normalizePackageName(projectName);
+ const candidates = tomlsByProjectName.get(normalizedName) ?? [];
+ candidates.push(uri);
+ tomlsByProjectName.set(normalizedName, candidates);
+ }
- tomlsByProjectName.forEach((candidates, projectName) => {
- if (candidates.length === 1) {
- selectedTomls.set(projectName, candidates[0]);
- return;
- }
+ tomlsByProjectName.forEach((candidates, projectName) => {
+ if (candidates.length === 1) {
+ selectedTomls.set(projectName, candidates[0]);
+ return;
+ }
- const preferredRoot = options.preferredRoot?.fsPath;
- const preferredCandidates = preferredRoot
- ? candidates
- .filter((candidate) => {
- const projectDir = path.dirname(candidate.fsPath);
- const relative = path.relative(projectDir, preferredRoot);
- return (
- relative === '' ||
- (relative !== '..' &&
- !relative.startsWith(`..${path.sep}`) &&
- !path.isAbsolute(relative))
- );
- })
- .sort((a, b) => path.dirname(b.fsPath).length - path.dirname(a.fsPath).length)
- : [];
-
- if (preferredCandidates.length > 0) {
- selectedTomls.set(projectName, preferredCandidates[0]);
- } else {
- ambiguousProjectNames.add(projectName);
+ const preferredRoot = options.preferredRoot?.fsPath;
+ const preferredCandidates = preferredRoot
+ ? candidates
+ .filter((candidate) => {
+ const projectDir = path.dirname(candidate.fsPath);
+ const relative = path.relative(projectDir, preferredRoot);
+ return (
+ relative === '' ||
+ (relative !== '..' &&
+ !relative.startsWith(`..${path.sep}`) &&
+ !path.isAbsolute(relative))
+ );
+ })
+ .sort((a, b) => path.dirname(b.fsPath).length - path.dirname(a.fsPath).length)
+ : [];
+
+ if (preferredCandidates.length > 0) {
+ selectedTomls.set(projectName, preferredCandidates[0]);
+ } else {
+ ambiguousProjectNames.add(projectName);
+ traceInfo(
+ `Skipping ambiguous editable installs for package "${projectName}" because no ` +
+ 'candidate contains the environment creation root.',
+ );
+ }
+ });
+ }
+
+ for (const uri of filtered) {
+ if (uri.fsPath.endsWith('.toml')) {
+ const toml = parsedTomls.get(uri.fsPath) ?? {};
+ const tomlInstallables = getTomlInstallable(toml, uri);
+
+ const projectName = getTomlProjectName(toml);
+ if (options?.deduplicateProjectPackages && projectName && tomlInstallables.length > 0) {
+ const normalizedName = normalizePackageName(projectName);
+ const selected = selectedTomls.get(normalizedName);
+ if (ambiguousProjectNames.has(normalizedName)) {
+ continue;
+ }
+ if (selected && selected.fsPath !== uri.fsPath) {
traceInfo(
- `Skipping ambiguous editable installs for package "${projectName}" because no ` +
- 'candidate contains the environment creation root.',
+ `Skipping duplicate project package "${projectName}" from ${uri.fsPath}; it is ` +
+ `already provided by ${selected.fsPath}.`,
);
+ continue;
}
- });
- }
-
- for (const uri of filtered) {
- if (uri.fsPath.endsWith('.toml')) {
- const toml = parsedTomls.get(uri.fsPath) ?? {};
- const tomlInstallables = getTomlInstallable(toml, uri);
-
- const projectName = getTomlProjectName(toml);
- if (options?.deduplicateProjectPackages && projectName && tomlInstallables.length > 0) {
- const normalizedName = normalizePackageName(projectName);
- const selected = selectedTomls.get(normalizedName);
- if (ambiguousProjectNames.has(normalizedName)) {
- continue;
- }
- if (selected && selected.fsPath !== uri.fsPath) {
- traceInfo(
- `Skipping duplicate project package "${projectName}" from ${uri.fsPath}; it is ` +
- `already provided by ${selected.fsPath}.`,
- );
- continue;
- }
- }
+ }
- // Validate pyproject.toml (report only the first error found).
- if (!validationError) {
- const error = validatePyprojectToml(toml);
- if (error) {
- validationError = {
- message: error,
- fileUri: uri,
- };
- }
+ // Validate pyproject.toml (report only the first error found).
+ if (!validationError) {
+ const error = validatePyprojectToml(toml);
+ if (error) {
+ validationError = {
+ message: error,
+ fileUri: uri,
+ };
}
-
- installable.push(...tomlInstallables);
- } else {
- const name = path.basename(uri.fsPath);
- installable.push({
- name,
- uri,
- displayName: name,
- group: 'Requirements',
- args: ['-r', uri.fsPath],
- });
}
+
+ installable.push(...tomlInstallables);
+ } else {
+ const name = path.basename(uri.fsPath);
+ installable.push({
+ name,
+ uri,
+ displayName: name,
+ group: 'Requirements',
+ args: ['-r', uri.fsPath],
+ });
}
- },
- );
+ }
+ throwIfCancelled(options?.token);
+ };
+ if (options?.runHeadless) {
+ await search(options.token);
+ } else {
+ await withProgress(
+ { location: ProgressLocation.Notification, title: VenvManagerStrings.searchingDependencies },
+ (_progress, token) => search(token),
+ );
+ }
return {
installables: installable,
diff --git a/src/managers/builtin/sysPythonManager.ts b/src/managers/builtin/sysPythonManager.ts
index 2b031ba3b..6d0b7cb72 100644
--- a/src/managers/builtin/sysPythonManager.ts
+++ b/src/managers/builtin/sysPythonManager.ts
@@ -1,5 +1,5 @@
import * as path from 'path';
-import { EventEmitter, LogOutputChannel, MarkdownString, ProgressLocation, ThemeIcon, Uri, window } from 'vscode';
+import { EventEmitter, LogOutputChannel, MarkdownString, ProgressLocation, ThemeIcon, Uri } from 'vscode';
import {
CreateEnvironmentOptions,
CreateEnvironmentScope,
@@ -20,6 +20,8 @@ import {
import { SysManagerStrings } from '../../common/localize';
import { createDeferred, Deferred } from '../../common/utils/deferred';
import { normalizePath } from '../../common/utils/pathUtils';
+import { EnvironmentToolSupport, pythonToolSupport } from '../../internal/pythonToolSupport';
+import { withProgress } from '../../common/window.apis';
import { getProjectFsPathForScope, tryFastPathGet } from '../common/fastPath';
import { NativePythonFinder } from '../common/nativePythonFinder';
import { getLatest } from '../common/utils';
@@ -66,17 +68,27 @@ export class SysPythonManager implements EnvironmentManager {
}
private _initialized: Deferred | undefined;
+ private discovery: Deferred | undefined;
+ private initializationError: unknown;
+ readonly [pythonToolSupport]: EnvironmentToolSupport = {
+ initialize: async () => {
+ await this.initializeDiscovery();
+ if (this.initializationError) {
+ throw this.initializationError;
+ }
+ },
+ get: (scope) => this.get(scope, true),
+ getEnvironments: (scope) => this.getEnvironments(scope, true),
+ resolve: (scope) => this.resolve(scope),
+ };
+
async initialize(): Promise {
if (this._initialized) {
return this._initialized.promise;
}
-
this._initialized = createDeferred();
-
try {
- await this.internalRefresh(false, SysManagerStrings.sysManagerDiscovering);
-
- // If no Python environments were found, offer to install via uv
+ await this.initializeDiscovery();
if (this.collection.length === 0) {
const pythonPath = await promptInstallPythonViaUv('activation', this.log);
if (pythonPath) {
@@ -101,12 +113,28 @@ export class SysPythonManager implements EnvironmentManager {
}
}
+ private async initializeDiscovery(): Promise {
+ if (this.discovery) {
+ return this.discovery.promise;
+ }
+ const discovery = (this.discovery = createDeferred());
+ try {
+ await this.internalRefresh(false, SysManagerStrings.sysManagerDiscovering);
+ } catch (error) {
+ this.initializationError = error;
+ this.discovery = undefined;
+ throw error;
+ } finally {
+ discovery.resolve();
+ }
+ }
+
refresh(_scope: RefreshEnvironmentsScope): Promise {
return this.internalRefresh(true, SysManagerStrings.sysManagerRefreshing);
}
private async internalRefresh(hardRefresh: boolean, title: string) {
- await window.withProgress(
+ await withProgress(
{
location: ProgressLocation.Window,
title,
@@ -125,12 +153,13 @@ export class SysPythonManager implements EnvironmentManager {
];
this._onDidChangeEnvironments.fire(args);
+ this.initializationError = undefined;
},
);
}
- async getEnvironments(scope: GetEnvironmentsScope): Promise {
- await this.initialize();
+ async getEnvironments(scope: GetEnvironmentsScope, toolExecution = false): Promise {
+ await (toolExecution ? this.initializeDiscovery() : this.initialize());
if (scope === 'all' || scope === 'global') {
return Array.from(this.collection);
@@ -146,8 +175,8 @@ export class SysPythonManager implements EnvironmentManager {
return [];
}
- async get(scope: GetEnvironmentScope): Promise {
- const fastResult = await tryFastPathGet({
+ async get(scope: GetEnvironmentScope, toolExecution = false): Promise {
+ const fastResult = toolExecution ? undefined : await tryFastPathGet({
initialized: this._initialized,
setInitialized: (deferred) => {
this._initialized = deferred;
@@ -158,13 +187,13 @@ export class SysPythonManager implements EnvironmentManager {
getPersistedPath: (fsPath) => getSystemEnvForWorkspace(fsPath),
getGlobalPersistedPath: () => getSystemEnvForGlobal(),
resolve: (p) => resolveSystemPythonEnvironmentPath(p, this.nativeFinder, this.api, this),
- startBackgroundInit: () => this.internalRefresh(false, SysManagerStrings.sysManagerDiscovering),
+ startBackgroundInit: () => this.initializeDiscovery(),
});
if (fastResult) {
return fastResult.env;
}
- await this.initialize();
+ await (toolExecution ? this.initializeDiscovery() : this.initialize());
if (scope instanceof Uri) {
return this.fromEnvMap(scope) ?? this.globalEnv;
diff --git a/src/managers/builtin/venvManager.ts b/src/managers/builtin/venvManager.ts
index e7808e0cc..8464a46db 100644
--- a/src/managers/builtin/venvManager.ts
+++ b/src/managers/builtin/venvManager.ts
@@ -1,6 +1,15 @@
import * as fs from 'fs/promises';
import * as path from 'path';
-import { EventEmitter, l10n, LogOutputChannel, MarkdownString, ProgressLocation, ThemeIcon, Uri } from 'vscode';
+import {
+ CancellationToken,
+ EventEmitter,
+ l10n,
+ LogOutputChannel,
+ MarkdownString,
+ ProgressLocation,
+ ThemeIcon,
+ Uri,
+} from 'vscode';
import {
CreateEnvironmentOptions,
CreateEnvironmentScope,
@@ -27,6 +36,14 @@ import { traceError, traceWarn } from '../../common/logging';
import { createDeferred, Deferred } from '../../common/utils/deferred';
import { normalizePath } from '../../common/utils/pathUtils';
import { PythonVersion } from '../../common/pythonVersion';
+import {
+ EnvironmentToolSupport,
+ PythonToolError,
+ PythonToolOperation,
+ pythonToolSupport,
+ supportsEnvironmentTools,
+ throwIfCancelled,
+} from '../../internal/pythonToolSupport';
import { showErrorMessage, showInformationMessage, withProgress } from '../../common/window.apis';
import { findParentIfFile } from '../../features/envCommands';
import { getProjectFsPathForScope, tryFastPathGet } from '../common/fastPath';
@@ -88,17 +105,136 @@ export class VenvManager implements EnvironmentManager {
}
private _initialized: Deferred | undefined;
+ private discovery: Deferred | undefined;
+ private initializationError: unknown;
+ readonly [pythonToolSupport]: EnvironmentToolSupport = {
+ initialize: async () => {
+ if (!supportsEnvironmentTools(this.baseManager)) {
+ throw new PythonToolError(
+ 'UNSUPPORTED_MANAGER',
+ l10n.t('The base Python manager does not support non-interactive discovery.'),
+ );
+ }
+ await this.baseManager[pythonToolSupport].initialize();
+ await this.initializeDiscovery();
+ if (this.initializationError) {
+ throw this.initializationError;
+ }
+ },
+ get: (scope) => this.get(scope, true),
+ getEnvironments: (scope) => this.getEnvironments(scope, true),
+ resolve: (scope) => this.resolve(scope),
+ create: (scope, operation) => this.createForTools(scope, operation),
+ set: (scope, environment, token) => this.set(scope, environment, token),
+ };
+
async initialize(): Promise {
if (this._initialized) {
return this._initialized.promise;
}
+ const initialized = (this._initialized = createDeferred());
+ try {
+ await this.initializeWithBase();
+ } catch (error) {
+ if (this._initialized === initialized) {
+ this._initialized = undefined;
+ }
+ throw error;
+ } finally {
+ initialized.resolve();
+ }
+ }
- this._initialized = createDeferred();
+ private async initializeWithBase(): Promise {
+ await this.initializeDiscovery();
+ const discovery = this.discovery;
+ try {
+ await this.loadGlobalEnv(await this.baseManager.getEnvironments('global'));
+ } catch (error) {
+ // Discovery alone is not a complete human initialization when the base step fails.
+ if (this.discovery === discovery) {
+ this.discovery = undefined;
+ }
+ throw error;
+ }
+ }
+ private async initializeDiscovery(): Promise {
+ if (this.discovery) {
+ return this.discovery.promise;
+ }
+ const discovery = (this.discovery = createDeferred());
try {
- await this.internalRefresh(undefined, false, VenvManagerStrings.venvInitialize);
+ await this.internalRefresh(
+ undefined,
+ false,
+ VenvManagerStrings.venvInitialize,
+ ProgressLocation.Window,
+ true,
+ );
+ } catch (error) {
+ this.initializationError = error;
+ this.discovery = undefined;
+ throw error;
} finally {
- this._initialized.resolve();
+ discovery.resolve();
+ }
+ }
+
+ private async createForTools(scope: Uri, operation: PythonToolOperation): Promise {
+ const base =
+ operation.baseEnvironment ??
+ getLatest(
+ (await operation.getGlobalEnvironments()).filter(
+ (environment) => PythonVersion.tryParse(environment.version)?.major === 3,
+ ),
+ );
+ if (!base || PythonVersion.tryParse(base.version)?.major !== 3) {
+ throw new PythonToolError(
+ 'MISSING_PYTHON',
+ l10n.t('Install or select Python 3 before creating a virtual environment.'),
+ );
+ }
+ throwIfCancelled(operation.token);
+ this.skipWatcherRefresh = true;
+ try {
+ const result = await quickCreateVenv(
+ this.nativeFinder,
+ this.api,
+ this.log,
+ this,
+ base,
+ scope,
+ undefined,
+ operation,
+ );
+ if (!result?.environment) {
+ throw new PythonToolError(
+ 'CREATION_FAILED',
+ result?.envCreationErr ?? l10n.t('Virtual environment creation returned no environment.'),
+ );
+ }
+ this.addEnvironment(result.environment, true);
+ try {
+ await fs.writeFile(path.join(result.environment.sysPrefix, '.gitignore'), '*\n', { flag: 'w' });
+ } catch (error) {
+ throw new PythonToolError(
+ 'CREATION_FAILED',
+ l10n.t(
+ 'The environment was created, but writing its .gitignore failed: {0}',
+ error instanceof Error ? error.message : String(error),
+ ),
+ result.environment,
+ );
+ }
+ return result.environment;
+ } catch (error) {
+ if (error instanceof PythonToolError && error.environment) {
+ this.addEnvironment(error.environment, true);
+ }
+ throw error;
+ } finally {
+ this.skipWatcherRefresh = false;
}
}
@@ -345,6 +481,7 @@ export class VenvManager implements EnvironmentManager {
hardRefresh: boolean,
title: string,
location: ProgressLocation = ProgressLocation.Window,
+ toolExecution = false,
): Promise {
const selectionRevisions = new Map(this.projectSelectionRevisions);
await withProgress(
@@ -367,16 +504,17 @@ export class VenvManager implements EnvironmentManager {
this,
scope ? [scope] : undefined,
)) ?? [];
- await this.loadEnvMap(selectionRevisions);
+ await this.loadEnvMap(selectionRevisions, toolExecution);
const added = this.collection.map((env) => ({ environment: env, kind: EnvironmentChangeKind.add }));
this._onDidChangeEnvironments.fire([...discard, ...added]);
+ this.initializationError = undefined;
},
);
}
- async getEnvironments(scope: GetEnvironmentsScope): Promise {
- await this.initialize();
+ async getEnvironments(scope: GetEnvironmentsScope, toolExecution = false): Promise {
+ await (toolExecution ? this.initializeDiscovery() : this.initialize());
if (scope === 'all') {
return Array.from(this.collection);
@@ -389,8 +527,8 @@ export class VenvManager implements EnvironmentManager {
return env ? [env] : [];
}
- async get(scope: GetEnvironmentScope): Promise {
- const fastResult = await tryFastPathGet({
+ async get(scope: GetEnvironmentScope, toolExecution = false): Promise {
+ const fastResult = toolExecution ? undefined : await tryFastPathGet({
initialized: this._initialized,
setInitialized: (deferred) => {
this._initialized = deferred;
@@ -400,13 +538,13 @@ export class VenvManager implements EnvironmentManager {
getProjectFsPath: (s) => getProjectFsPathForScope(this.api, s),
getPersistedPath: (fsPath) => getVenvForWorkspace(fsPath),
resolve: (p) => resolveVenvPythonEnvironmentPath(p, this.nativeFinder, this.api, this, this.baseManager),
- startBackgroundInit: () => this.internalRefresh(undefined, false, VenvManagerStrings.venvInitialize),
+ startBackgroundInit: () => this.initializeWithBase(),
});
if (fastResult) {
return fastResult.env;
}
- await this.initialize();
+ await (toolExecution ? this.initializeDiscovery() : this.initialize());
if (!scope) {
// `undefined` for venv scenario return the global environment.
@@ -426,7 +564,8 @@ export class VenvManager implements EnvironmentManager {
return env ?? this.globalEnv;
}
- async set(scope: SetEnvironmentScope, environment?: PythonEnvironment): Promise {
+ async set(scope: SetEnvironmentScope, environment?: PythonEnvironment, token?: CancellationToken): Promise {
+ throwIfCancelled(token);
if (scope === undefined) {
const before = this.globalEnv;
this.globalEnv = environment;
@@ -445,7 +584,7 @@ export class VenvManager implements EnvironmentManager {
}
// Notify user if VIRTUAL_ENV is set and they're trying to select a different environment
- if (process.env.VIRTUAL_ENV && environment) {
+ if (!token && process.env.VIRTUAL_ENV && environment) {
const virtualEnvPath = process.env.VIRTUAL_ENV;
const selectedPath = environment.sysPrefix;
// Only show notification if they selected a different environment
@@ -608,9 +747,12 @@ export class VenvManager implements EnvironmentManager {
* Preserves project selections changed after this refresh began, including queued change notifications.
* Skips unresolvable selections without preventing other projects from restoring their environments.
*/
- private async loadEnvMap(selectionRevisions: ReadonlyMap) {
+ private async loadEnvMap(selectionRevisions: ReadonlyMap, toolExecution = false) {
const isCurrent = (key: string) => this.projectSelectionRevisions.get(key) === selectionRevisions.get(key);
- const globals = await this.baseManager.getEnvironments('global');
+ const tools = supportsEnvironmentTools(this.baseManager) ? this.baseManager[pythonToolSupport] : undefined;
+ const globals = await (toolExecution && tools?.getEnvironments
+ ? tools.getEnvironments('global')
+ : this.baseManager.getEnvironments('global'));
await this.loadGlobalEnv(globals);
this.fsPathToEnv.forEach((_env, key) => {
diff --git a/src/managers/builtin/venvUtils.ts b/src/managers/builtin/venvUtils.ts
index e70cc79d3..c91af70eb 100644
--- a/src/managers/builtin/venvUtils.ts
+++ b/src/managers/builtin/venvUtils.ts
@@ -35,6 +35,13 @@ import {
withProgress,
} from '../../common/window.apis';
import { getConfiguration } from '../../common/workspace.apis';
+import {
+ PythonToolError,
+ PythonToolOperation,
+ throwIfCancelled,
+ waitForToolRead,
+} from '../../internal/pythonToolSupport';
+import { ProcessTerminationError, ProcessTimeoutError } from '../../common/utils/processRunner';
import {
isNativeEnvInfo,
NativeEnvInfo,
@@ -364,6 +371,7 @@ export interface CreateWithProgressOptions {
readonly progressTitle?: string;
/** Controls how the created environment's user-facing name is formatted. */
readonly nameStyle?: VenvNameStyle;
+ readonly operation?: PythonToolOperation;
}
/**
@@ -416,81 +424,144 @@ export async function createWithProgress(
options?: CreateWithProgressOptions,
): Promise {
const pythonPath = getVenvPythonPath(envPath);
-
- return await withProgress(
- {
- location: ProgressLocation.Notification,
- title:
- options?.progressTitle ??
- l10n.t(
- 'Creating virtual environment named {0} using python version {1}.',
- path.basename(envPath),
- basePython.version,
- ),
- },
- async () => {
- const result: CreateEnvironmentResult = {};
- try {
- const useUv = await shouldUseUv(log, basePython.environmentPath.fsPath, venvRoot.fsPath);
- // env creation
- const baseExecutable = await getBaseInterpreterForVenv(basePython);
- if (baseExecutable) {
- if (useUv) {
- const uvExecutable = await getUvExecutable(log, venvRoot.fsPath);
- if (!uvExecutable) {
- throw new Error(`uv became unavailable for workspace: ${venvRoot.fsPath}`);
- }
- await runUV(
- ['venv', '--verbose', '--seed', '--python', baseExecutable, envPath],
- venvRoot.fsPath,
- log,
- undefined,
- undefined,
- uvExecutable,
- );
- } else {
- await runPython(baseExecutable, ['-m', 'venv', envPath], venvRoot.fsPath, manager.log);
- }
- if (!(await fsapi.pathExists(pythonPath))) {
- throw new Error('no python executable found in virtual environment');
+ const operation = options?.operation;
+ const token = operation?.token;
+ const create = async (): Promise => {
+ const result: CreateEnvironmentResult = {};
+ try {
+ throwIfCancelled(token);
+ const useUv = await shouldUseUv(log, basePython.environmentPath.fsPath, venvRoot.fsPath, token);
+ const baseExecutable = await getBaseInterpreterForVenv(basePython);
+ if (operation && !baseExecutable) {
+ throw new Error(l10n.t('Unable to determine the base Python executable.'));
+ }
+ throwIfCancelled(token);
+ if (baseExecutable) {
+ if (useUv) {
+ const uvExecutable = await getUvExecutable(log, venvRoot.fsPath, token);
+ if (!uvExecutable) {
+ throw new Error(`uv became unavailable for workspace: ${venvRoot.fsPath}`);
}
+ await runUV(
+ ['venv', '--verbose', '--seed', '--python', baseExecutable, envPath],
+ venvRoot.fsPath,
+ log,
+ token,
+ operation ? 300_000 : undefined,
+ uvExecutable,
+ !!operation,
+ );
+ } else {
+ await runPython(
+ baseExecutable,
+ ['-m', 'venv', envPath],
+ venvRoot.fsPath,
+ manager.log,
+ token,
+ operation ? 300_000 : undefined,
+ !!operation,
+ );
}
-
- // handle admin of new env
- const resolved = await nativeFinder.resolve(pythonPath);
- const env = api.createPythonEnvironmentItem(await getPythonInfo(resolved, options?.nameStyle), manager);
-
- if (
- trackUvEnvironment &&
- useUv &&
- (resolved.kind === NativePythonEnvironmentKind.venvUv ||
- resolved.kind === NativePythonEnvironmentKind.uvWorkspace)
- ) {
- await addUvEnvironment(env.environmentPath.fsPath);
+ throwIfCancelled(token);
+ if (!(await fsapi.pathExists(pythonPath))) {
+ throw new Error('no python executable found in virtual environment');
}
+ }
+
+ const resolution = nativeFinder.resolve(pythonPath);
+ const resolved = token ? await waitForToolRead(resolution, token) : await resolution;
+ throwIfCancelled(token);
+ const env = api.createPythonEnvironmentItem(await getPythonInfo(resolved, options?.nameStyle), manager);
+ if (operation) {
+ result.environment = env;
+ }
+ if (
+ trackUvEnvironment &&
+ useUv &&
+ (resolved.kind === NativePythonEnvironmentKind.venvUv ||
+ resolved.kind === NativePythonEnvironmentKind.uvWorkspace)
+ ) {
+ throwIfCancelled(token);
+ await addUvEnvironment(env.environmentPath.fsPath);
+ }
- // install packages
- if (packages && (packages.install.length > 0 || packages.uninstall.length > 0)) {
- try {
- await api.managePackages(env, {
- upgrade: false,
- install: packages?.install,
- uninstall: packages?.uninstall ?? [],
- });
- } catch (e) {
- // error occurred while installing packages
- result.pkgInstallationErr = e instanceof Error ? e.message : String(e);
- result.pkgInstallationCancelled = e instanceof CancellationError;
+ if (packages && (packages.install.length > 0 || packages.uninstall.length > 0)) {
+ try {
+ throwIfCancelled(token);
+ const packageOptions = {
+ upgrade: false,
+ install: packages.install,
+ uninstall: packages.uninstall ?? [],
+ };
+ if (operation) {
+ await operation.managePackages(env, { ...packageOptions, runHeadless: true });
+ } else {
+ await api.managePackages(env, packageOptions);
}
+ } catch (error) {
+ if (operation) {
+ if (error instanceof CancellationError) {
+ throw error;
+ }
+ throw new PythonToolError(
+ error instanceof PythonToolError
+ ? error.code
+ : error instanceof ProcessTerminationError
+ ? 'PROCESS_TERMINATION_FAILED'
+ : error instanceof ProcessTimeoutError
+ ? 'TIMEOUT'
+ : 'PACKAGE_INSTALL_FAILED',
+ l10n.t(
+ 'The environment was created, but installing project dependencies failed: {0}',
+ error instanceof Error ? error.message : String(error),
+ ),
+ env,
+ );
+ }
+ result.pkgInstallationErr = error instanceof Error ? error.message : String(error);
+ result.pkgInstallationCancelled = error instanceof CancellationError;
}
- result.environment = env;
- } catch (e) {
- log.error(`Failed to create virtual environment: ${e}`);
- result.envCreationErr = `Failed to create virtual environment: ${e}`;
}
- return result;
- },
- );
+ result.environment = env;
+ throwIfCancelled(token);
+ } catch (error) {
+ log.error(`Failed to create virtual environment: ${error}`);
+ if (operation) {
+ if (error instanceof CancellationError || error instanceof PythonToolError) {
+ throw error;
+ }
+ throw new PythonToolError(
+ error instanceof ProcessTerminationError
+ ? 'PROCESS_TERMINATION_FAILED'
+ : error instanceof ProcessTimeoutError
+ ? 'TIMEOUT'
+ : 'CREATION_FAILED',
+ l10n.t(
+ 'Failed to create virtual environment: {0}',
+ error instanceof Error ? error.message : String(error),
+ ),
+ result.environment,
+ );
+ }
+ result.envCreationErr = `Failed to create virtual environment: ${error}`;
+ }
+ return result;
+ };
+ return operation
+ ? create()
+ : withProgress(
+ {
+ location: ProgressLocation.Notification,
+ title:
+ options?.progressTitle ??
+ l10n.t(
+ 'Creating virtual environment named {0} using python version {1}.',
+ path.basename(envPath),
+ basePython.version,
+ ),
+ },
+ create,
+ );
}
export function ensureGlobalEnv(basePythons: PythonEnvironment[], log: LogOutputChannel): PythonEnvironment[] {
@@ -521,6 +592,7 @@ export async function quickCreateVenv(
baseEnv: PythonEnvironment,
venvRoot: Uri,
additionalPackages?: string[],
+ operation?: PythonToolOperation,
): Promise {
const project = api.getPythonProject(venvRoot);
@@ -528,6 +600,8 @@ export async function quickCreateVenv(
const result = await getProjectInstallable(api, project ? [project] : undefined, {
deduplicateProjectPackages: true,
preferredRoot: venvRoot,
+ token: operation?.token,
+ runHeadless: !!operation,
});
const installables = result.installables;
const allPackages = [];
@@ -537,7 +611,17 @@ export async function quickCreateVenv(
}
const validationError = result.validationError;
- const shouldProceed = await shouldProceedAfterPyprojectValidation(validationError, allPackages);
+ if (operation && validationError) {
+ throw new PythonToolError(
+ 'INVALID_PROJECT',
+ l10n.t(
+ 'Fix {0} before installing project dependencies: {1}',
+ validationError.fileUri.fsPath,
+ validationError.message,
+ ),
+ );
+ }
+ const shouldProceed = operation || (await shouldProceedAfterPyprojectValidation(validationError, allPackages));
if (!shouldProceed) {
return undefined;
}
@@ -545,6 +629,15 @@ export async function quickCreateVenv(
// Check if .venv already exists
let venvPath = path.join(venvRoot.fsPath, '.venv');
if (await fsapi.pathExists(venvPath)) {
+ if (operation) {
+ throw new PythonToolError(
+ 'ENVIRONMENT_EXISTS',
+ l10n.t(
+ 'An environment directory already exists at {0}. Resolve or remove it before creating another environment.',
+ venvPath,
+ ),
+ );
+ }
// increment to create a unique name, e.g. .venv-1
let i = 1;
while (await fsapi.pathExists(`${venvPath}-${i}`)) {
@@ -554,10 +647,22 @@ export async function quickCreateVenv(
}
// createWithProgress handles building CreateEnvironmentResult and adding err msgs
- return await createWithProgress(nativeFinder, api, log, manager, baseEnv, venvRoot, venvPath, {
- install: allPackages,
- uninstall: [],
- });
+ throwIfCancelled(operation?.token);
+ return await createWithProgress(
+ nativeFinder,
+ api,
+ log,
+ manager,
+ baseEnv,
+ venvRoot,
+ venvPath,
+ {
+ install: allPackages,
+ uninstall: [],
+ },
+ true,
+ { operation },
+ );
}
export async function createPythonVenv(
diff --git a/src/managers/common/packageChanges.ts b/src/managers/common/packageChanges.ts
index e3eaea9a0..70db6daec 100644
--- a/src/managers/common/packageChanges.ts
+++ b/src/managers/common/packageChanges.ts
@@ -56,11 +56,12 @@ export async function updatePackagesAndNotify(
before: Package[] | undefined,
onChanges: PackageChangesCallback,
fetchPackages?: PackageFetcher,
+ skipDirectDependencies = false,
): Promise {
const [after, afterDirectDependenciesNames] = await Promise.all([
fetchPackages?.() ?? packageManager.getPackages(environment, { skipCache: true }),
// Handle transitive dependencies (best-effort, don't break package refresh on failure)
- packageManager.getDirectPackageNames?.(environment).catch(() => undefined),
+ skipDirectDependencies ? undefined : packageManager.getDirectPackageNames?.(environment).catch(() => undefined),
]);
if (after === undefined) {
diff --git a/src/managers/common/registeredManagers.ts b/src/managers/common/registeredManagers.ts
index 4c929eaa2..834964cd3 100644
--- a/src/managers/common/registeredManagers.ts
+++ b/src/managers/common/registeredManagers.ts
@@ -11,6 +11,13 @@ import { StopWatch } from '../../common/stopWatch';
import { EventNames } from '../../common/telemetry/constants';
import { classifyError, isTimeoutErrorType } from '../../common/telemetry/errorClassifier';
import { sendTelemetryEvent } from '../../common/telemetry/sender';
+import {
+ EnvironmentToolSupport,
+ PackageToolSupport,
+ pythonToolSupport,
+ supportsEnvironmentTools,
+ supportsPackageTools,
+} from '../../internal/pythonToolSupport';
import type {
CreateEnvironmentOptions,
CreateEnvironmentScope,
@@ -48,6 +55,10 @@ export class InternalEnvironmentManager implements EnvironmentManager {
private readonly manager: EnvironmentManager,
) {}
+ public get tools(): EnvironmentToolSupport | undefined {
+ return supportsEnvironmentTools(this.manager) ? this.manager[pythonToolSupport] : undefined;
+ }
+
public get name(): string {
return this.manager.name;
}
@@ -251,6 +262,10 @@ export class InternalPackageManager implements PackageManager {
}
}
+ public get tools(): PackageToolSupport | undefined {
+ return supportsPackageTools(this.manager) ? this.manager[pythonToolSupport] : undefined;
+ }
+
public get name(): string {
return this.manager.name;
}
diff --git a/src/managers/conda/commands/install.ts b/src/managers/conda/commands/install.ts
index ad3e04690..58007f6a7 100644
--- a/src/managers/conda/commands/install.ts
+++ b/src/managers/conda/commands/install.ts
@@ -33,6 +33,12 @@ export class CondaInstallCommand extends InstallCommand {
}
async execute(executeArgs: InstallExecuteArgs): Promise {
- await runCondaExecutable(this.buildCommand(executeArgs), this.log, executeArgs.cancellationToken);
+ await runCondaExecutable(
+ this.buildCommand(executeArgs),
+ this.log,
+ executeArgs.cancellationToken,
+ this.timeout ?? (executeArgs.toolExecution ? 300_000 : undefined),
+ executeArgs.toolExecution,
+ );
}
}
diff --git a/src/managers/conda/commands/list.ts b/src/managers/conda/commands/list.ts
index b344c10b8..ca2fdf2ac 100644
--- a/src/managers/conda/commands/list.ts
+++ b/src/managers/conda/commands/list.ts
@@ -34,7 +34,13 @@ export class CondaListCommand extends ListCommand {
}
async execute(executeArgs?: BaseExecuteArgs): Promise {
- const output = await runCondaExecutable(this.buildCommand(), this.log, executeArgs?.cancellationToken);
+ const output = await runCondaExecutable(
+ this.buildCommand(),
+ this.log,
+ executeArgs?.cancellationToken,
+ executeArgs?.toolExecution ? this.timeout : undefined,
+ executeArgs?.toolExecution,
+ );
let parsed: unknown;
try {
parsed = JSON.parse(output);
@@ -59,6 +65,8 @@ export class CondaListCommand extends ListCommand {
version: condaPkg.version,
description: condaPkg.version,
});
+ } else if (executeArgs?.strict) {
+ throw new CondaListOutputError('Invalid package in conda list output');
}
}
diff --git a/src/managers/conda/commands/uninstall.ts b/src/managers/conda/commands/uninstall.ts
index 53400acf3..57ed0c7cc 100644
--- a/src/managers/conda/commands/uninstall.ts
+++ b/src/managers/conda/commands/uninstall.ts
@@ -22,6 +22,12 @@ export class CondaUninstallCommand extends UninstallCommand {
}
async execute(executeArgs: UninstallExecuteArgs): Promise {
- await runCondaExecutable(this.buildCommand(executeArgs), this.log, executeArgs.cancellationToken);
+ await runCondaExecutable(
+ this.buildCommand(executeArgs),
+ this.log,
+ executeArgs.cancellationToken,
+ executeArgs.toolExecution ? 300_000 : undefined,
+ executeArgs.toolExecution,
+ );
}
}
diff --git a/src/managers/conda/condaEnvManager.ts b/src/managers/conda/condaEnvManager.ts
index 558057820..adcf6264a 100644
--- a/src/managers/conda/condaEnvManager.ts
+++ b/src/managers/conda/condaEnvManager.ts
@@ -1,6 +1,15 @@
import * as fs from 'fs-extra';
import * as path from 'path';
-import { Disposable, EventEmitter, l10n, LogOutputChannel, MarkdownString, ProgressLocation, Uri } from 'vscode';
+import {
+ CancellationToken,
+ Disposable,
+ EventEmitter,
+ l10n,
+ LogOutputChannel,
+ MarkdownString,
+ ProgressLocation,
+ Uri,
+} from 'vscode';
import {
CreateEnvironmentOptions,
CreateEnvironmentScope,
@@ -27,6 +36,12 @@ import { classifyError } from '../../common/telemetry/errorClassifier';
import { sendTelemetryEvent } from '../../common/telemetry/sender';
import { createDeferred, Deferred } from '../../common/utils/deferred';
import { normalizePath } from '../../common/utils/pathUtils';
+import {
+ EnvironmentToolSupport,
+ PythonToolError,
+ pythonToolSupport,
+ throwIfCancelled,
+} from '../../internal/pythonToolSupport';
import { showErrorMessage, showInformationMessage, withProgress } from '../../common/window.apis';
import type { PythonProjectManager } from '../../features/projectManager';
import { getProjectFsPathForScope, tryFastPathGet } from '../common/fastPath';
@@ -40,6 +55,7 @@ import {
deleteCondaEnvironment,
generateName,
getConda,
+ getCondaForTools,
getCondaForGlobal,
getCondaForWorkspace,
getCondaPathSetting,
@@ -90,6 +106,83 @@ export class CondaEnvManager implements EnvironmentManager, Disposable {
}
private _initialized: Deferred | undefined;
+ private discovery: Deferred | undefined;
+ private initializationError: unknown;
+ readonly [pythonToolSupport]: EnvironmentToolSupport = {
+ initialize: async () => {
+ await this.initializeDiscovery();
+ if (this.initializationError) {
+ throw this.initializationError;
+ }
+ },
+ get: (scope) => this.get(scope, true),
+ getEnvironments: (scope) => this.getEnvironments(scope, true),
+ resolve: (scope) => this.resolve(scope, true),
+ create: async (scope, operation) => {
+ throwIfCancelled(operation.token);
+ const prefix = path.join(scope.fsPath, '.conda');
+ if (await fs.pathExists(prefix)) {
+ throw new PythonToolError(
+ 'ENVIRONMENT_EXISTS',
+ l10n.t(
+ 'An environment directory already exists at {0}. Resolve or remove it before creating another environment.',
+ prefix,
+ ),
+ );
+ }
+ const environment = await quickCreateConda(
+ this.api,
+ this.log,
+ this,
+ scope.fsPath,
+ '.conda',
+ undefined,
+ operation,
+ );
+ if (!environment) {
+ throw new PythonToolError('CREATION_FAILED', l10n.t('Conda did not return a created environment.'));
+ }
+ throwIfCancelled(operation.token);
+ this.addEnvironment(environment);
+ try {
+ await fs.writeFile(path.join(prefix, '.gitignore'), '*\n', { flag: 'w' });
+ } catch (error) {
+ throw new PythonToolError(
+ 'CREATION_FAILED',
+ l10n.t(
+ 'The environment was created, but writing its .gitignore failed: {0}',
+ error instanceof Error ? error.message : String(error),
+ ),
+ environment,
+ );
+ }
+ return environment;
+ },
+ set: (scope, environment, token) => this.set(scope, environment, token),
+ describe: async (environment, token) => {
+ throwIfCancelled(token);
+ const conda = await getCondaForTools();
+ throwIfCancelled(token);
+ return {
+ ...environment,
+ execInfo: {
+ ...environment.execInfo,
+ activatedRun: {
+ executable: conda,
+ args: [
+ 'run',
+ '--prefix',
+ environment.sysPrefix,
+ '--no-capture-output',
+ environment.execInfo.run.executable,
+ ...(environment.execInfo.run.args ?? []),
+ ],
+ },
+ },
+ };
+ },
+ };
+
async initialize(): Promise {
if (this._initialized) {
return this._initialized.promise;
@@ -117,21 +210,7 @@ export class CondaEnvManager implements EnvironmentManager, Disposable {
toolSource = hasExplicitSetting ? 'settings' : 'local';
}
- await withProgress(
- {
- location: ProgressLocation.Window,
- title: CondaStrings.condaDiscovering,
- },
- async () => {
- this.collection =
- (await refreshCondaEnvs(false, this.nativeFinder, this.api, this.log, this)) ?? [];
- await this.loadEnvMap();
-
- this._onDidChangeEnvironments.fire(
- this.collection.map((e) => ({ environment: e, kind: EnvironmentChangeKind.add })),
- );
- },
- );
+ await this.initializeDiscovery();
envCount = this.collection.length;
@@ -177,8 +256,55 @@ export class CondaEnvManager implements EnvironmentManager, Disposable {
}
}
- async getEnvironments(scope: GetEnvironmentsScope): Promise {
- await this.initialize();
+ private async initializeDiscovery(): Promise {
+ if (this.discovery) {
+ return this.discovery.promise;
+ }
+ const discovery = (this.discovery = createDeferred());
+ try {
+ await withProgress(
+ { location: ProgressLocation.Window, title: CondaStrings.condaDiscovering },
+ async () => {
+ this.collection = await this.discoverEnvironments(false);
+ await this.loadEnvMap();
+ this._onDidChangeEnvironments.fire(
+ this.collection.map((environment) => ({ environment, kind: EnvironmentChangeKind.add })),
+ );
+ },
+ );
+ } catch (error) {
+ this.initializationError = error;
+ this.discovery = undefined;
+ throw error;
+ } finally {
+ discovery.resolve();
+ }
+ }
+
+ private async discoverEnvironments(hardRefresh: boolean): Promise {
+ try {
+ let discoveryError: unknown;
+ const environments = await refreshCondaEnvs(
+ hardRefresh,
+ this.nativeFinder,
+ this.api,
+ this.log,
+ this,
+ false,
+ (error) => {
+ discoveryError ??= error;
+ },
+ );
+ this.initializationError = discoveryError;
+ return environments;
+ } catch (error) {
+ this.initializationError = error;
+ throw error;
+ }
+ }
+
+ async getEnvironments(scope: GetEnvironmentsScope, toolExecution = false): Promise {
+ await (toolExecution ? this.initializeDiscovery() : this.initialize());
if (scope === 'all') {
return Array.from(this.collection);
@@ -315,7 +441,7 @@ export class CondaEnvManager implements EnvironmentManager, Disposable {
async () => {
this.log.info('Refreshing Conda Environments');
const discard = this.collection.map((c) => c);
- this.collection = (await refreshCondaEnvs(true, this.nativeFinder, this.api, this.log, this)) ?? [];
+ this.collection = await this.discoverEnvironments(true);
await this.loadEnvMap();
@@ -329,8 +455,8 @@ export class CondaEnvManager implements EnvironmentManager, Disposable {
);
}
}
- async get(scope: GetEnvironmentScope): Promise {
- const fastResult = await tryFastPathGet({
+ async get(scope: GetEnvironmentScope, toolExecution = false): Promise {
+ const fastResult = toolExecution ? undefined : await tryFastPathGet({
initialized: this._initialized,
setInitialized: (deferred) => {
this._initialized = deferred;
@@ -340,24 +466,13 @@ export class CondaEnvManager implements EnvironmentManager, Disposable {
getProjectFsPath: (s) => getProjectFsPathForScope(this.api, s),
getPersistedPath: (fsPath) => getCondaForWorkspace(fsPath),
resolve: (p) => resolveCondaPath(p, this.nativeFinder, this.api, this.log, this),
- startBackgroundInit: () =>
- withProgress({ location: ProgressLocation.Window, title: CondaStrings.condaDiscovering }, async () => {
- this.collection =
- (await refreshCondaEnvs(false, this.nativeFinder, this.api, this.log, this)) ?? [];
- await this.loadEnvMap();
- this._onDidChangeEnvironments.fire(
- this.collection.map((e) => ({
- environment: e,
- kind: EnvironmentChangeKind.add,
- })),
- );
- }),
+ startBackgroundInit: () => this.initializeDiscovery(),
});
if (fastResult) {
return fastResult.env;
}
- await this.initialize();
+ await (toolExecution ? this.initializeDiscovery() : this.initialize());
if (scope instanceof Uri) {
let env = this.fsPathToEnv.get(normalizePath(scope.fsPath));
if (env) {
@@ -375,10 +490,25 @@ export class CondaEnvManager implements EnvironmentManager, Disposable {
return this.globalEnv;
}
- async set(scope: SetEnvironmentScope, environment?: PythonEnvironment | undefined): Promise {
- const checkedEnv = environment
- ? await checkForNoPythonCondaEnvironment(this.nativeFinder, this, environment, this.api, this.log)
- : undefined;
+ async set(
+ scope: SetEnvironmentScope,
+ environment?: PythonEnvironment | undefined,
+ token?: CancellationToken,
+ ): Promise {
+ throwIfCancelled(token);
+ if (token && environment?.version === 'no-python') {
+ throw new PythonToolError(
+ 'MISSING_PYTHON',
+ l10n.t('The selected Conda environment has no Python. Install Python into it before selecting it.'),
+ environment,
+ );
+ }
+ const checkedEnv = token
+ ? environment
+ : environment
+ ? await checkForNoPythonCondaEnvironment(this.nativeFinder, this, environment, this.api, this.log)
+ : undefined;
+ throwIfCancelled(token);
if (scope === undefined) {
const before = this.globalEnv;
@@ -392,7 +522,7 @@ export class CondaEnvManager implements EnvironmentManager, Disposable {
const fsPath = folder?.uri?.fsPath ?? scope.fsPath;
if (fsPath) {
// Notify user if CONDA_PREFIX is set and they're trying to select a different environment
- if (process.env.CONDA_PREFIX && checkedEnv) {
+ if (!token && process.env.CONDA_PREFIX && checkedEnv) {
const condaPrefixPath = process.env.CONDA_PREFIX;
const selectedPath = checkedEnv.environmentPath.fsPath;
// Only show notification if they selected a different environment
@@ -461,8 +591,8 @@ export class CondaEnvManager implements EnvironmentManager, Disposable {
}
}
- async resolve(context: ResolveEnvironmentContext): Promise {
- await this.initialize();
+ async resolve(context: ResolveEnvironmentContext, toolExecution = false): Promise {
+ await (toolExecution ? this.initializeDiscovery() : this.initialize());
if (context instanceof Uri) {
const env = await resolveCondaPath(context.fsPath, this.nativeFinder, this.api, this.log, this);
diff --git a/src/managers/conda/condaPackageManager.ts b/src/managers/conda/condaPackageManager.ts
index f0d9ca4c2..eef0c5646 100644
--- a/src/managers/conda/condaPackageManager.ts
+++ b/src/managers/conda/condaPackageManager.ts
@@ -37,6 +37,7 @@ import {
CondaVersionCommand,
} from './commands/index';
import { getCommonCondaPackagesToInstall } from './condaUtils';
+import { PackageToolSupport, pythonToolSupport, throwIfCancelled } from '../../internal/pythonToolSupport';
export class CondaPackageManager implements PackageManager, Disposable {
private readonly _onDidChangePackages = new EventEmitter();
@@ -59,7 +60,23 @@ export class CondaPackageManager implements PackageManager, Disposable {
tooltip?: string | MarkdownString;
iconPath?: IconPath;
- async manage(environment: PythonEnvironment, options: PackageManagementOptions): Promise {
+ readonly [pythonToolSupport]: PackageToolSupport = {
+ manage: (environment, options, token) => this.manage(environment, { ...options, runHeadless: true }, token),
+ getPackages: async (environment, token) => {
+ const packages = await this.fetchPackages(environment, token);
+ if (!packages) {
+ throw new Error('Conda package listing returned no result.');
+ }
+ return packages;
+ },
+ };
+
+ async manage(
+ environment: PythonEnvironment,
+ options: PackageManagementOptions,
+ toolToken?: CancellationToken,
+ ): Promise {
+ throwIfCancelled(toolToken);
let toInstall: string[] = [...(options.install ?? [])];
let toUninstall: string[] = [...(options.uninstall ?? [])];
@@ -89,6 +106,7 @@ export class CondaPackageManager implements PackageManager, Disposable {
await command.execute({
packages: parsePackageSpecs(toUninstall),
cancellationToken: token,
+ toolExecution: !!toolToken,
});
}
@@ -98,6 +116,7 @@ export class CondaPackageManager implements PackageManager, Disposable {
packages: parsePackageSpecs(toInstall),
upgrade: options.upgrade,
cancellationToken: token,
+ toolExecution: !!toolToken,
});
}
@@ -108,7 +127,8 @@ export class CondaPackageManager implements PackageManager, Disposable {
(changes) => {
this._onDidChangePackages.fire({ environment, manager: this, changes });
},
- () => this.fetchPackages(environment),
+ () => this.fetchPackages(environment, toolToken),
+ !!toolToken,
);
} catch (e) {
if (e instanceof CancellationError) {
@@ -125,7 +145,7 @@ export class CondaPackageManager implements PackageManager, Disposable {
};
if (options.runHeadless) {
- await execute();
+ await execute(toolToken);
return;
}
@@ -169,18 +189,28 @@ export class CondaPackageManager implements PackageManager, Disposable {
return this.packages.get(environment.envId.id);
}
- private async fetchPackages(environment: PythonEnvironment): Promise {
+ private async fetchPackages(
+ environment: PythonEnvironment,
+ token?: CancellationToken,
+ ): Promise {
+ throwIfCancelled(token);
const listCmd = new CondaListCommand({
pythonExecutable: 'conda',
condaEnvironmentPath: environment.environmentPath.fsPath,
log: this.log,
});
try {
- const data = await listCmd.execute();
+ const data = await listCmd.execute(
+ token ? { cancellationToken: token, strict: true, toolExecution: true } : undefined,
+ );
+ throwIfCancelled(token);
const packages = data.map((pkg) => this.api.createPackageItem(pkg, environment, this));
this.packages.set(environment.envId.id, packages);
return packages;
} catch (error) {
+ if (token) {
+ throw error;
+ }
if (error instanceof CondaListOutputError) {
this.log.error('Error parsing installed Conda packages', error);
return undefined;
diff --git a/src/managers/conda/condaUtils.ts b/src/managers/conda/condaUtils.ts
index 98a92a396..dd0df78fe 100644
--- a/src/managers/conda/condaUtils.ts
+++ b/src/managers/conda/condaUtils.ts
@@ -1,6 +1,8 @@
import * as fse from 'fs-extra';
import * as os from 'os';
import * as path from 'path';
+import { PythonToolError, PythonToolOperation, throwIfCancelled } from '../../internal/pythonToolSupport';
+import { ProcessTerminationError, ProcessTimeoutError, runLoggedProcess } from '../../common/utils/processRunner';
import {
CancellationError,
CancellationToken,
@@ -31,7 +33,6 @@ import { getWorkspacePersistentState } from '../../common/persistentState';
import { pickProject } from '../../common/pickers/projects';
import { PythonVersion } from '../../common/pythonVersion';
import { StopWatch } from '../../common/stopWatch';
-import { createDeferred } from '../../common/utils/deferred';
import { untildify } from '../../common/utils/pathUtils';
import { isWindows } from '../../common/utils/platformUtils';
import {
@@ -201,51 +202,85 @@ export async function getConda(native?: NativePythonFinder): Promise {
return await getCondaExecutable(native);
}
+/** Resolves a configured command name for tool execution and portable command descriptors. */
+export async function getCondaForTools(): Promise {
+ const command = await getConda();
+ const resolved = path.resolve(path.isAbsolute(command) ? command : await which(command));
+ if (!isWindows() || !['.bat', '.cmd'].includes(path.extname(resolved).toLowerCase())) {
+ return resolved;
+ }
+
+ const directory = path.dirname(resolved);
+ const executableName = `${path.basename(resolved, path.extname(resolved))}.exe`;
+ const candidates = [
+ path.join(directory, executableName),
+ path.join(directory, 'Scripts', 'conda.exe'),
+ path.join(directory, '_conda.exe'),
+ path.resolve(directory, '..', 'Scripts', 'conda.exe'),
+ path.resolve(directory, '..', '_conda.exe'),
+ ];
+ for (const candidate of new Set(candidates.map((value) => path.resolve(value)))) {
+ if (await fse.pathExists(candidate)) {
+ return candidate;
+ }
+ }
+
+ throw new PythonToolError(
+ 'CONDA_EXECUTABLE_NOT_FOUND',
+ l10n.t(
+ 'Conda tool operations cannot launch the command script at {0}. Set python.condaPath to a conda.exe executable.',
+ resolved,
+ ),
+ );
+}
+
async function _runConda(
conda: string,
args: string[],
log?: LogOutputChannel,
token?: CancellationToken,
+ timeout?: number,
+ toolExecution = false,
): Promise {
- const deferred = createDeferred();
- args = quoteArgs(args);
- const quotedConda = quoteStringIfNecessary(conda);
+ const command = toolExecution ? conda : quoteStringIfNecessary(conda);
+ const commandArgs = toolExecution ? args : quoteArgs(args);
const timer = new StopWatch();
- deferred.promise.finally(() => traceInfo(`Ran conda in ${timer.elapsedTime}: ${quotedConda} ${args.join(' ')}`));
- const proc = spawnProcess(quotedConda, args, { shell: true });
-
- token?.onCancellationRequested(() => {
- proc.kill();
- deferred.reject(new CancellationError());
- });
-
- proc.on('error', (err) => {
- log?.error(`Error spawning conda: ${err}`);
- deferred.reject(new Error(`Error spawning conda: ${err.message}`));
- });
-
- let stdout = '';
- let stderr = '';
- proc.stdout?.on('data', (data) => {
- const d = data.toString('utf-8');
- stdout += d;
- log?.info(d.trim());
- });
- proc.stderr?.on('data', (data) => {
- const d = data.toString('utf-8');
- stderr += d;
- log?.error(d.trim());
- });
- proc.on('close', () => {
- deferred.resolve(stdout);
- });
- proc.on('exit', (code) => {
- if (code !== 0) {
- deferred.reject(new Error(`Failed to run "conda ${args.join(' ')}":\n ${stderr}`));
+ try {
+ if (toolExecution) {
+ return await runLoggedProcess(command, commandArgs, { shell: false, windowsHide: true }, log, token, timeout);
}
- });
-
- return deferred.promise;
+ return await new Promise((resolve, reject) => {
+ const proc = spawnProcess(command, commandArgs, { shell: true });
+ token?.onCancellationRequested(() => {
+ proc.kill();
+ reject(new CancellationError());
+ });
+ proc.on('error', (err) => {
+ log?.error(`Error spawning conda: ${err}`);
+ reject(new Error(`Error spawning conda: ${err.message}`));
+ });
+ let stdout = '';
+ let stderr = '';
+ proc.stdout?.on('data', (data) => {
+ const value = data.toString('utf-8');
+ stdout += value;
+ log?.info(value.trim());
+ });
+ proc.stderr?.on('data', (data) => {
+ const value = data.toString('utf-8');
+ stderr += value;
+ log?.error(value.trim());
+ });
+ proc.on('close', () => resolve(stdout));
+ proc.on('exit', (code) => {
+ if (code !== 0) {
+ reject(new Error(`Failed to run "conda ${commandArgs.join(' ')}":\n ${stderr}`));
+ }
+ });
+ });
+ } finally {
+ traceInfo(`Ran conda in ${timer.elapsedTime}: ${command} ${commandArgs.join(' ')}`);
+ }
}
async function runConda(args: string[], log?: LogOutputChannel, token?: CancellationToken): Promise {
@@ -257,9 +292,14 @@ export async function runCondaExecutable(
args: string[],
log?: LogOutputChannel,
token?: CancellationToken,
+ timeout?: number,
+ toolExecution = false,
): Promise {
- const conda = await getCondaExecutable(undefined);
- return await _runConda(conda, args, log, token);
+ if (toolExecution) {
+ throwIfCancelled(token);
+ }
+ const conda = toolExecution ? await getCondaForTools() : await getCondaExecutable(undefined);
+ return await _runConda(conda, args, log, token, timeout, toolExecution);
}
let prefixes: string[] | undefined;
@@ -841,6 +881,8 @@ export async function refreshCondaEnvs(
api: PythonEnvironmentApi,
log: LogOutputChannel,
manager: EnvironmentManager,
+ strict = false,
+ onError?: (error: unknown) => void,
): Promise {
log.info(`Refreshing conda environments (hardRefresh=${hardRefresh})`);
@@ -850,6 +892,10 @@ export async function refreshCondaEnvs(
} catch (error) {
traceError('Failed to refresh native finder for conda environments', error);
log.error(`Failed to refresh native finder: ${error instanceof Error ? error.message : String(error)}`);
+ onError?.(error);
+ if (strict) {
+ throw error;
+ }
return [];
}
@@ -857,6 +903,15 @@ export async function refreshCondaEnvs(
if (!data || !Array.isArray(data)) {
traceWarn(`Native finder returned invalid data: ${typeof data}, expected array`);
log.warn(`Native finder returned invalid data type: ${typeof data}`);
+ const error = new Error(
+ l10n.t(
+ 'Conda discovery returned invalid data. Retry environment discovery before configuring an environment.',
+ ),
+ );
+ onError?.(error);
+ if (strict) {
+ throw error;
+ }
return [];
}
@@ -911,6 +966,10 @@ export async function refreshCondaEnvs(
error instanceof Error ? error.message : String(error)
}`,
);
+ onError?.(error);
+ if (strict) {
+ throw error;
+ }
}
}),
);
@@ -1189,38 +1248,77 @@ export async function quickCreateConda(
fsPath: string,
name: string,
additionalPackages?: string[],
+ operation?: PythonToolOperation,
): Promise {
const prefix = path.join(fsPath, name);
const execPath = os.platform() === 'win32' ? path.join(prefix, 'python.exe') : path.join(prefix, 'bin', 'python');
- return await withProgress(
- {
- location: ProgressLocation.Notification,
- title: `Creating conda environment: ${name}`,
- },
- async () => {
- try {
- const conda = await getConda();
- await runCondaExecutable(['create', '--yes', '--prefix', prefix, 'python'], log);
- if (additionalPackages && additionalPackages.length > 0) {
- await runConda(['install', '--yes', '--prefix', prefix, ...additionalPackages], log);
- }
- const version = await getVersion(prefix);
+ const create = async () => {
+ let environment: PythonEnvironment | undefined;
+ try {
+ throwIfCancelled(operation?.token);
+ const conda = await getConda();
+ const baseVersion = operation?.baseEnvironment
+ ? PythonVersion.tryParse(operation.baseEnvironment.version)
+ : undefined;
+ await runCondaExecutable(
+ [
+ 'create',
+ '--yes',
+ '--prefix',
+ prefix,
+ baseVersion ? `python=${baseVersion.major}.${baseVersion.minor}` : 'python',
+ ],
+ log,
+ operation?.token,
+ operation ? 300_000 : undefined,
+ !!operation,
+ );
+ throwIfCancelled(operation?.token);
+ if (!operation && additionalPackages && additionalPackages.length > 0) {
+ await runConda(['install', '--yes', '--prefix', prefix, ...additionalPackages], log);
+ }
+ const version = await getVersion(prefix);
- // Use proper prefix-based activation with actual conda path
- const environment = api.createPythonEnvironmentItem(
- await getPrefixesCondaPythonInfo(prefix, execPath, version, conda, manager),
- manager,
+ // Use proper prefix-based activation with actual conda path
+ environment = api.createPythonEnvironmentItem(
+ await getPrefixesCondaPythonInfo(prefix, execPath, version, conda, manager),
+ manager,
+ );
+ if (operation && additionalPackages && additionalPackages.length > 0) {
+ await operation.managePackages(environment, { install: additionalPackages, runHeadless: true });
+ }
+ throwIfCancelled(operation?.token);
+ return environment;
+ } catch (e) {
+ log.error('Failed to create conda environment', e);
+ if (operation) {
+ if (e instanceof CancellationError || e instanceof PythonToolError) {
+ throw e;
+ }
+ throw new PythonToolError(
+ e instanceof ProcessTerminationError
+ ? 'PROCESS_TERMINATION_FAILED'
+ : e instanceof ProcessTimeoutError
+ ? 'TIMEOUT'
+ : environment
+ ? 'PACKAGE_INSTALL_FAILED'
+ : 'CREATION_FAILED',
+ l10n.t('Failed to set up the Conda environment: {0}', e instanceof Error ? e.message : String(e)),
+ environment,
);
- return environment;
- } catch (e) {
- log.error('Failed to create conda environment', e);
- setImmediate(async () => {
- await showErrorMessageWithLogs(CondaStrings.condaCreateFailed, log);
- });
}
- },
- );
+ setImmediate(async () => {
+ await showErrorMessageWithLogs(CondaStrings.condaCreateFailed, log);
+ });
+ }
+ };
+ return operation
+ ? create()
+ : withProgress(
+ { location: ProgressLocation.Notification, title: `Creating conda environment: ${name}` },
+ create,
+ );
}
export async function deleteCondaEnvironment(environment: PythonEnvironment, log: LogOutputChannel): Promise {
diff --git a/src/managers/pipenv/pipenvManager.ts b/src/managers/pipenv/pipenvManager.ts
index 583811e69..6c21e16ab 100644
--- a/src/managers/pipenv/pipenvManager.ts
+++ b/src/managers/pipenv/pipenvManager.ts
@@ -1,4 +1,4 @@
-import { Disposable, EventEmitter, MarkdownString, ProgressLocation, Uri, workspace } from 'vscode';
+import { Disposable, EventEmitter, MarkdownString, ProgressLocation, Uri } from 'vscode';
import {
DidChangeEnvironmentEventArgs,
DidChangeEnvironmentsEventArgs,
@@ -23,10 +23,16 @@ import { sendTelemetryEvent } from '../../common/telemetry/sender';
import { createDeferred, Deferred } from '../../common/utils/deferred';
import { normalizePath } from '../../common/utils/pathUtils';
import { withProgress } from '../../common/window.apis';
+import { getConfiguration } from '../../common/workspace.apis';
import type { PythonProjectManager } from '../../features/projectManager';
import { getProjectFsPathForScope, tryFastPathGet } from '../common/fastPath';
-import { NativePythonFinder } from '../common/nativePythonFinder';
+import { NativePythonEnvironmentKind, NativePythonFinder } from '../common/nativePythonFinder';
import { notifyMissingManagerIfDefault } from '../common/utils';
+import {
+ EnvironmentToolSupport,
+ pythonToolSupport,
+ resolveToolProjectEnvironment,
+} from '../../internal/pythonToolSupport';
import {
clearPipenvCache,
getPipenv,
@@ -77,6 +83,24 @@ export class PipenvManager implements EnvironmentManager, Disposable {
this._onDidChangeEnvironments.dispose();
}
+ private discovery: Deferred | undefined;
+ private initializationError: unknown;
+ readonly [pythonToolSupport]: EnvironmentToolSupport = {
+ initialize: async () => {
+ await this.initializeDiscovery();
+ if (this.initializationError) {
+ throw this.initializationError;
+ }
+ },
+ get: (scope) => this.get(scope, true),
+ getEnvironments: (scope) => this.getEnvironments(scope, true),
+ resolve: (scope) => this.resolve(scope, true),
+ resolveProject: (scope) =>
+ resolveToolProjectEnvironment(scope, NativePythonEnvironmentKind.pipenv, this.nativeFinder, (uri) =>
+ this.resolve(uri, true),
+ ),
+ };
+
async initialize(): Promise {
if (this._initialized) {
return this._initialized.promise;
@@ -90,26 +114,13 @@ export class PipenvManager implements EnvironmentManager, Disposable {
try {
// Check if tool is findable before PET refresh (settings/cache/PATH only, no PET)
- const hasExplicitSetting = !!workspace.getConfiguration('python').get('pipenvPath');
+ const hasExplicitSetting = !!getConfiguration('python').get('pipenvPath');
const preRefreshTool = await getPipenv();
if (preRefreshTool) {
toolSource = hasExplicitSetting ? 'settings' : 'local';
}
- await withProgress(
- {
- location: ProgressLocation.Window,
- title: PipenvStrings.pipenvDiscovering,
- },
- async () => {
- this.collection = (await refreshPipenv(false, this.nativeFinder, this.api, this)) ?? [];
- await this.loadEnvMap();
-
- this._onDidChangeEnvironments.fire(
- this.collection.map((e) => ({ environment: e, kind: EnvironmentChangeKind.add })),
- );
- },
- );
+ await this.initializeDiscovery();
envCount = this.collection.length;
@@ -141,6 +152,32 @@ export class PipenvManager implements EnvironmentManager, Disposable {
}
}
+ private async initializeDiscovery(): Promise {
+ if (this.discovery) {
+ return this.discovery.promise;
+ }
+ const discovery = (this.discovery = createDeferred());
+ try {
+ await withProgress(
+ { location: ProgressLocation.Window, title: PipenvStrings.pipenvDiscovering },
+ async () => {
+ this.collection = (await refreshPipenv(false, this.nativeFinder, this.api, this)) ?? [];
+ await this.loadEnvMap();
+ this.initializationError = undefined;
+ this._onDidChangeEnvironments.fire(
+ this.collection.map((environment) => ({ environment, kind: EnvironmentChangeKind.add })),
+ );
+ },
+ );
+ } catch (error) {
+ this.initializationError = error;
+ this.discovery = undefined;
+ throw error;
+ } finally {
+ discovery.resolve();
+ }
+ }
+
private async loadEnvMap() {
// Load environment mappings for projects
const projects = this.api.getPythonProjects();
@@ -183,6 +220,7 @@ export class PipenvManager implements EnvironmentManager, Disposable {
const oldCollection = [...this.collection];
this.collection = (await refreshPipenv(hardRefresh, this.nativeFinder, this.api, this)) ?? [];
await this.loadEnvMap();
+ this.initializationError = undefined;
// Fire change events for environments that were added or removed
const changes: { environment: PythonEnvironment; kind: EnvironmentChangeKind }[] = [];
@@ -208,8 +246,8 @@ export class PipenvManager implements EnvironmentManager, Disposable {
);
}
- async getEnvironments(scope: GetEnvironmentsScope): Promise {
- await this.initialize();
+ async getEnvironments(scope: GetEnvironmentsScope, toolExecution = false): Promise {
+ await (toolExecution ? this.initializeDiscovery() : this.initialize());
if (scope === 'all') {
return Array.from(this.collection);
@@ -302,8 +340,8 @@ export class PipenvManager implements EnvironmentManager, Disposable {
}
}
- async get(scope: GetEnvironmentScope): Promise {
- const fastResult = await tryFastPathGet({
+ async get(scope: GetEnvironmentScope, toolExecution = false): Promise {
+ const fastResult = toolExecution ? undefined : await tryFastPathGet({
initialized: this._initialized,
setInitialized: (deferred) => {
this._initialized = deferred;
@@ -313,26 +351,13 @@ export class PipenvManager implements EnvironmentManager, Disposable {
getProjectFsPath: (s) => getProjectFsPathForScope(this.api, s),
getPersistedPath: (fsPath) => getPipenvForWorkspace(fsPath),
resolve: (p) => resolvePipenvPath(p, this.nativeFinder, this.api, this),
- startBackgroundInit: () =>
- withProgress(
- { location: ProgressLocation.Window, title: PipenvStrings.pipenvDiscovering },
- async () => {
- this.collection = (await refreshPipenv(false, this.nativeFinder, this.api, this)) ?? [];
- await this.loadEnvMap();
- this._onDidChangeEnvironments.fire(
- this.collection.map((e) => ({
- environment: e,
- kind: EnvironmentChangeKind.add,
- })),
- );
- },
- ),
+ startBackgroundInit: () => this.initializeDiscovery(),
});
if (fastResult) {
return fastResult.env;
}
- await this.initialize();
+ await (toolExecution ? this.initializeDiscovery() : this.initialize());
if (scope === undefined) {
return this.globalEnv;
@@ -348,8 +373,8 @@ export class PipenvManager implements EnvironmentManager, Disposable {
return undefined;
}
- async resolve(context: ResolveEnvironmentContext): Promise {
- await this.initialize();
+ async resolve(context: ResolveEnvironmentContext, toolExecution = false): Promise {
+ await (toolExecution ? this.initializeDiscovery() : this.initialize());
return resolvePipenvPath(context.fsPath, this.nativeFinder, this.api, this);
}
@@ -359,5 +384,7 @@ export class PipenvManager implements EnvironmentManager, Disposable {
this.fsPathToEnv.clear();
this.globalEnv = undefined;
this._initialized = undefined;
+ this.discovery = undefined;
+ this.initializationError = undefined;
}
}
diff --git a/src/managers/poetry/commands/add.ts b/src/managers/poetry/commands/add.ts
index 972c711e8..868b801ba 100644
--- a/src/managers/poetry/commands/add.ts
+++ b/src/managers/poetry/commands/add.ts
@@ -22,6 +22,12 @@ export class PoetryAddCommand extends InstallCommand {
}
async execute(executeArgs: InstallExecuteArgs): Promise {
- await runPoetry(this.buildCommand(executeArgs), this.cwd, this.log, executeArgs.cancellationToken);
+ await runPoetry(
+ this.buildCommand(executeArgs),
+ this.cwd,
+ this.log,
+ executeArgs.cancellationToken,
+ executeArgs.toolExecution,
+ );
}
}
diff --git a/src/managers/poetry/commands/remove.ts b/src/managers/poetry/commands/remove.ts
index 6d27e79e0..fcc529e1b 100644
--- a/src/managers/poetry/commands/remove.ts
+++ b/src/managers/poetry/commands/remove.ts
@@ -12,6 +12,12 @@ export class PoetryRemoveCommand extends UninstallCommand {
}
async execute(executeArgs: UninstallExecuteArgs): Promise {
- await runPoetry(this.buildCommand(executeArgs), this.cwd, this.log, executeArgs.cancellationToken);
+ await runPoetry(
+ this.buildCommand(executeArgs),
+ this.cwd,
+ this.log,
+ executeArgs.cancellationToken,
+ executeArgs.toolExecution,
+ );
}
}
diff --git a/src/managers/poetry/commands/runPoetry.ts b/src/managers/poetry/commands/runPoetry.ts
index 092fe0a7c..f9edb844b 100644
--- a/src/managers/poetry/commands/runPoetry.ts
+++ b/src/managers/poetry/commands/runPoetry.ts
@@ -1,5 +1,7 @@
import { CancellationError, CancellationToken, LogOutputChannel } from 'vscode';
import { spawnProcess } from '../../../common/childProcess.apis';
+import { runLoggedProcess } from '../../../common/utils/processRunner';
+import { throwIfCancelled } from '../../../internal/pythonToolSupport';
import { getPoetry } from '../poetryUtils';
export async function runPoetry(
@@ -7,12 +9,19 @@ export async function runPoetry(
cwd?: string,
log?: LogOutputChannel,
token?: CancellationToken,
+ toolExecution = false,
): Promise {
+ if (toolExecution) {
+ throwIfCancelled(token);
+ }
const poetry = await getPoetry();
if (!poetry) {
throw new Error('Poetry executable not found');
}
+ if (toolExecution) {
+ return runLoggedProcess(poetry, ['--no-interaction', ...args], { cwd }, log, token, 300_000, true);
+ }
log?.info(`Running: ${poetry} ${args.join(' ')}`);
return new Promise((resolve, reject) => {
diff --git a/src/managers/poetry/poetryManager.ts b/src/managers/poetry/poetryManager.ts
index 42854b8e7..b969ca47e 100644
--- a/src/managers/poetry/poetryManager.ts
+++ b/src/managers/poetry/poetryManager.ts
@@ -1,5 +1,5 @@
import * as path from 'path';
-import { Disposable, EventEmitter, MarkdownString, ProgressLocation, Uri, workspace } from 'vscode';
+import { Disposable, EventEmitter, MarkdownString, ProgressLocation, Uri } from 'vscode';
import {
DidChangeEnvironmentEventArgs,
DidChangeEnvironmentsEventArgs,
@@ -24,9 +24,15 @@ import { sendTelemetryEvent } from '../../common/telemetry/sender';
import { createDeferred, Deferred } from '../../common/utils/deferred';
import { normalizePath } from '../../common/utils/pathUtils';
import { withProgress } from '../../common/window.apis';
+import { getConfiguration } from '../../common/workspace.apis';
import type { PythonProjectManager } from '../../features/projectManager';
-import { NativePythonFinder } from '../common/nativePythonFinder';
+import { NativePythonEnvironmentKind, NativePythonFinder } from '../common/nativePythonFinder';
import { getLatest, notifyMissingManagerIfDefault } from '../common/utils';
+import {
+ EnvironmentToolSupport,
+ pythonToolSupport,
+ resolveToolProjectEnvironment,
+} from '../../internal/pythonToolSupport';
import {
clearPoetryCache,
getPoetry,
@@ -75,6 +81,24 @@ export class PoetryManager implements EnvironmentManager, Disposable {
}
private _initialized: Deferred | undefined;
+ private discovery: Deferred | undefined;
+ private initializationError: unknown;
+ readonly [pythonToolSupport]: EnvironmentToolSupport = {
+ initialize: async () => {
+ await this.initializeDiscovery();
+ if (this.initializationError) {
+ throw this.initializationError;
+ }
+ },
+ get: (scope) => this.get(scope, true),
+ getEnvironments: (scope) => this.getEnvironments(scope, true),
+ resolve: (scope) => this.resolve(scope, true),
+ resolveProject: (scope) =>
+ resolveToolProjectEnvironment(scope, NativePythonEnvironmentKind.poetry, this.nativeFinder, (uri) =>
+ this.resolve(uri, true),
+ ),
+ };
+
async initialize(): Promise {
if (this._initialized) {
return this._initialized.promise;
@@ -88,26 +112,13 @@ export class PoetryManager implements EnvironmentManager, Disposable {
try {
// Check if tool is findable before PET refresh (settings/cache/PATH only, no PET)
- const hasExplicitSetting = !!workspace.getConfiguration('python').get('poetryPath');
+ const hasExplicitSetting = !!getConfiguration('python').get('poetryPath');
const preRefreshTool = await getPoetry();
if (preRefreshTool) {
toolSource = hasExplicitSetting ? 'settings' : 'local';
}
- await withProgress(
- {
- location: ProgressLocation.Window,
- title: PoetryStrings.poetryDiscovering,
- },
- async () => {
- this.collection = (await refreshPoetry(false, this.nativeFinder, this.api, this)) ?? [];
- await this.loadEnvMap();
-
- this._onDidChangeEnvironments.fire(
- this.collection.map((e) => ({ environment: e, kind: EnvironmentChangeKind.add })),
- );
- },
- );
+ await this.initializeDiscovery();
envCount = this.collection.length;
@@ -139,8 +150,34 @@ export class PoetryManager implements EnvironmentManager, Disposable {
}
}
- async getEnvironments(scope: GetEnvironmentsScope): Promise {
- await this.initialize();
+ private async initializeDiscovery(): Promise {
+ if (this.discovery) {
+ return this.discovery.promise;
+ }
+ const discovery = (this.discovery = createDeferred());
+ try {
+ await withProgress(
+ { location: ProgressLocation.Window, title: PoetryStrings.poetryDiscovering },
+ async () => {
+ this.collection = (await refreshPoetry(false, this.nativeFinder, this.api, this)) ?? [];
+ await this.loadEnvMap();
+ this.initializationError = undefined;
+ this._onDidChangeEnvironments.fire(
+ this.collection.map((environment) => ({ environment, kind: EnvironmentChangeKind.add })),
+ );
+ },
+ );
+ } catch (error) {
+ this.initializationError = error;
+ this.discovery = undefined;
+ throw error;
+ } finally {
+ discovery.resolve();
+ }
+ }
+
+ async getEnvironments(scope: GetEnvironmentsScope, toolExecution = false): Promise {
+ await (toolExecution ? this.initializeDiscovery() : this.initialize());
if (scope === 'all') {
return Array.from(this.collection);
@@ -175,6 +212,7 @@ export class PoetryManager implements EnvironmentManager, Disposable {
this.collection = (await refreshPoetry(true, this.nativeFinder, this.api, this)) ?? [];
await this.loadEnvMap();
+ this.initializationError = undefined;
const args = [
...discard.map((env) => ({ kind: EnvironmentChangeKind.remove, environment: env })),
@@ -187,8 +225,8 @@ export class PoetryManager implements EnvironmentManager, Disposable {
}
}
- async get(scope: GetEnvironmentScope): Promise {
- await this.initialize();
+ async get(scope: GetEnvironmentScope, toolExecution = false): Promise {
+ await (toolExecution ? this.initializeDiscovery() : this.initialize());
if (scope instanceof Uri) {
let env = this.fsPathToEnv.get(normalizePath(scope.fsPath));
if (env) {
@@ -256,8 +294,8 @@ export class PoetryManager implements EnvironmentManager, Disposable {
}
}
- async resolve(context: ResolveEnvironmentContext): Promise {
- await this.initialize();
+ async resolve(context: ResolveEnvironmentContext, toolExecution = false): Promise