diff --git a/src/managers/base/commands/packageManagerCommand.ts b/src/managers/base/commands/packageManagerCommand.ts index 9bba7506c..fa2d5eb25 100644 --- a/src/managers/base/commands/packageManagerCommand.ts +++ b/src/managers/base/commands/packageManagerCommand.ts @@ -9,6 +9,7 @@ export interface BaseExecuteArgs { cancellationToken?: CancellationToken; strict?: boolean; toolExecution?: boolean; + env?: NodeJS.ProcessEnv; } /** diff --git a/src/managers/common/packageChanges.ts b/src/managers/common/packageChanges.ts index 70db6daec..ecf24ceaf 100644 --- a/src/managers/common/packageChanges.ts +++ b/src/managers/common/packageChanges.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +import { CancellationError } from 'vscode'; import { Package, PackageChangeKind, PackageManager, PythonEnvironment } from '../../api'; import { normalizePackageName } from './packageUtils'; @@ -10,6 +11,8 @@ import { normalizePackageName } from './packageUtils'; export type PackageChangesCallback = (changes: { kind: PackageChangeKind; pkg: Package }[]) => void; type PackageFetcher = () => Promise; +type DirectPackageNamesFetcher = () => Promise | undefined>; +type PackagesResolvedCallback = (packages: Package[]) => void; /** * Computes the list of package changes between a before and after snapshot. @@ -49,6 +52,10 @@ export function getPackageChanges(before: Package[], after: Package[]): { kind: * @param before The package snapshot from before the operation. * @param onChanges Callback invoked when package changes are detected. * @param fetchPackages Optional internal fetcher for operation-specific refresh behavior. + * @param skipDirectDependencies Whether to skip direct-dependency enrichment. + * @param fetchDirectPackageNames Optional internal fetcher for operation-specific direct dependencies. + * @param onPackagesResolved Optional callback invoked with enriched packages before change events. + * @returns The refreshed package list, or undefined when packages could not be fetched. */ export async function updatePackagesAndNotify( packageManager: PackageManager, @@ -57,11 +64,24 @@ export async function updatePackagesAndNotify( onChanges: PackageChangesCallback, fetchPackages?: PackageFetcher, skipDirectDependencies = false, + fetchDirectPackageNames?: DirectPackageNamesFetcher, + onPackagesResolved?: PackagesResolvedCallback, ): Promise { + const directPackageNames = skipDirectDependencies + ? undefined + : ( + fetchDirectPackageNames + ? fetchDirectPackageNames() + : packageManager.getDirectPackageNames?.(environment) + )?.catch((error) => { + if (error instanceof CancellationError) { + throw error; + } + return undefined; + }); const [after, afterDirectDependenciesNames] = await Promise.all([ fetchPackages?.() ?? packageManager.getPackages(environment, { skipCache: true }), - // Handle transitive dependencies (best-effort, don't break package refresh on failure) - skipDirectDependencies ? undefined : packageManager.getDirectPackageNames?.(environment).catch(() => undefined), + directPackageNames, ]); if (after === undefined) { @@ -76,6 +96,8 @@ export async function updatePackagesAndNotify( })) : after; + onPackagesResolved?.(enriched); + // Fire change event const changes = getPackageChanges(before ?? [], enriched); if (changes.length > 0) { diff --git a/src/managers/poetry/commands/add.ts b/src/managers/poetry/commands/add.ts index 868b801ba..c09057094 100644 --- a/src/managers/poetry/commands/add.ts +++ b/src/managers/poetry/commands/add.ts @@ -28,6 +28,7 @@ export class PoetryAddCommand extends InstallCommand { this.log, executeArgs.cancellationToken, executeArgs.toolExecution, + executeArgs.env, ); } } diff --git a/src/managers/poetry/commands/remove.ts b/src/managers/poetry/commands/remove.ts index fcc529e1b..98305904a 100644 --- a/src/managers/poetry/commands/remove.ts +++ b/src/managers/poetry/commands/remove.ts @@ -18,6 +18,7 @@ export class PoetryRemoveCommand extends UninstallCommand { this.log, executeArgs.cancellationToken, executeArgs.toolExecution, + executeArgs.env, ); } } diff --git a/src/managers/poetry/commands/runPoetry.ts b/src/managers/poetry/commands/runPoetry.ts index f9edb844b..ddcd60df3 100644 --- a/src/managers/poetry/commands/runPoetry.ts +++ b/src/managers/poetry/commands/runPoetry.ts @@ -1,4 +1,4 @@ -import { CancellationError, CancellationToken, LogOutputChannel } from 'vscode'; +import { CancellationError, CancellationToken, Disposable, l10n, LogOutputChannel } from 'vscode'; import { spawnProcess } from '../../../common/childProcess.apis'; import { runLoggedProcess } from '../../../common/utils/processRunner'; import { throwIfCancelled } from '../../../internal/pythonToolSupport'; @@ -10,25 +10,39 @@ export async function runPoetry( log?: LogOutputChannel, token?: CancellationToken, toolExecution = false, + env?: NodeJS.ProcessEnv, ): Promise { - if (toolExecution) { - throwIfCancelled(token); - } + throwIfCancelled(token); const poetry = await getPoetry(); if (!poetry) { - throw new Error('Poetry executable not found'); + throw new Error(l10n.t('Poetry executable not found.')); } + throwIfCancelled(token); if (toolExecution) { - return runLoggedProcess(poetry, ['--no-interaction', ...args], { cwd }, log, token, 300_000, true); + return runLoggedProcess(poetry, ['--no-interaction', ...args], { cwd, env }, log, token, 300_000); } log?.info(`Running: ${poetry} ${args.join(' ')}`); return new Promise((resolve, reject) => { - const proc = spawnProcess(poetry, args, { cwd }); - token?.onCancellationRequested(() => { + const proc = spawnProcess(poetry, args, { cwd, env }); + let settled = false; + let subscription: Disposable | undefined; + const finish = (error?: Error, output?: string) => { + if (settled) { + return; + } + settled = true; + subscription?.dispose(); + if (error) { + reject(error); + } else { + resolve(output ?? ''); + } + }; + subscription = token?.onCancellationRequested(() => { proc.kill(); - reject(new CancellationError()); + finish(new CancellationError()); }); let builder = ''; proc.stdout?.on('data', (data) => { @@ -38,19 +52,18 @@ export async function runPoetry( }); proc.stderr?.on('data', (data) => { const output = data.toString('utf-8'); - builder += output; log?.append(`poetry: ${output}`); }); proc.on('close', (code) => { if (code !== 0) { - reject(new Error(`Failed to run poetry ${args.join(' ')}`)); + finish(new Error(l10n.t('Failed to run Poetry command: {0}', args.join(' ')))); return; } - resolve(builder); + finish(undefined, builder); }); proc.on('error', (error) => { log?.error(`Error executing poetry command: ${error}`); - reject(error); + finish(error); }); }); } diff --git a/src/managers/poetry/commands/show.ts b/src/managers/poetry/commands/show.ts index 9370d9740..ada7dc305 100644 --- a/src/managers/poetry/commands/show.ts +++ b/src/managers/poetry/commands/show.ts @@ -13,7 +13,14 @@ export class PoetryShowCommand extends ListCommand { } async execute(executeArgs?: BaseExecuteArgs): Promise { - const output = await runPoetry(this.buildCommand(), this.cwd, this.log, executeArgs?.cancellationToken); + const output = await runPoetry( + this.buildCommand(), + this.cwd, + this.log, + executeArgs?.cancellationToken, + executeArgs?.toolExecution, + executeArgs?.env, + ); const packages: PackageInfo[] = []; try { // Parse poetry show output diff --git a/src/managers/poetry/commands/showTopLevel.ts b/src/managers/poetry/commands/showTopLevel.ts index 2289508e5..aa33e02a5 100644 --- a/src/managers/poetry/commands/showTopLevel.ts +++ b/src/managers/poetry/commands/showTopLevel.ts @@ -13,7 +13,14 @@ export class PoetryShowTopLevelCommand extends ListDirectNamesCommand { } async execute(executeArgs?: BaseExecuteArgs): Promise> { - const output = await runPoetry(this.buildCommand(), this.cwd, this.log, executeArgs?.cancellationToken); + const output = await runPoetry( + this.buildCommand(), + this.cwd, + this.log, + executeArgs?.cancellationToken, + executeArgs?.toolExecution, + executeArgs?.env, + ); try { const names = output diff --git a/src/managers/poetry/poetryPackageManager.ts b/src/managers/poetry/poetryPackageManager.ts index 7be4dad56..f5f4f5b9b 100644 --- a/src/managers/poetry/poetryPackageManager.ts +++ b/src/managers/poetry/poetryPackageManager.ts @@ -28,7 +28,6 @@ import { PythonProject, } from '../../api'; import { Capabilities, PackageCapability, PackageManagerCapability, Support } from '../../capabilities'; -import { normalizePath } from '../../common/utils/pathUtils'; import { showErrorMessage, showInputBox, withProgress } from '../../common/window.apis'; import * as workspaceFs from '../../common/workspace.fs.apis'; import { @@ -36,6 +35,7 @@ import { PythonToolError, pythonToolSupport, throwIfCancelled, + waitForToolRead, } from '../../internal/pythonToolSupport'; import { PackageManagerRequiresProjectError } from '../common/errors'; import { updatePackagesAndNotify } from '../common/packageChanges'; @@ -51,6 +51,43 @@ import { runPoetry } from './commands/runPoetry'; import { PoetryManager } from './poetryManager'; import { getPoetry } from './poetryUtils'; +const pendingPoetryEnvironmentMismatchNotifications = new Set(); + +interface PoetryTarget { + readonly canonical?: string; + readonly identity?: string; + readonly reported: string; +} + +interface VerifiedPoetryEnvironment { + readonly cacheKey: string; + readonly env: NodeJS.ProcessEnv; +} + +interface ResolvedEnvironment { + readonly canonical: string; + readonly identity: string; +} + +interface PoetryPackageManagerSharedState { + readonly environmentOperations: Map>; + readonly installedPackages: Map; +} + +function isPoetryEnvironmentMismatch(error: unknown): error is PythonToolError { + return error instanceof PythonToolError && error.code === 'ENVIRONMENT_MISMATCH'; +} + +async function resolveEnvironmentEntry(envPath: string): Promise { + const canonical = await fsapi.realpath(envPath); + const stat = await fsapi.stat(canonical, { bigint: true }); + const identity = + stat.ino !== BigInt(0) + ? `${stat.dev.toString()}:${stat.ino.toString()}` + : path.resolve(canonical); + return { canonical, identity }; +} + export class PoetryPackageManager implements PackageManager, Disposable { readonly capabilities: Capabilities = { [PackageCapability.List]: async () => this.checkProjectSupport(), @@ -77,13 +114,18 @@ export class PoetryPackageManager implements PackageManager, Disposable { private readonly packagesChangedEmitter = new EventEmitter(); readonly onDidChangePackages: Event = this.packagesChangedEmitter.event; - private packages: Map = new Map(); + private readonly packages = new Map(); constructor( private readonly api: PythonEnvironmentApi, public readonly log: LogOutputChannel, private readonly poetryManager: PoetryManager, private readonly project?: PythonProject, + private readonly sharedState: PoetryPackageManagerSharedState = { + environmentOperations: new Map(), + installedPackages: new Map(), + }, + private readonly ownsSharedState = true, ) { this.name = 'poetry'; this.displayName = 'Poetry'; @@ -104,49 +146,161 @@ export class PoetryPackageManager implements PackageManager, Disposable { * @returns A Poetry package manager scoped to the project. */ createForProject(project: PythonProject): PoetryPackageManager { - return new PoetryPackageManager(this.api, this.log, this.poetryManager, project); + return new PoetryPackageManager( + this.api, + this.log, + this.poetryManager, + project, + this.sharedState, + false, + ); } readonly [pythonToolSupport]: PackageToolSupport = { manage: async (environment, options, token, scope) => { - await this.verifyToolEnvironment(environment, scope, token); await this.manage(environment, { ...options, runHeadless: true }, token, scope); }, - getPackages: async (environment, token, scope) => { - await this.verifyToolEnvironment(environment, scope, token); - return this.fetchInstalledPackagesForTools(environment, token, scope); - }, + getPackages: (environment, token, scope) => + this.enqueueEnvironmentOperation(environment, token, async () => { + const verified = await this.verifyEnvironment(environment, scope.fsPath, token, true); + const packages = await this.fetchInstalledPackagesForTools(environment, token, scope, verified.env); + this.sharedState.installedPackages.set(verified.cacheKey, packages); + return packages; + }), }; - private async verifyToolEnvironment( + private async resolvePoetryTarget( + cwd: string, + token?: CancellationToken, + toolExecution = false, + env?: NodeJS.ProcessEnv, + ): Promise { + const output = await runPoetry( + ['--no-ansi', 'env', 'info', '--path'], + cwd, + this.log, + token, + toolExecution, + env, + ); + throwIfCancelled(token); + const reported = output.trim(); + const lines = reported.split(/\r?\n/).filter((line) => line.length > 0); + const target = lines.length === 1 ? lines[0] : undefined; + if (!target || !path.isAbsolute(target)) { + return { reported }; + } + try { + const resolved = await resolveEnvironmentEntry(target); + return { ...resolved, reported: target }; + } catch (error) { + this.log.error('Failed to resolve the Poetry target environment', error); + return { reported: target }; + } + } + + private async resolveSelectedEnvironment(environment: PythonEnvironment): Promise { + if (!environment.sysPrefix || !path.isAbsolute(environment.sysPrefix)) { + return undefined; + } + try { + return await resolveEnvironmentEntry(environment.sysPrefix); + } catch (error) { + this.log.error('Failed to resolve the selected Python environment', error); + return undefined; + } + } + + private async verifyEnvironment( environment: PythonEnvironment, - scope: Uri, - token: CancellationToken, - ): Promise { + cwd: string, + token?: CancellationToken, + toolExecution = false, + ): Promise { throwIfCancelled(token); - const target = (await runPoetry(['env', 'info', '--path'], scope.fsPath, this.log, token, true)).trim(); + const operationEnv: NodeJS.ProcessEnv = toolExecution ? {} : { ...process.env }; + const target = await this.resolvePoetryTarget(cwd, token, toolExecution, operationEnv); + const selected = await this.resolveSelectedEnvironment(environment); throwIfCancelled(token); - const actual = path.isAbsolute(target) ? await fsapi.realpath(target) : undefined; - const selected = environment.sysPrefix ? await fsapi.realpath(environment.sysPrefix) : undefined; - if (!actual || !selected || normalizePath(actual) !== normalizePath(selected)) { - throw new PythonToolError( - 'ENVIRONMENT_MISMATCH', - l10n.t( - "Poetry for {0} targets {1}, not the selected environment {2}. Select this project's Poetry environment before querying or installing packages.", - scope.fsPath, - target || l10n.t('no environment'), - environment.sysPrefix, - ), - environment, - ); + if (!target.canonical || !target.identity || !selected || target.identity !== selected.identity) { + this.deletePackageCaches(environment, selected?.identity); + throw this.createEnvironmentMismatch(environment, cwd, target.reported); + } + + const env = { ...operationEnv, VIRTUAL_ENV: target.canonical }; + const pinnedTarget = await this.resolvePoetryTarget(cwd, token, toolExecution, env); + throwIfCancelled(token); + if ( + !pinnedTarget.canonical || + !pinnedTarget.identity || + pinnedTarget.identity !== target.identity + ) { + this.deletePackageCaches(environment, selected.identity); + throw this.createEnvironmentMismatch(environment, cwd, pinnedTarget.reported); + } + + return { cacheKey: target.identity, env }; + } + + private createEnvironmentMismatch( + environment: PythonEnvironment, + cwd: string, + reportedTarget: string, + ): PythonToolError { + return new PythonToolError( + 'ENVIRONMENT_MISMATCH', + l10n.t( + "Poetry for {0} targets {1}, not the selected environment {2}. Select this project's Poetry environment or choose a compatible package manager before querying or changing packages.", + cwd, + reportedTarget || l10n.t('no environment'), + environment.sysPrefix || l10n.t('no environment'), + ), + environment, + ); + } + + private deletePackageCaches(environment: PythonEnvironment, installedCacheKey?: string): void { + this.packages.delete(environment.envId.id); + if (installedCacheKey) { + this.sharedState.installedPackages.delete(installedCacheKey); + } else { + this.sharedState.installedPackages.clear(); } + } + + private async enqueueEnvironmentOperation( + environment: PythonEnvironment, + token: CancellationToken | undefined, + operation: () => Promise, + ): Promise { throwIfCancelled(token); + const selected = await this.resolveSelectedEnvironment(environment); + throwIfCancelled(token); + const key = selected?.identity ?? `environment:${environment.envId.id}`; + const previous = this.sharedState.environmentOperations.get(key) ?? Promise.resolve(); + const waitForPrevious = token ? waitForToolRead(previous, token, null) : previous; + const result = waitForPrevious.then(async () => { + throwIfCancelled(token); + return operation(); + }); + const tail = previous.then( + () => result.then(() => undefined, () => undefined), + () => result.then(() => undefined, () => undefined), + ); + this.sharedState.environmentOperations.set(key, tail); + void tail.finally(() => { + if (this.sharedState.environmentOperations.get(key) === tail) { + this.sharedState.environmentOperations.delete(key); + } + }); + return result; } private async fetchInstalledPackagesForTools( environment: PythonEnvironment, token: CancellationToken, scope: Uri, + env: NodeJS.ProcessEnv, ): Promise { // poetry show describes the lockfile, not the installed distributions. const output = await runPoetry( @@ -155,6 +309,7 @@ export class PoetryPackageManager implements PackageManager, Disposable { this.log, token, true, + env, ); throwIfCancelled(token); const data: unknown = JSON.parse(output); @@ -197,12 +352,17 @@ export class PoetryPackageManager implements PackageManager, Disposable { if (toInstall.length === 0 && toUninstall.length === 0) { if (options.runHeadless) { // Headless mode: skip the interactive package input prompt. + if (toolToken) { + await this.enqueueEnvironmentOperation(environment, toolToken, async () => { + await this.verifyEnvironment(environment, cwd, toolToken, true); + }); + } return; } // Show package input UI if no packages are specified const installInput = await showInputBox({ - prompt: 'Enter packages to install (comma separated)', - placeHolder: 'e.g., requests, pytest, black', + prompt: l10n.t('Enter packages to install (comma separated)'), + placeHolder: l10n.t('e.g., requests, pytest, black'), }); if (installInput) { @@ -217,20 +377,37 @@ export class PoetryPackageManager implements PackageManager, Disposable { } } - const execute = async (token?: CancellationToken): Promise => { + const execute = (token?: CancellationToken): Promise => + this.enqueueEnvironmentOperation(environment, token, async () => { try { - await this.runPoetryManage({ install: toInstall, uninstall: toUninstall }, cwd, token, !!toolToken); - await updatePackagesAndNotify( - this, + const toolExecution = toolToken !== undefined; + const verified = await this.verifyEnvironment(environment, cwd, token, toolExecution); + const cache = toolExecution ? this.sharedState.installedPackages : this.packages; + const cacheKey = toolExecution ? verified.cacheKey : environment.envId.id; + const before = cache.get(cacheKey); + this.deletePackageCaches(environment, verified.cacheKey); + await this.runPoetryManage( + { install: toInstall, uninstall: toUninstall }, + cwd, + verified.env, + token, + toolExecution, + ); + await this.updatePackagesForVerifiedEnvironment( environment, - this.packages.get(environment.envId.id), - (changes) => { - this.packagesChangedEmitter.fire({ environment, manager: this, changes }); + cwd, + verified.env, + { + before, + cacheKey, + token, + toolExecution, + fetchPackages: + toolToken && scope + ? () => this.fetchInstalledPackagesForTools(environment, toolToken, scope, verified.env) + : undefined, + skipDirectDependencies: toolExecution, }, - toolToken && scope - ? () => this.fetchInstalledPackagesForTools(environment, toolToken, scope) - : undefined, - !!toolToken, ); } catch (e) { if (e instanceof CancellationError) { @@ -238,16 +415,11 @@ export class PoetryPackageManager implements PackageManager, Disposable { } this.log.error('Error managing packages with Poetry', e); if (!options.runHeadless) { - setImmediate(async () => { - const result = await showErrorMessage('Error managing packages with Poetry', 'View Output'); - if (result === 'View Output') { - this.log.show(); - } - }); + this.notifyPackageError(e, l10n.t('Error managing packages with Poetry')); } throw e; } - }; + }); if (options.runHeadless) { await execute(toolToken); @@ -257,7 +429,7 @@ export class PoetryPackageManager implements PackageManager, Disposable { await withProgress( { location: ProgressLocation.Notification, - title: 'Managing packages with Poetry', + title: l10n.t('Managing packages with Poetry'), cancellable: true, }, (_progress, token) => execute(token), @@ -271,43 +443,44 @@ export class PoetryPackageManager implements PackageManager, Disposable { await withProgress( { location: ProgressLocation.Window, - title: 'Refreshing Poetry packages', + title: l10n.t('Refreshing Poetry packages'), }, - async () => { + (_progress, token) => this.enqueueEnvironmentOperation(environment, token, async () => { try { - const packages = await updatePackagesAndNotify( - this, - environment, - this.packages.get(environment.envId.id), - (changes) => { - this.packagesChangedEmitter.fire({ environment, manager: this, changes }); - }, - ); - this.packages.set(environment.envId.id, packages ?? []); + const cwd = await this.getProjectCwd(); + const verified = await this.verifyEnvironment(environment, cwd, token); + await this.updatePackagesForVerifiedEnvironment(environment, cwd, verified.env, { + cacheKey: environment.envId.id, + token, + }); } catch (error) { + if (error instanceof CancellationError) { + throw error; + } this.log.error(`Failed to refresh packages: ${error}`); - // Show error to user but don't break the UI - setImmediate(async () => { - const result = await showErrorMessage('Error refreshing Poetry packages', 'View Output'); - if (result === 'View Output') { - this.log.show(); - } - }); + if (isPoetryEnvironmentMismatch(error)) { + throw error; + } + this.notifyPackageError(error, l10n.t('Error refreshing Poetry packages')); } - }, + }), ); } - async getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise { + getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise { if (!this.project) { - return undefined; + return Promise.resolve(undefined); } - if (options?.skipCache || !this.packages.has(environment.envId.id)) { - const packages = await this.fetchPackagesFromTool(environment); - this.packages.set(environment.envId.id, packages); - return packages; - } - return this.packages.get(environment.envId.id); + return this.enqueueEnvironmentOperation(environment, undefined, async () => { + const cwd = await this.getProjectCwd(); + const verified = await this.verifyEnvironment(environment, cwd); + if (options?.skipCache || !this.packages.has(environment.envId.id)) { + const packages = await this.fetchPackagesFromTool(environment, cwd, verified.env); + this.packages.set(environment.envId.id, packages); + return packages; + } + return this.packages.get(environment.envId.id); + }); } async getVersion(_environment: PythonEnvironment): Promise { @@ -336,17 +509,20 @@ export class PoetryPackageManager implements PackageManager, Disposable { dispose(): void { this.packagesChangedEmitter.dispose(); this.packages.clear(); + if (this.ownsSharedState) { + this.sharedState.installedPackages.clear(); + this.sharedState.environmentOperations.clear(); + } } private async runPoetryManage( options: { install?: string[]; uninstall?: string[] }, cwd: string, + env: NodeJS.ProcessEnv, token?: CancellationToken, toolExecution = false, ): Promise { - if (toolExecution) { - throwIfCancelled(token); - } + throwIfCancelled(token); const poetry = await getPoetry(); if (!poetry) { throw new Error( @@ -357,32 +533,85 @@ export class PoetryPackageManager implements PackageManager, Disposable { } // Handle uninstalls first if (options.uninstall && options.uninstall.length > 0) { + throwIfCancelled(token); const removeCmd = new PoetryRemoveCommand({ pythonExecutable: poetry, cwd, log: this.log, }); const packages = parsePackageSpecs(options.uninstall); - await removeCmd.execute({ packages, cancellationToken: token, toolExecution }); + await removeCmd.execute({ packages, cancellationToken: token, toolExecution, env }); } // Handle installs if (options.install && options.install.length > 0) { + throwIfCancelled(token); const addCmd = new PoetryAddCommand({ pythonExecutable: poetry, cwd, log: this.log, }); const packages = parsePackageSpecs(options.install); - await addCmd.execute({ packages, cancellationToken: token, toolExecution }); + await addCmd.execute({ packages, cancellationToken: token, toolExecution, env }); if (toolExecution) { // poetry add skips already-declared dependencies, even when they are not installed. - await runPoetry(['install', '--no-root'], cwd, this.log, token, true); + await runPoetry(['install', '--no-root'], cwd, this.log, token, true, env); } } } - private async fetchPackagesFromTool(environment: PythonEnvironment): Promise { + private updatePackagesForVerifiedEnvironment( + environment: PythonEnvironment, + cwd: string, + env: NodeJS.ProcessEnv, + options: { + before?: Package[]; + cacheKey?: string; + token?: CancellationToken; + toolExecution?: boolean; + fetchPackages?: () => Promise; + skipDirectDependencies?: boolean; + } = {}, + ): Promise { + const cache = options.skipDirectDependencies ? this.sharedState.installedPackages : this.packages; + const cacheKey = options.cacheKey ?? environment.envId.id; + return updatePackagesAndNotify( + this, + environment, + options.before ?? cache.get(cacheKey), + (changes) => { + this.packagesChangedEmitter.fire({ environment, manager: this, changes }); + }, + options.fetchPackages ?? + (() => + this.fetchPackagesFromTool( + environment, + cwd, + env, + options.token, + options.toolExecution, + )), + options.skipDirectDependencies, + options.skipDirectDependencies + ? undefined + : () => + this.fetchDirectPackageNames( + cwd, + env, + options.token, + options.toolExecution, + ), + (packages) => cache.set(cacheKey, packages), + ); + } + + private async fetchPackagesFromTool( + environment: PythonEnvironment, + cwd: string, + env: NodeJS.ProcessEnv, + token?: CancellationToken, + toolExecution = false, + ): Promise { const poetry = await getPoetry(); if (!poetry) { throw new Error( @@ -392,25 +621,40 @@ export class PoetryPackageManager implements PackageManager, Disposable { ); } - const cwd = await this.getProjectCwd(); const showCmd = new PoetryShowCommand({ pythonExecutable: poetry, cwd, log: this.log, }); try { - const data = await showCmd.execute(); + const data = await showCmd.execute({ cancellationToken: token, toolExecution, env }); return data.map((pkg) => this.api.createPackageItem(pkg, environment, this)); } catch (error) { + if (error instanceof CancellationError) { + throw error; + } this.log.error(`Error refreshing packages with Poetry: ${error}`); return []; } } - async getDirectPackageNames(_environment: PythonEnvironment): Promise | undefined> { + getDirectPackageNames(environment: PythonEnvironment): Promise | undefined> { if (!this.project) { - return undefined; + return Promise.resolve(undefined); } + return this.enqueueEnvironmentOperation(environment, undefined, async () => { + const cwd = await this.getProjectCwd(); + const verified = await this.verifyEnvironment(environment, cwd); + return this.fetchDirectPackageNames(cwd, verified.env); + }); + } + + private async fetchDirectPackageNames( + cwd: string, + env: NodeJS.ProcessEnv, + token?: CancellationToken, + toolExecution = false, + ): Promise | undefined> { try { const poetry = await getPoetry(); if (!poetry) { @@ -418,16 +662,45 @@ export class PoetryPackageManager implements PackageManager, Disposable { } const showTopLevelCmd = new PoetryShowTopLevelCommand({ pythonExecutable: poetry, - cwd: await this.getProjectCwd(), + cwd, log: this.log, }); - return await showTopLevelCmd.execute(); + return await showTopLevelCmd.execute({ cancellationToken: token, toolExecution, env }); } catch (err) { + if (err instanceof CancellationError) { + throw err; + } this.log.error(`Error fetching direct package names with Poetry: ${err}`); return undefined; } } + private notifyPackageError(error: unknown, fallbackMessage: string): void { + const mismatch = isPoetryEnvironmentMismatch(error); + let notificationKey: string | undefined; + if (mismatch) { + const projectKey = this.project?.uri.toString() ?? 'global'; + notificationKey = `${projectKey}\0${error.message}`; + if (pendingPoetryEnvironmentMismatchNotifications.has(notificationKey)) { + return; + } + pendingPoetryEnvironmentMismatchNotifications.add(notificationKey); + } + setImmediate(async () => { + try { + const viewOutput = l10n.t('View Output'); + const result = await showErrorMessage(mismatch ? error.message : fallbackMessage, viewOutput); + if (result === viewOutput) { + this.log.show(); + } + } finally { + if (notificationKey) { + pendingPoetryEnvironmentMismatchNotifications.delete(notificationKey); + } + } + }); + } + private async getProjectCwd(): Promise { if (!this.project) { throw new PackageManagerRequiresProjectError(); diff --git a/src/test/internal/publicApiCompatibility.unit.test.ts b/src/test/internal/publicApiCompatibility.unit.test.ts index 59992dd54..8b9875ee4 100644 --- a/src/test/internal/publicApiCompatibility.unit.test.ts +++ b/src/test/internal/publicApiCompatibility.unit.test.ts @@ -172,13 +172,28 @@ suite('Public package API execution policy', () => { test(`${kind}: public progress tokens do not enable agent process policy`, async () => { await publicApi(manager()).managePackages(environment, { install: ['requests'] }); assert.ok(progress.calledOnce); - assert.strictEqual(calls.length, 1); - assert.strictEqual(calls[0].options.timeout, undefined); - assert.strictEqual(calls[0].options.env, undefined); - assert.strictEqual(calls[0].options.detached, undefined); - assert.ok(calls[0].endInput.notCalled); - assert.ok(!calls[0].args.includes('--no-interaction')); + assert.strictEqual(calls.length, kind === 'poetry' ? 3 : 1); + for (const call of calls) { + assert.strictEqual(call.options.timeout, undefined); + assert.strictEqual(call.options.detached, undefined); + assert.ok(call.endInput.notCalled); + assert.ok(!call.args.includes('--no-interaction')); + } assert.ok(!timers.getCalls().some((call) => call.args[1] === 300_000)); + if (kind === 'poetry') { + assert.deepStrictEqual(calls[0].args, ['--no-ansi', 'env', 'info', '--path']); + assert.deepStrictEqual(calls[1].args, ['--no-ansi', 'env', 'info', '--path']); + assert.strictEqual(calls[0].options.env?.VIRTUAL_ENV, process.env.VIRTUAL_ENV); + assert.strictEqual(calls[1].options.env?.VIRTUAL_ENV, environment.sysPrefix); + assert.strictEqual(calls[2].options.env?.VIRTUAL_ENV, environment.sysPrefix); + assert.strictEqual(calls[2].args[0], 'add'); + for (const call of calls) { + assert.strictEqual(call.options.env?.PIP_NO_INPUT, process.env.PIP_NO_INPUT); + assert.strictEqual(call.options.env?.UV_NO_PROGRESS, process.env.UV_NO_PROGRESS); + } + } else { + assert.strictEqual(calls[0].options.env, undefined); + } if (kind === 'uv') { assert.strictEqual(calls[0].executable, path.join(temp, '.pyprojectx', 'main', 'uv')); } diff --git a/src/test/internal/pythonToolManagers.unit.test.ts b/src/test/internal/pythonToolManagers.unit.test.ts index 2ca80abbb..1c5471d65 100644 --- a/src/test/internal/pythonToolManagers.unit.test.ts +++ b/src/test/internal/pythonToolManagers.unit.test.ts @@ -214,17 +214,21 @@ suite('Python tool manager capabilities', () => { const show = sinon.stub(PoetryShowCommand.prototype, 'execute').resolves([]); const target = sinon .stub(poetryRunner, 'runPoetry') - .callsFake(async (args) => (args[0] === 'env' ? environment.sysPrefix : '[]')); + .callsFake(async (args) => (args.includes('env') ? environment.sysPrefix : '[]')); await poetry[pythonToolSupport].manage(environment, { install: ['requests'] }, source.token, scope); - assert.deepStrictEqual(target.firstCall.args[0], ['env', 'info', '--path']); + assert.deepStrictEqual(target.firstCall.args[0], ['--no-ansi', 'env', 'info', '--path']); assert.strictEqual(target.firstCall.args[1], scope.fsPath); assert.strictEqual(target.firstCall.args[3], source.token); assert.strictEqual(target.firstCall.args[4], true); + assert.deepStrictEqual(target.firstCall.args[5], {}); + assert.deepStrictEqual(target.secondCall.args[0], ['--no-ansi', 'env', 'info', '--path']); + assert.strictEqual(target.secondCall.args[5]?.VIRTUAL_ENV, environment.sysPrefix); assert.strictEqual(add.firstCall.args[0].cancellationToken, source.token); assert.strictEqual(add.firstCall.args[0].toolExecution, true); + assert.strictEqual(add.firstCall.args[0].env?.VIRTUAL_ENV, environment.sysPrefix); assert.strictEqual(Reflect.get(add.firstCall.thisValue, 'cwd'), scope.fsPath); - assert.deepStrictEqual(target.secondCall.args[0], ['install', '--no-root']); - assert.deepStrictEqual(target.thirdCall.args[0], [ + assert.deepStrictEqual(target.thirdCall.args[0], ['install', '--no-root']); + assert.deepStrictEqual(target.getCall(3).args[0], [ 'run', 'pip', 'list', @@ -250,7 +254,7 @@ suite('Python tool manager capabilities', () => { let output = '[{"name":"installed-only","version":"1.0.0"}]'; const runner = sinon .stub(poetryRunner, 'runPoetry') - .callsFake(async (args) => (args[0] === 'env' ? environment.sysPrefix : output)); + .callsFake(async (args) => (args.includes('env') ? environment.sysPrefix : output)); sinon.stub(poetryUtils, 'getPoetry').resolves(path.join(temp, 'poetry')); const show = sinon .stub(PoetryShowCommand.prototype, 'execute') @@ -261,7 +265,7 @@ suite('Python tool manager capabilities', () => { ['installed-only'], ); assert.ok(show.notCalled); - assert.ok(runner.secondCall.args[0].includes('--disable-pip-version-check')); + assert.ok(runner.thirdCall.args[0].includes('--disable-pip-version-check')); sinon.stub(workspaceFs, 'stat').resolves({ type: FileType.Directory, ctime: 0, mtime: 0, size: 0 }); const scopedPoetry = poetry.createForProject({ name: 'project', uri: scope }); const publicPackages = await scopedPoetry.getPackages(environment, { skipCache: true }); diff --git a/src/test/managers/common/packageChanges.unit.test.ts b/src/test/managers/common/packageChanges.unit.test.ts index 8f6f77402..74808b458 100644 --- a/src/test/managers/common/packageChanges.unit.test.ts +++ b/src/test/managers/common/packageChanges.unit.test.ts @@ -3,6 +3,7 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; +import { CancellationError } from 'vscode'; import { Package, PackageChangeKind, PackageManager, PythonEnvironment } from '../../../api'; import { getPackageChanges, updatePackagesAndNotify } from '../../../managers/common/packageChanges'; @@ -145,6 +146,88 @@ suite('packageChanges', () => { assert.ok(getPackagesStub.notCalled); }); + test('uses an operation-specific direct-package fetcher when provided', async () => { + const fetched = [ + { name: 'requests', version: '2.31.0' } as Package, + { name: 'urllib3', version: '2.0.0' } as Package, + ]; + const fetchPackages = sinon.stub().resolves(fetched); + const fetchDirectPackageNames = sinon.stub().resolves(new Set(['requests'])); + const managerDirectPackageNames = sinon.stub().resolves(new Set(['urllib3'])); + packageManager.getDirectPackageNames = managerDirectPackageNames; + + const result = await updatePackagesAndNotify( + packageManager, + environment, + undefined, + sinon.stub(), + fetchPackages, + false, + fetchDirectPackageNames, + ); + + assert.ok(fetchDirectPackageNames.calledOnce); + assert.ok(managerDirectPackageNames.notCalled); + assert.strictEqual(result?.[0].isTransitive, false); + assert.strictEqual(result?.[1].isTransitive, true); + }); + + test('stores enriched packages before firing change events', async () => { + const fetched = [ + { name: 'requests', version: '2.31.0' } as Package, + { name: 'urllib3', version: '2.0.0' } as Package, + ]; + const order: string[] = []; + const resolvedPackages: Package[][] = []; + const onChanges = sinon.stub().callsFake(() => order.push('changes')); + const onPackagesResolved = sinon.stub().callsFake((packages: Package[]) => { + order.push('resolved'); + resolvedPackages.push(packages); + }); + + await updatePackagesAndNotify( + packageManager, + environment, + undefined, + onChanges, + sinon.stub().resolves(fetched), + false, + sinon.stub().resolves(new Set(['requests'])), + onPackagesResolved, + ); + + assert.deepStrictEqual(order, ['resolved', 'changes']); + assert.deepStrictEqual( + resolvedPackages[0].map((pkg) => ({ name: pkg.name, isTransitive: pkg.isTransitive })), + [ + { name: 'requests', isTransitive: false }, + { name: 'urllib3', isTransitive: true }, + ], + ); + }); + + test('propagates direct-package cancellation before caching or firing changes', async () => { + const onChanges = sinon.stub(); + const onPackagesResolved = sinon.stub(); + + await assert.rejects( + updatePackagesAndNotify( + packageManager, + environment, + undefined, + onChanges, + sinon.stub().resolves([{ name: 'requests', version: '2.31.0' } as Package]), + false, + sinon.stub().rejects(new CancellationError()), + onPackagesResolved, + ), + CancellationError, + ); + + assert.ok(onPackagesResolved.notCalled); + assert.ok(onChanges.notCalled); + }); + test('preserves undefined and does not report removals when fetching fails', async () => { const before = [{ name: 'requests', version: '2.31.0' } as Package]; getPackagesStub.resolves(undefined); diff --git a/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts b/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts index 46eaf7f5c..b4b1ffcb2 100644 --- a/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts +++ b/src/test/managers/common/packageManagerHeadlessConformance.unit.test.ts @@ -28,6 +28,7 @@ import { CondaListCommand } from '../../../managers/conda/commands/list'; import { CondaPackageManager } from '../../../managers/conda/condaPackageManager'; import * as condaUtils from '../../../managers/conda/condaUtils'; import { PoetryAddCommand } from '../../../managers/poetry/commands/add'; +import * as runPoetryModule from '../../../managers/poetry/commands/runPoetry'; import { PoetryManager } from '../../../managers/poetry/poetryManager'; import { PoetryPackageManager } from '../../../managers/poetry/poetryPackageManager'; import * as poetryUtils from '../../../managers/poetry/poetryUtils'; @@ -38,6 +39,7 @@ suite('Package manager headless conformance', () => { envId: { id: 'test-environment', managerId: 'test-manager' }, environmentPath: Uri.joinPath(Uri.file(__dirname), 'path', 'to', 'environment'), execInfo: { run: { executable: 'python', args: [] } }, + sysPrefix: process.cwd(), version: '3.12.0', } as unknown as PythonEnvironment; @@ -75,6 +77,7 @@ suite('Package manager headless conformance', () => { sinon.stub(PipInstallCommand.prototype, 'execute').rejects(operationError); sinon.stub(CondaInstallCommand.prototype, 'execute').rejects(operationError); sinon.stub(PoetryAddCommand.prototype, 'execute').rejects(operationError); + sinon.stub(runPoetryModule, 'runPoetry').resolves(environment.sysPrefix); sinon.stub(poetryUtils, 'getPoetry').resolves('poetry'); const showErrorMessage = sinon.stub(windowApis, 'showErrorMessage').resolves(undefined); const showErrorMessageWithLogs = sinon.stub(errorUtils, 'showErrorMessageWithLogs').resolves(); @@ -134,15 +137,17 @@ suite('Package manager headless conformance', () => { assert.ok(showErrorMessageWithLogs.notCalled); }); - test('suppresses Poetry refresh failures without showing progress or error notifications', async () => { + test('suppresses public Poetry refresh failures without showing progress or error notifications', async () => { const refreshError = new Error('package refresh failed'); const withProgress = sinon.stub(windowApis, 'withProgress'); sinon.stub(poetryUtils, 'getPoetry').resolves('poetry'); - sinon.stub(PoetryPackageManager.prototype, 'getDirectPackageNames').resolves(undefined); const spawnProcess = sinon.stub(childProcessApis, 'spawnProcess').callsFake((_command, args) => { const process = new MockChildProcess('poetry', args); setImmediate(() => { - if (args[0] === 'add') { + if (args.includes('env')) { + process.stdout?.emit('data', Buffer.from(environment.sysPrefix)); + process.emit('close', 0, null); + } else if (args[0] === 'add') { process.emit('close', 0, null); } else { process.emit('error', refreshError); @@ -154,10 +159,12 @@ suite('Package manager headless conformance', () => { const showErrorMessageWithLogs = sinon.stub(errorUtils, 'showErrorMessageWithLogs').resolves(); const manager = createManagers().poetry; - await manager.manage(environment, { install: ['requests'], runHeadless: true }); + await assert.doesNotReject( + manager.manage(environment, { install: ['requests'], runHeadless: true }), + ); await flushImmediate(); - assert.strictEqual(spawnProcess.callCount, 2); + assert.strictEqual(spawnProcess.callCount, 5); assert.ok(withProgress.notCalled); assert.ok(showErrorMessage.notCalled); assert.ok(showErrorMessageWithLogs.notCalled); diff --git a/src/test/managers/poetry/commands.unit.test.ts b/src/test/managers/poetry/commands.unit.test.ts index fd5c33a5f..29087fc2b 100644 --- a/src/test/managers/poetry/commands.unit.test.ts +++ b/src/test/managers/poetry/commands.unit.test.ts @@ -1,6 +1,7 @@ import assert from 'assert'; import * as sinon from 'sinon'; -import { LogOutputChannel, Uri } from 'vscode'; +import { CancellationTokenSource, LogOutputChannel, Uri } from 'vscode'; +import * as childProcessApis from '../../../common/childProcess.apis'; import * as workspaceApis from '../../../common/workspace.apis'; import { PoetryAddCommand } from '../../../managers/poetry/commands/add'; import { PoetryRemoveCommand } from '../../../managers/poetry/commands/remove'; @@ -10,6 +11,7 @@ import { PoetryShowTopLevelCommand } from '../../../managers/poetry/commands/sho import { PoetryVersionCommand } from '../../../managers/poetry/commands/version'; import * as poetryUtils from '../../../managers/poetry/poetryUtils'; import { createMockLogOutputChannel } from '../../mocks/helper'; +import { MockChildProcess } from '../../mocks/mockChildProcess'; suite('Poetry commands', () => { let cwd: string; @@ -31,11 +33,13 @@ suite('Poetry commands', () => { }); test('PoetryAddCommand builds versioned arguments and preserves cwd', async () => { + const env = { VIRTUAL_ENV: Uri.file('environment').fsPath }; const command = new PoetryAddCommand({ pythonExecutable: 'poetry', cwd, log: mockLog }); - await command.execute({ packages: [{ packageName: 'requests', version: '2.32.0' }] }); + await command.execute({ packages: [{ packageName: 'requests', version: '2.32.0' }], env }); assert.deepStrictEqual(runPoetryStub.firstCall.args.slice(0, 2), [['add', 'requests@2.32.0'], cwd]); + assert.strictEqual(runPoetryStub.firstCall.args[5], env); }); test('PoetryRemoveCommand builds arguments and preserves cwd', async () => { @@ -72,6 +76,63 @@ suite('Poetry commands', () => { assert.deepStrictEqual([...result], ['flask-thing', 'requests']); }); + test('runPoetry returns stdout without mixing warning stderr into paths or JSON', async () => { + runPoetryStub.restore(); + sinon.stub(poetryUtils, 'getPoetry').resolves('poetry'); + const spawnProcess = sinon.stub(childProcessApis, 'spawnProcess').callsFake((_command, args) => { + const process = new MockChildProcess('poetry', args); + setImmediate(() => { + process.stdout?.emit('data', Buffer.from(`${cwd}\n`)); + process.stderr?.emit('data', Buffer.from('A benign Poetry warning\n')); + process.emit('close', 0, null); + }); + return process as unknown as ReturnType; + }); + + const output = await poetryRunner.runPoetry(['--no-ansi', 'env', 'info', '--path'], cwd, mockLog); + + assert.strictEqual(output, `${cwd}\n`); + assert.ok(spawnProcess.calledOnce); + assert.ok((mockLog.append as sinon.SinonStub).calledWith('poetry: A benign Poetry warning\n')); + }); + + test('runPoetry tool execution returns stdout without mixing warning stderr', async () => { + runPoetryStub.restore(); + sinon.stub(poetryUtils, 'getPoetry').resolves('poetry'); + const source = new CancellationTokenSource(); + const spawnProcess = sinon.stub(childProcessApis, 'spawnProcess').callsFake((_command, args) => { + const process = new MockChildProcess('poetry', args); + setImmediate(() => { + process.stdout?.emit('data', Buffer.from('{"name":"requests"}\n')); + process.stderr?.emit('data', Buffer.from('A benign Poetry warning\n')); + process.emit('close', 0, null); + }); + return process as unknown as ReturnType; + }); + + try { + const output = await poetryRunner.runPoetry( + ['run', 'pip', 'list', '--format=json'], + cwd, + mockLog, + source.token, + true, + ); + + assert.strictEqual(output, '{"name":"requests"}\n'); + assert.deepStrictEqual(spawnProcess.firstCall.args[1], [ + '--no-interaction', + 'run', + 'pip', + 'list', + '--format=json', + ]); + assert.ok((mockLog.append as sinon.SinonStub).calledWith('A benign Poetry warning\n')); + } finally { + source.dispose(); + } + }); + test('PoetryVersionCommand parses the configured Poetry version', async () => { const command = new PoetryVersionCommand({ pythonExecutable: 'poetry', log: mockLog }); diff --git a/src/test/managers/poetry/poetryPackageManager.unit.test.ts b/src/test/managers/poetry/poetryPackageManager.unit.test.ts index a22062cfb..dae3c9803 100644 --- a/src/test/managers/poetry/poetryPackageManager.unit.test.ts +++ b/src/test/managers/poetry/poetryPackageManager.unit.test.ts @@ -2,34 +2,102 @@ // Licensed under the MIT License. import assert from 'assert'; +import * as fs from 'fs-extra'; +import * as os from 'os'; import * as path from 'path'; import * as sinon from 'sinon'; -import { FileType, LogOutputChannel, Uri } from 'vscode'; -import { PythonEnvironmentApi } from '../../../api'; +import { + CancellationError, + CancellationTokenSource, + FileType, + LogOutputChannel, + Uri, +} from 'vscode'; +import { + DidChangePackagesEventArgs, + Package, + PackageChangeKind, + PythonEnvironment, + PythonEnvironmentApi, +} from '../../../api'; import * as windowApis from '../../../common/window.apis'; import * as workspaceFs from '../../../common/workspace.fs.apis'; +import { PythonToolError, pythonToolSupport } from '../../../internal/pythonToolSupport'; import { PackageManagerRequiresProjectError } from '../../../managers/common/errors'; import * as packageChanges from '../../../managers/common/packageChanges'; import * as runPoetryModule from '../../../managers/poetry/commands/runPoetry'; +import { PoetryAddCommand } from '../../../managers/poetry/commands/add'; import { PoetryPackageManager } from '../../../managers/poetry/poetryPackageManager'; import { PoetryManager } from '../../../managers/poetry/poetryManager'; import * as poetryUtils from '../../../managers/poetry/poetryUtils'; import { createMockPythonEnvironment } from '../../mocks/pythonEnvironment'; +interface PoetryPackageManagerInternals { + packages: Map; + sharedState: { + environmentOperations: Map>; + installedPackages: Map; + }; +} + suite('PoetryPackageManager', () => { - const environment = createMockPythonEnvironment({ - envPath: path.join(process.cwd(), '.venv'), - managerId: 'ms-python.python:poetry', - }); + let environment: PythonEnvironment; + let environmentPath: string; let logError: sinon.SinonStub; let manager: PoetryPackageManager; + let mismatchPath: string; + let packageChangesStub: sinon.SinonStub; + let posixBackslashPath: string; + let posixSlashPath: string; + let progressSource: CancellationTokenSource; let projectManager: PoetryPackageManager; + let projectRoot: string; + let projectUri: Uri; let runPoetryStub: sinon.SinonStub; let statStub: sinon.SinonStub; - const projectUri = Uri.file(path.join(process.cwd(), 'project', 'pyproject.toml')); + let symlinkPath: string; + let temp: string; + + suiteSetup(async () => { + temp = await fs.mkdtemp(path.join(os.tmpdir(), 'poetry-package-manager-')); + environmentPath = path.join(temp, 'selected environment'); + mismatchPath = path.join(temp, 'different environment'); + posixBackslashPath = path.join(temp, 'env\\prod'); + posixSlashPath = path.join(temp, 'env', 'prod'); + symlinkPath = path.join(temp, 'selected environment link'); + projectRoot = path.join(temp, 'project with spaces'); + projectUri = Uri.file(path.join(projectRoot, 'pyproject.toml')); + await Promise.all([ + fs.ensureDir(environmentPath), + fs.ensureDir(mismatchPath), + fs.ensureDir(projectRoot), + ...(process.platform === 'win32' + ? [] + : [fs.ensureDir(posixBackslashPath), fs.ensureDir(posixSlashPath)]), + ]); + await fs.symlink(environmentPath, symlinkPath, process.platform === 'win32' ? 'junction' : 'dir'); + }); + + suiteTeardown(async () => { + await fs.remove(temp); + }); setup(() => { - const api = {} as PythonEnvironmentApi; + progressSource = new CancellationTokenSource(); + environment = createMockPythonEnvironment({ + envPath: environmentPath, + managerId: 'ms-python.python:poetry', + }); + const api = { + createPackageItem: (pkg: Package, target: PythonEnvironment) => ({ + ...pkg, + pkgId: { + id: pkg.name, + managerId: 'ms-python.python:poetry', + environmentId: target.envId.id, + }, + }), + } as unknown as PythonEnvironmentApi; logError = sinon.stub(); const log = { append: sinon.stub(), @@ -39,9 +107,13 @@ suite('PoetryPackageManager', () => { } as unknown as LogOutputChannel; sinon.stub(poetryUtils, 'getPoetry').resolves('poetry'); - sinon.stub(windowApis, 'withProgress').callsFake((_options, task) => task({} as never, {} as never)); - sinon.stub(packageChanges, 'updatePackagesAndNotify').resolves([]); - runPoetryStub = sinon.stub(runPoetryModule, 'runPoetry').resolves(''); + sinon + .stub(windowApis, 'withProgress') + .callsFake((_options, task) => task({} as never, progressSource.token)); + packageChangesStub = sinon.stub(packageChanges, 'updatePackagesAndNotify').resolves([]); + runPoetryStub = sinon + .stub(runPoetryModule, 'runPoetry') + .callsFake(async (args: string[]) => (args.includes('env') ? environmentPath : '')); statStub = sinon.stub(workspaceFs, 'stat'); statStub.resolves({ type: FileType.File, ctime: 0, mtime: 0, size: 0 }); manager = new PoetryPackageManager(api, log, {} as PoetryManager); @@ -49,73 +121,652 @@ suite('PoetryPackageManager', () => { }); teardown(() => { + progressSource.dispose(); + projectManager.dispose(); manager.dispose(); sinon.restore(); }); - test('package management uses the project working directory', async () => { + test('package management verifies, pins, and uses the project working directory', async () => { + const expectedCwd = path.dirname(projectUri.fsPath); await projectManager.manage(environment, { install: ['requests'], uninstall: ['flask'] }); - assert.strictEqual(runPoetryStub.callCount, 2); - assert.strictEqual(runPoetryStub.firstCall.args[1], path.dirname(projectUri.fsPath)); - assert.strictEqual(runPoetryStub.secondCall.args[1], path.dirname(projectUri.fsPath)); + assert.strictEqual(runPoetryStub.callCount, 4); + assert.deepStrictEqual(runPoetryStub.firstCall.args[0], ['--no-ansi', 'env', 'info', '--path']); + assert.deepStrictEqual(runPoetryStub.secondCall.args[0], ['--no-ansi', 'env', 'info', '--path']); + assert.deepStrictEqual(runPoetryStub.thirdCall.args[0], ['remove', 'flask']); + assert.deepStrictEqual(runPoetryStub.getCall(3).args[0], ['add', 'requests']); + for (const call of runPoetryStub.getCalls()) { + assert.strictEqual(call.args[1], expectedCwd); + } + for (const call of runPoetryStub.getCalls().slice(1)) { + assert.strictEqual(call.args[5].VIRTUAL_ENV, environmentPath); + } }); - test('direct package listing uses the project working directory', async () => { + test('direct package listing verifies, pins, and uses the project working directory', async () => { + const expectedCwd = path.dirname(projectUri.fsPath); await projectManager.getDirectPackageNames(environment); - assert.strictEqual(runPoetryStub.callCount, 1); - assert.strictEqual(runPoetryStub.firstCall.args[1], path.dirname(projectUri.fsPath)); + assert.strictEqual(runPoetryStub.callCount, 3); + assert.deepStrictEqual(runPoetryStub.firstCall.args[0], ['--no-ansi', 'env', 'info', '--path']); + assert.deepStrictEqual(runPoetryStub.secondCall.args[0], ['--no-ansi', 'env', 'info', '--path']); + assert.deepStrictEqual(runPoetryStub.thirdCall.args[0], ['show', '--no-ansi', '--top-level']); + assert.ok(runPoetryStub.getCalls().every((call) => call.args[1] === expectedCwd)); + assert.strictEqual(runPoetryStub.thirdCall.args[5].VIRTUAL_ENV, environmentPath); }); test('directory project URIs are used directly as the working directory', async () => { - const directoryUri = Uri.file(process.cwd()); + const directoryUri = Uri.file(projectRoot); statStub.withArgs(directoryUri).resolves({ type: FileType.Directory, ctime: 0, mtime: 0, size: 0 }); const directoryManager = manager.createForProject({ name: 'directory-project', uri: directoryUri }); - await directoryManager.getDirectPackageNames(environment); - - assert.strictEqual(runPoetryStub.callCount, 1); - assert.strictEqual(runPoetryStub.firstCall.args[1], directoryUri.fsPath); + try { + await directoryManager.getDirectPackageNames(environment); + assert.ok(runPoetryStub.getCalls().every((call) => call.args[1] === directoryUri.fsPath)); + } finally { + directoryManager.dispose(); + } }); test('symlinked directory project URIs are used directly as the working directory', async () => { - const symlinkedDirectoryUri = Uri.file(path.join(process.cwd(), 'symlinked-project')); + const symlinkedProjectUri = Uri.file(path.join(temp, 'symlinked project')); statStub - .withArgs(symlinkedDirectoryUri) + .withArgs(symlinkedProjectUri) .resolves({ type: FileType.Directory | FileType.SymbolicLink, ctime: 0, mtime: 0, size: 0 }); - const symlinkedDirectoryManager = manager.createForProject({ + const symlinkedProjectManager = manager.createForProject({ name: 'symlinked-directory-project', - uri: symlinkedDirectoryUri, + uri: symlinkedProjectUri, }); - await symlinkedDirectoryManager.getDirectPackageNames(environment); - - assert.strictEqual(runPoetryStub.callCount, 1); - assert.strictEqual(runPoetryStub.firstCall.args[1], symlinkedDirectoryUri.fsPath); + try { + await symlinkedProjectManager.getDirectPackageNames(environment); + assert.ok(runPoetryStub.getCalls().every((call) => call.args[1] === symlinkedProjectUri.fsPath)); + } finally { + symlinkedProjectManager.dispose(); + } }); test('inaccessible projects reject instead of falling back to the parent directory', async () => { - const inaccessibleUri = Uri.file(path.join(process.cwd(), 'missing-project')); + const inaccessibleUri = Uri.file(path.join(temp, 'missing project')); statStub.withArgs(inaccessibleUri).rejects(new Error('access denied')); const inaccessibleManager = manager.createForProject({ name: 'missing', uri: inaccessibleUri }); + try { + await assert.rejects( + inaccessibleManager.manage(environment, { install: ['requests'] }), + /Unable to access the Python project/, + ); + assert.strictEqual(runPoetryStub.callCount, 0); + } finally { + inaccessibleManager.dispose(); + } + }); + + test('package loading preserves best-effort public behavior when poetry show fails', async () => { + const showError = new Error('poetry show failed'); + runPoetryStub.callsFake(async (args: string[]) => { + if (args.includes('env')) { + return environmentPath; + } + throw showError; + }); + + assert.deepStrictEqual(await projectManager.getPackages(environment, { skipCache: true }), []); + assert.ok(logError.calledOnceWithExactly(`Error refreshing packages with Poetry: ${showError}`)); + }); + + test('rejects mismatched public mutations, reads, and direct-package queries', async () => { + const add = sinon.stub(PoetryAddCommand.prototype, 'execute').resolves(); + const showErrorMessage = sinon.stub(windowApis, 'showErrorMessage').resolves(undefined); + runPoetryStub.resolves(mismatchPath); + const isMismatch = (error: unknown) => + error instanceof PythonToolError && + error.code === 'ENVIRONMENT_MISMATCH' && + error.message.includes('choose a compatible package manager'); + + await assert.rejects(projectManager.manage(environment, { install: ['requests'] }), isMismatch); + await assert.rejects(projectManager.getPackages(environment, { skipCache: true }), isMismatch); + await assert.rejects(projectManager.getDirectPackageNames(environment), isMismatch); + await flushImmediate(); + + assert.strictEqual(runPoetryStub.callCount, 3, 'Each public operation should stop at its target probe'); + assert.ok(add.notCalled, 'No Poetry mutation should run after a target mismatch'); + assert.ok(packageChangesStub.notCalled, 'A mismatch must not refresh or emit package changes'); + assert.ok(showErrorMessage.calledOnce); + assert.ok( + showErrorMessage.calledWith( + sinon.match(/targets .*not the selected environment.*compatible package manager/i), + sinon.match.string, + ), + ); + + runPoetryStub.callsFake(async (args: string[]) => (args.includes('env') ? environmentPath : '')); + await projectManager.getPackages(environment, { skipCache: true }); + }); + + test('refresh-first mismatch rejects once without also scheduling a manager notification', async () => { + const showErrorMessage = sinon.stub(windowApis, 'showErrorMessage').resolves(undefined); + runPoetryStub.resolves(mismatchPath); + + await assert.rejects(projectManager.refresh(environment), isEnvironmentMismatch); + await flushImmediate(); + + assert.ok(packageChangesStub.notCalled); + assert.ok(showErrorMessage.notCalled); + }); + + test('deduplicates concurrent mismatch notifications but not a later explicit retry', async () => { + let resolveFirstNotification!: () => void; + const firstNotification = new Promise((resolve) => { + resolveFirstNotification = resolve; + }); + const showErrorMessage = sinon + .stub(windowApis, 'showErrorMessage') + .onFirstCall() + .returns(firstNotification as never) + .resolves(undefined); + runPoetryStub.resolves(mismatchPath); + + await Promise.all([ + assert.rejects(projectManager.manage(environment, { install: ['requests'] }), isEnvironmentMismatch), + assert.rejects(projectManager.manage(environment, { install: ['flask'] }), isEnvironmentMismatch), + ]); + await flushImmediate(); + assert.strictEqual(showErrorMessage.callCount, 1); + + resolveFirstNotification(); + await flushImmediate(); await assert.rejects( - inaccessibleManager.manage(environment, { install: ['requests'] }), - /Unable to access the Python project/, + projectManager.manage(environment, { install: ['httpx'] }), + isEnvironmentMismatch, ); + await flushImmediate(); + assert.strictEqual(showErrorMessage.callCount, 2); + }); - assert.strictEqual(runPoetryStub.callCount, 0); + test('rejects before mutation when a pinned target is not honored', async () => { + const add = sinon.stub(PoetryAddCommand.prototype, 'execute').resolves(); + let targetProbe = 0; + runPoetryStub.callsFake(async (args: string[]) => { + if (args.includes('env')) { + targetProbe += 1; + return targetProbe === 1 ? environmentPath : mismatchPath; + } + return ''; + }); + + await assert.rejects( + projectManager.manage(environment, { install: ['requests'], runHeadless: true }), + isEnvironmentMismatch, + ); + + assert.strictEqual(targetProbe, 2); + assert.ok(add.notCalled); + assert.ok(packageChangesStub.notCalled); }); - test('package loading returns an empty list when poetry show fails', async () => { - const showError = new Error('poetry show failed'); - runPoetryStub.rejects(showError); + test('private tool manage and package queries reject mismatches without notifications', async () => { + const source = new CancellationTokenSource(); + const add = sinon.stub(PoetryAddCommand.prototype, 'execute').resolves(); + const showErrorMessage = sinon.stub(windowApis, 'showErrorMessage').resolves(undefined); + runPoetryStub.resolves(mismatchPath); - const packages = await projectManager.getPackages(environment, { skipCache: true }); + try { + await assert.rejects( + projectManager[pythonToolSupport].manage( + environment, + { install: ['requests'] }, + source.token, + Uri.file(projectRoot), + ), + isEnvironmentMismatch, + ); + await assert.rejects( + projectManager[pythonToolSupport].getPackages( + environment, + source.token, + Uri.file(projectRoot), + ), + isEnvironmentMismatch, + ); + } finally { + source.dispose(); + } + await flushImmediate(); - assert.deepStrictEqual(packages, []); - assert.ok(logError.calledOnceWithExactly(`Error refreshing packages with Poetry: ${showError}`)); + assert.strictEqual(runPoetryStub.callCount, 2); + assert.ok(add.notCalled); + assert.ok(packageChangesStub.notCalled); + assert.ok(showErrorMessage.notCalled); + }); + + test('matched package changes update the enriched cache before exact-attribution events', async () => { + packageChangesStub.resetBehavior(); + packageChangesStub.callThrough(); + let showOutput = 'old-package 1.0.0 Existing package'; + runPoetryStub.callsFake(async (args: string[]) => { + if (args.includes('env')) { + return environmentPath; + } + if (args.includes('--top-level')) { + return 'old-package'; + } + if (args[0] === 'show') { + return showOutput; + } + return ''; + }); + const before = await projectManager.getPackages(environment, { skipCache: true }); + assert.deepStrictEqual(before?.map((pkg) => pkg.name), ['old-package']); + showOutput = [ + 'old-package 1.0.0 Existing package', + 'new-package 2.0.0 Newly installed package', + ].join('\n'); + const events: DidChangePackagesEventArgs[] = []; + const cachedSnapshots: Promise[] = []; + const subscription = projectManager.onDidChangePackages!((event) => { + events.push(event); + cachedSnapshots.push(projectManager.getPackages(environment)); + }); + + try { + await projectManager.manage(environment, { install: ['new-package'] }); + const snapshots = await Promise.all(cachedSnapshots); + assert.deepStrictEqual( + snapshots[0]?.map((pkg) => ({ name: pkg.name, isTransitive: pkg.isTransitive })), + [ + { name: 'old-package', isTransitive: false }, + { name: 'new-package', isTransitive: true }, + ], + ); + await projectManager.manage(environment, { install: ['new-package'] }); + } finally { + subscription.dispose(); + } + + assert.strictEqual(events.length, 1, 'An unchanged second refresh must not repeat the first delta'); + assert.strictEqual(events[0].environment, environment); + assert.strictEqual(events[0].manager, projectManager); + assert.deepStrictEqual( + events[0].changes.map((change) => ({ + kind: change.kind, + name: change.pkg.name, + isTransitive: change.pkg.isTransitive, + })), + [{ kind: PackageChangeKind.add, name: 'new-package', isTransitive: true }], + ); + }); + + test('private tool inventory and events use a separate exact-environment cache', async () => { + packageChangesStub.resetBehavior(); + packageChangesStub.callThrough(); + const source = new CancellationTokenSource(); + const publicPackages = 'declared-package 9.0.0 Public lockfile package'; + let installed = [{ name: 'old-package', version: '1.0.0' }]; + runPoetryStub.callsFake(async (args: string[]) => { + if (args.includes('env')) { + return environmentPath; + } + if (args.includes('list')) { + return JSON.stringify(installed); + } + if (args[0] === 'show') { + return publicPackages; + } + return ''; + }); + assert.deepStrictEqual( + (await projectManager.getPackages(environment, { skipCache: true }))?.map((pkg) => pkg.name), + ['declared-package'], + ); + await projectManager[pythonToolSupport].getPackages(environment, source.token, Uri.file(projectRoot)); + installed = [ + ...installed, + { name: 'new-package', version: '2.0.0' }, + ]; + const events: DidChangePackagesEventArgs[] = []; + const subscription = projectManager.onDidChangePackages!((event) => events.push(event)); + + try { + await projectManager[pythonToolSupport].manage( + environment, + { install: ['new-package'] }, + source.token, + Uri.file(projectRoot), + ); + } finally { + subscription.dispose(); + source.dispose(); + } + + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0].environment, environment); + assert.strictEqual(events[0].manager, projectManager); + assert.deepStrictEqual( + events[0].changes.map((change) => change.pkg.name), + ['new-package'], + ); + assert.deepStrictEqual( + (await projectManager.getPackages(environment))?.map((pkg) => pkg.name), + ['declared-package'], + 'Private installed-distribution refreshes must not overwrite the public lockfile cache', + ); + assert.strictEqual( + runPoetryStub.getCalls().filter((call) => call.args[0][0] === 'show').length, + 2, + 'The public inventory must be refetched after a private mutation invalidates both caches', + ); + }); + + test('shares private inventory baselines across project-scoped managers for one environment', async () => { + packageChangesStub.resetBehavior(); + packageChangesStub.callThrough(); + const secondProjectRoot = path.join(temp, 'second project'); + await fs.ensureDir(secondProjectRoot); + const secondManager = manager.createForProject({ + name: 'second-project', + uri: Uri.file(path.join(secondProjectRoot, 'pyproject.toml')), + }); + const source = new CancellationTokenSource(); + let installed = [{ name: 'old-package', version: '1.0.0' }]; + runPoetryStub.callsFake(async (args: string[]) => { + if (args.includes('env')) { + return environmentPath; + } + if (args.includes('list')) { + return JSON.stringify(installed); + } + return ''; + }); + await projectManager[pythonToolSupport].getPackages(environment, source.token, Uri.file(projectRoot)); + installed = [...installed, { name: 'new-package', version: '2.0.0' }]; + const events: DidChangePackagesEventArgs[] = []; + const subscription = secondManager.onDidChangePackages!((event) => events.push(event)); + + try { + await secondManager[pythonToolSupport].manage( + environment, + { install: ['new-package'] }, + source.token, + Uri.file(secondProjectRoot), + ); + } finally { + subscription.dispose(); + source.dispose(); + secondManager.dispose(); + } + + assert.strictEqual(events.length, 1); + assert.deepStrictEqual(events[0].changes.map((change) => change.pkg.name), ['new-package']); + }); + + test('cancels a queued cross-project mutation without running it after the active read', async () => { + const secondProjectRoot = path.join(temp, 'queued second project'); + await fs.ensureDir(secondProjectRoot); + const secondManager = manager.createForProject({ + name: 'queued-second-project', + uri: Uri.file(path.join(secondProjectRoot, 'pyproject.toml')), + }); + const source = new CancellationTokenSource(); + let resolveRead!: (value: string) => void; + const blockedRead = new Promise((resolve) => { + resolveRead = resolve; + }); + runPoetryStub.callsFake(async (args: string[]) => { + if (args.includes('env')) { + return environmentPath; + } + if (args[0] === 'show') { + return blockedRead; + } + return ''; + }); + const add = sinon.stub(PoetryAddCommand.prototype, 'execute').resolves(); + const pendingRead = projectManager.getPackages(environment, { skipCache: true }); + await flushImmediate(); + const pendingMutation = secondManager[pythonToolSupport].manage( + environment, + { install: ['requests'] }, + source.token, + Uri.file(secondProjectRoot), + ); + source.cancel(); + + try { + await assert.rejects(pendingMutation, CancellationError); + assert.ok(add.notCalled); + const laterMutation = secondManager.manage(environment, { install: ['flask'] }); + await flushImmediate(); + assert.ok(add.notCalled, 'Later work must remain queued behind the original read'); + resolveRead('old-package 1.0.0 Existing package'); + await pendingRead; + await laterMutation; + assert.ok(add.calledOnce, 'Only the non-canceled later mutation should run'); + assert.deepStrictEqual(add.firstCall.args[0].packages, [{ packageName: 'flask' }]); + } finally { + resolveRead('old-package 1.0.0 Existing package'); + source.dispose(); + secondManager.dispose(); + } + }); + + test('invalidates both inventories before a public mutation and keeps failed refreshes non-stale', async () => { + packageChangesStub.resetBehavior(); + packageChangesStub.callThrough(); + const internals = projectManager as unknown as PoetryPackageManagerInternals; + const installedCacheKey = await environmentIdentity(environmentPath); + internals.packages.set(environment.envId.id, [{ name: 'stale-public', version: '1.0' } as Package]); + internals.sharedState.installedPackages.set(installedCacheKey, [ + { name: 'stale-private', version: '1.0' } as Package, + ]); + runPoetryStub.callsFake(async (args: string[]) => { + if (args.includes('env')) { + return environmentPath; + } + if (args[0] === 'show') { + throw new Error('transient poetry show failure'); + } + return ''; + }); + + await projectManager.manage(environment, { install: ['requests'] }); + + assert.deepStrictEqual(internals.packages.get(environment.envId.id), []); + assert.strictEqual(internals.sharedState.installedPackages.has(installedCacheKey), false); + }); + + test('serializes an older read ahead of mutation so it cannot repopulate stale cache', async () => { + packageChangesStub.resetBehavior(); + packageChangesStub.callThrough(); + let resolveOldRead!: (value: string) => void; + const oldRead = new Promise((resolve) => { + resolveOldRead = resolve; + }); + let showCall = 0; + runPoetryStub.callsFake(async (args: string[]) => { + if (args.includes('env')) { + return environmentPath; + } + if (args[0] === 'show') { + showCall += 1; + return showCall === 1 ? oldRead : 'new-package 2.0.0 Replacement package'; + } + return ''; + }); + const add = sinon.stub(PoetryAddCommand.prototype, 'execute').resolves(); + + const pendingRead = projectManager.getPackages(environment, { skipCache: true }); + await flushImmediate(); + const pendingMutation = projectManager.manage(environment, { install: ['new-package'] }); + await flushImmediate(); + assert.ok(add.notCalled, 'Mutation must wait for the in-flight read of the same environment'); + + resolveOldRead('old-package 1.0.0 Existing package'); + await pendingRead; + await pendingMutation; + + assert.ok(add.calledOnce); + assert.deepStrictEqual( + (await projectManager.getPackages(environment))?.map((pkg) => pkg.name), + ['new-package'], + ); + }); + + test('invalidates a cached inventory when the Poetry target changes', async () => { + let target = environmentPath; + let showOutput = 'old-package 1.0.0 Existing package'; + runPoetryStub.callsFake(async (args: string[]) => { + if (args.includes('env')) { + return target; + } + if (args[0] === 'show') { + return showOutput; + } + return ''; + }); + assert.deepStrictEqual( + (await projectManager.getPackages(environment, { skipCache: true }))?.map((pkg) => pkg.name), + ['old-package'], + ); + + target = mismatchPath; + await assert.rejects(projectManager.getPackages(environment), isEnvironmentMismatch); + + target = environmentPath; + showOutput = 'new-package 2.0.0 Replacement package'; + assert.deepStrictEqual( + (await projectManager.getPackages(environment))?.map((pkg) => pkg.name), + ['new-package'], + 'A mismatched cached read must discard the prior inventory', + ); + assert.strictEqual( + runPoetryStub.getCalls().filter((call) => call.args[0][0] === 'show').length, + 2, + ); + }); + + test('accepts symlink aliases and Windows casing differences for the same environment', async () => { + const aliasedEnvironment = { + ...environment, + sysPrefix: process.platform === 'win32' ? symlinkPath.toUpperCase() : symlinkPath, + }; + + await projectManager.manage(aliasedEnvironment, { install: ['requests'], runHeadless: true }); + + assert.strictEqual(runPoetryStub.getCall(1).args[5].VIRTUAL_ENV, environmentPath); + assert.deepStrictEqual(runPoetryStub.getCall(2).args[0], ['add', 'requests']); + }); + + test('does not equate POSIX backslashes with path separators', async function () { + if (process.platform === 'win32') { + this.skip(); + } + const backslashEnvironment = { + ...environment, + sysPrefix: posixBackslashPath, + }; + runPoetryStub.resolves(posixSlashPath); + + await assert.rejects( + projectManager.manage(backslashEnvironment, { install: ['requests'], runHeadless: true }), + isEnvironmentMismatch, + ); + }); + + test('reports missing target and selected paths as actionable mismatches', async () => { + const missingTarget = path.join(temp, 'missing target'); + runPoetryStub.resolves(missingTarget); + await assert.rejects( + projectManager.getPackages(environment), + (error: unknown) => + isEnvironmentMismatch(error) && + error.message.includes(missingTarget) && + error.message.includes(environmentPath), + ); + + runPoetryStub.resolves(environmentPath); + const missingSelected = { + ...environment, + sysPrefix: path.join(temp, 'missing selected environment'), + }; + await assert.rejects( + projectManager.getPackages(missingSelected), + (error: unknown) => + isEnvironmentMismatch(error) && + error.message.includes(missingSelected.sysPrefix), + ); + assert.ok(logError.called); + }); + + test('cancelling package input performs no target probe, mutation, refresh, or event', async () => { + const showInputBox = sinon.stub(windowApis, 'showInputBox').resolves(undefined); + const event = sinon.stub(); + const subscription = projectManager.onDidChangePackages!(event); + + try { + await projectManager.manage(environment, { install: [], uninstall: [] }); + } finally { + subscription.dispose(); + } + + assert.ok(showInputBox.calledOnce); + assert.ok(runPoetryStub.notCalled); + assert.ok(packageChangesStub.notCalled); + assert.ok(event.notCalled); + }); + + test('cancels before target verification when progress is already cancelled', async () => { + const source = new CancellationTokenSource(); + source.cancel(); + (windowApis.withProgress as sinon.SinonStub).callsFake((_options, task) => task({} as never, source.token)); + const showErrorMessage = sinon.stub(windowApis, 'showErrorMessage').resolves(undefined); + try { + await assert.rejects( + projectManager.manage(environment, { install: ['requests'] }), + (error: unknown) => error instanceof CancellationError, + ); + } finally { + source.dispose(); + } + await flushImmediate(); + + assert.ok(runPoetryStub.notCalled); + assert.ok(packageChangesStub.notCalled); + assert.ok(showErrorMessage.notCalled); + }); + + test('cancels after target verification but before mutation without a generic error', async () => { + const source = new CancellationTokenSource(); + const add = sinon.stub(PoetryAddCommand.prototype, 'execute').resolves(); + const showErrorMessage = sinon.stub(windowApis, 'showErrorMessage').resolves(undefined); + let targetProbe = 0; + (windowApis.withProgress as sinon.SinonStub).callsFake((_options, task) => task({} as never, source.token)); + runPoetryStub.callsFake(async (args: string[]) => { + if (args.includes('env')) { + targetProbe += 1; + if (targetProbe === 2) { + source.cancel(); + } + return environmentPath; + } + return ''; + }); + + try { + await assert.rejects( + projectManager.manage(environment, { install: ['requests'] }), + (error: unknown) => error instanceof CancellationError, + ); + } finally { + source.dispose(); + } + await flushImmediate(); + + assert.strictEqual(targetProbe, 2); + assert.ok(add.notCalled); + assert.ok(packageChangesStub.notCalled); + assert.ok(showErrorMessage.notCalled); }); test('project-sensitive reads are unavailable without a project', async () => { @@ -149,4 +800,20 @@ suite('PoetryPackageManager', () => { await assert.rejects(manager.refresh(environment), PackageManagerRequiresProjectError); assert.strictEqual(runPoetryStub.callCount, 0); }); + + function isEnvironmentMismatch(error: unknown): error is PythonToolError { + return error instanceof PythonToolError && error.code === 'ENVIRONMENT_MISMATCH'; + } + + async function flushImmediate(): Promise { + await new Promise((resolve) => setImmediate(resolve)); + } + + async function environmentIdentity(envPath: string): Promise { + const canonical = await fs.realpath(envPath); + const stat = await fs.stat(canonical, { bigint: true }); + return stat.ino !== BigInt(0) + ? `${stat.dev.toString()}:${stat.ino.toString()}` + : path.resolve(canonical); + } });