diff --git a/src/common/localize.ts b/src/common/localize.ts index c18e7340..cbf5b20c 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -198,6 +198,22 @@ export namespace PoetryStrings { export const poetryManager = l10n.t('Manages Poetry environments'); export const poetryDiscovering = l10n.t('Discovering Poetry environments'); export const poetryRefreshing = l10n.t('Refreshing Poetry environments'); + export namespace create { + export const description = l10n.t('Create a Poetry environment for the current project'); + export const progress = (path: string) => l10n.t('Creating Poetry environment for {0}', path); + export const singleProject = l10n.t('Poetry environments can only be created for one project at a time.'); + export const noPyproject = (path: string) => l10n.t('No pyproject.toml was found in {0}.', path); + export const noPython = l10n.t('No usable global Python 3 environment was found.'); + export const missingPath = l10n.t('Poetry did not report the path of the created environment.'); + export const resolveFailed = (path: string) => l10n.t('The Poetry environment at {0} could not be resolved.', path); + } + export namespace remove { + export const progress = (path: string) => l10n.t('Removing Poetry environment at {0}', path); + export const noProject = (path: string) => + l10n.t('The Poetry project associated with the environment at {0} could not be determined.', path); + export const noExecutable = (path: string) => + l10n.t('The Python executable for the Poetry environment at {0} could not be determined.', path); + } } export namespace ProjectCreatorString { diff --git a/src/managers/poetry/main.ts b/src/managers/poetry/main.ts index 685ba28c..67957095 100644 --- a/src/managers/poetry/main.ts +++ b/src/managers/poetry/main.ts @@ -17,7 +17,7 @@ export async function registerPoetryFeatures( const api: PythonEnvironmentApi = await getPythonApi(); traceInfo('Registering poetry manager (environments will be discovered lazily)'); - const envManager = new PoetryManager(nativeFinder, api, projectManager); + const envManager = new PoetryManager(nativeFinder, api, outputChannel, projectManager); const pkgManager = new PoetryPackageManager(api, outputChannel, envManager); disposables.push( diff --git a/src/managers/poetry/poetryManager.ts b/src/managers/poetry/poetryManager.ts index 31a35e10..3274297c 100644 --- a/src/managers/poetry/poetryManager.ts +++ b/src/managers/poetry/poetryManager.ts @@ -1,6 +1,19 @@ import * as path from 'path'; -import { Disposable, EventEmitter, MarkdownString, ProgressLocation, Uri, workspace } from 'vscode'; +import * as fs from 'fs-extra'; import { + CancellationError, + CancellationToken, + Disposable, + EventEmitter, + LogOutputChannel, + MarkdownString, + ProgressLocation, + Uri, + workspace, +} from 'vscode'; +import { + CreateEnvironmentOptions, + CreateEnvironmentScope, DidChangeEnvironmentEventArgs, DidChangeEnvironmentsEventArgs, EnvironmentChangeKind, @@ -11,6 +24,7 @@ import { PythonEnvironment, PythonEnvironmentApi, PythonProject, + QuickCreateConfig, RefreshEnvironmentsScope, ResolveEnvironmentContext, SetEnvironmentScope, @@ -24,9 +38,11 @@ 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 { findParentIfFile } from '../../features/envCommands'; import { PythonProjectManager } from '../../internal.api'; import { NativePythonFinder } from '../common/nativePythonFinder'; import { getLatest, notifyMissingManagerIfDefault } from '../common/utils'; +import { runPoetry } from './commands/runPoetry'; import { clearPoetryCache, getPoetry, @@ -54,6 +70,7 @@ export class PoetryManager implements EnvironmentManager, Disposable { constructor( private readonly nativeFinder: NativePythonFinder, private readonly api: PythonEnvironmentApi, + public readonly log: LogOutputChannel, private readonly projectManager?: PythonProjectManager, ) { this.name = 'poetry'; @@ -69,6 +86,145 @@ export class PoetryManager implements EnvironmentManager, Disposable { tooltip: string | MarkdownString; iconPath?: IconPath; + /** + * Returns the configuration used to offer Poetry as a quick-create option. + */ + public quickCreateConfig(): QuickCreateConfig { + return { + description: PoetryStrings.create.description, + }; + } + + /** + * Creates and selects a Poetry environment for a single existing Python project. + */ + public async create( + scope: CreateEnvironmentScope, + options: CreateEnvironmentOptions = {}, + ): Promise { + await this.initialize(); + const projectRoot = await this.getCreateProjectRoot(scope); + const pyprojectPath = path.join(projectRoot.fsPath, 'pyproject.toml'); + if (!(await fs.pathExists(pyprojectPath))) { + throw new Error(PoetryStrings.create.noPyproject(projectRoot.fsPath)); + } + + const baseEnvironment = await this.getBaseEnvironment(); + const pythonExecutable = baseEnvironment.execInfo?.run?.executable; + if (!pythonExecutable) { + throw new Error(PoetryStrings.create.noPython); + } + + return withProgress( + { + location: ProgressLocation.Notification, + title: PoetryStrings.create.progress(projectRoot.fsPath), + }, + async (_, token) => { + await runPoetry(['--no-ansi', 'env', 'use', pythonExecutable], projectRoot.fsPath, this.log, token); + const result = await runPoetry( + ['--no-ansi', 'env', 'info', '--path'], + projectRoot.fsPath, + this.log, + token, + ); + const environmentPath = this.parseEnvironmentPath(result); + const resolvedEnvironment = await resolvePoetryPath(environmentPath, this.nativeFinder, this.api, this); + if (!resolvedEnvironment) { + throw new Error(PoetryStrings.create.resolveFailed(environmentPath)); + } + + const existingEnvironment = this.collection.find((item) => + this.sameEnvironment(item, resolvedEnvironment), + ); + const environment = existingEnvironment ?? resolvedEnvironment; + const previousEnvironment = this.fsPathToEnv.get(normalizePath(projectRoot.fsPath)); + await setPoetryForWorkspace(projectRoot.fsPath, environment.environmentPath.fsPath); + if (!existingEnvironment) { + this.collection.push(environment); + } + this.fsPathToEnv.set(normalizePath(projectRoot.fsPath), environment); + + if (!existingEnvironment) { + this._onDidChangeEnvironments.fire([{ kind: EnvironmentChangeKind.add, environment }]); + } + this._onDidChangeEnvironment.fire({ + uri: projectRoot, + old: previousEnvironment, + new: environment, + }); + + if (options.additionalPackages?.length) { + await runPoetry( + ['--no-ansi', 'add', ...options.additionalPackages], + projectRoot.fsPath, + this.log, + token, + ); + } + + return environment; + }, + ); + } + + /** + * Removes a Poetry environment from its associated project and clears the cached selection. + */ + public async remove(environment: PythonEnvironment): Promise { + await this.initialize(); + const projectRoots = this.getAssociatedProjectRoots(environment); + if (projectRoots.length === 0) { + throw new Error(PoetryStrings.remove.noProject(environment.environmentPath.fsPath)); + } + + const pythonExecutable = environment.execInfo?.run?.executable; + if (!pythonExecutable) { + throw new Error(PoetryStrings.remove.noExecutable(environment.environmentPath.fsPath)); + } + + await withProgress( + { + location: ProgressLocation.Notification, + title: PoetryStrings.remove.progress(environment.environmentPath.fsPath), + }, + async (_, token) => { + const projectRoot = await this.findOwningProjectRoot(environment, projectRoots, token); + await runPoetry( + ['--no-ansi', 'env', 'remove', pythonExecutable], + projectRoot.fsPath, + this.log, + token, + ); + + this.collection = this.collection.filter((item) => !this.sameEnvironment(item, environment)); + for (const root of projectRoots) { + const previousEnvironment = this.fsPathToEnv.get(normalizePath(root.fsPath)); + this.fsPathToEnv.delete(normalizePath(root.fsPath)); + await setPoetryForWorkspace(root.fsPath, undefined); + this._onDidChangeEnvironment.fire({ + uri: root, + old: previousEnvironment ?? environment, + new: undefined, + }); + } + + if (this.globalEnv && this.sameEnvironment(this.globalEnv, environment)) { + const previousEnvironment = this.globalEnv; + this.globalEnv = undefined; + await setPoetryForGlobal(undefined); + this._onDidChangeEnvironment.fire({ + uri: undefined, + old: previousEnvironment, + new: undefined, + }); + } + + this._onDidChangeEnvironments.fire([{ kind: EnvironmentChangeKind.remove, environment }]); + }, + ); + } + public dispose() { this.collection = []; this.fsPathToEnv.clear(); @@ -208,7 +364,12 @@ export class PoetryManager implements EnvironmentManager, Disposable { async set(scope: SetEnvironmentScope, environment?: PythonEnvironment | undefined): Promise { if (scope === undefined) { + const previousEnvironment = this.globalEnv; await setPoetryForGlobal(environment?.environmentPath?.fsPath); + this.globalEnv = environment; + if (previousEnvironment?.envId.id !== environment?.envId.id) { + this._onDidChangeEnvironment.fire({ uri: undefined, old: previousEnvironment, new: environment }); + } } else if (scope instanceof Uri) { const folder = this.api.getPythonProject(scope); const fsPath = folder?.uri?.fsPath ?? scope.fsPath; @@ -388,4 +549,94 @@ export class PoetryManager implements EnvironmentManager, Disposable { ); }); } + + private async getCreateProjectRoot(scope: CreateEnvironmentScope): Promise { + if (scope === 'global' || (Array.isArray(scope) && scope.length !== 1)) { + throw new Error(PoetryStrings.create.singleProject); + } + const projectScope = Array.isArray(scope) ? scope[0] : scope; + const project = this.api.getPythonProject(projectScope); + return project?.uri ?? Uri.file(await findParentIfFile(projectScope.fsPath)); + } + + private async getBaseEnvironment(): Promise { + const environments = await this.api.getEnvironments('global'); + const baseEnvironment = getLatest( + environments.filter( + (environment) => + environment.version?.startsWith('3.') && + !!environment.execInfo?.run?.executable && + environment.envId.managerId !== this.preferredPackageManagerId, + ), + ); + if (!baseEnvironment) { + throw new Error(PoetryStrings.create.noPython); + } + return baseEnvironment; + } + + private parseEnvironmentPath(output: string): string { + const environmentPath = output + .split(/\r?\n/) + .map((line) => line.trim()) + .reverse() + .find((line) => path.isAbsolute(line)); + if (!environmentPath) { + throw new Error(PoetryStrings.create.missingPath); + } + return environmentPath; + } + + private getAssociatedProjectRoots(environment: PythonEnvironment): Uri[] { + const projects = this.api.getPythonProjects(); + const mappedRoots = Array.from(this.fsPathToEnv.entries()) + .filter(([, item]) => this.sameEnvironment(item, environment)) + .map(([projectPath]) => { + const project = projects.find((item) => normalizePath(item.uri.fsPath) === projectPath); + return project?.uri ?? Uri.file(projectPath); + }); + const owningProject = this.api.getPythonProject(environment.environmentPath); + if (owningProject) { + const owningProjectKey = normalizePath(owningProject.uri.fsPath); + const mappedEnvironment = this.fsPathToEnv.get(owningProjectKey); + if ( + (!mappedEnvironment || this.sameEnvironment(mappedEnvironment, environment)) && + !mappedRoots.some((root) => normalizePath(root.fsPath) === owningProjectKey) + ) { + mappedRoots.push(owningProject.uri); + } + } + return mappedRoots; + } + + private async findOwningProjectRoot( + environment: PythonEnvironment, + projectRoots: Uri[], + token: CancellationToken, + ): Promise { + for (const projectRoot of projectRoots) { + try { + const output = await runPoetry( + ['--no-ansi', 'env', 'info', '--path'], + projectRoot.fsPath, + this.log, + token, + ); + const environmentPath = this.parseEnvironmentPath(output); + if (normalizePath(environmentPath) === normalizePath(environment.environmentPath.fsPath)) { + return projectRoot; + } + } catch (error) { + if (error instanceof CancellationError) { + throw error; + } + traceInfo(`Poetry project at ${projectRoot.fsPath} does not own the environment being removed`); + } + } + throw new Error(PoetryStrings.remove.noProject(environment.environmentPath.fsPath)); + } + + private sameEnvironment(left: PythonEnvironment, right: PythonEnvironment): boolean { + return normalizePath(left.environmentPath.fsPath) === normalizePath(right.environmentPath.fsPath); + } } diff --git a/src/test/managers/poetry/poetryManager.createRemove.unit.test.ts b/src/test/managers/poetry/poetryManager.createRemove.unit.test.ts new file mode 100644 index 00000000..2890b536 --- /dev/null +++ b/src/test/managers/poetry/poetryManager.createRemove.unit.test.ts @@ -0,0 +1,421 @@ +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 { CancellationToken, Uri } from 'vscode'; +import { + DidChangeEnvironmentEventArgs, + DidChangeEnvironmentsEventArgs, + EnvironmentChangeKind, + PythonEnvironment, + PythonEnvironmentApi, + PythonProject, +} from '../../../api'; +import { normalizePath } from '../../../common/utils/pathUtils'; +import * as windowApis from '../../../common/window.apis'; +import { NativePythonFinder } from '../../../managers/common/nativePythonFinder'; +import * as poetryCommands from '../../../managers/poetry/commands/runPoetry'; +import { PoetryManager } from '../../../managers/poetry/poetryManager'; +import * as poetryUtils from '../../../managers/poetry/poetryUtils'; +import { createMockPythonEnvironment } from '../../mocks/pythonEnvironment'; + +function makeEnvironment(name: string, envPath: string, managerId: string): PythonEnvironment { + return createMockPythonEnvironment({ + name, + envPath, + managerId, + }); +} + +function createManager(apiOverrides: Partial = {}): PoetryManager { + const api = { + getPythonProject: sinon.stub().returns(undefined), + getPythonProjects: sinon.stub().returns([]), + getEnvironments: sinon.stub().resolves([]), + ...apiOverrides, + } as unknown as PythonEnvironmentApi; + const manager = new PoetryManager( + {} as NativePythonFinder, + api, + { info: sinon.stub(), append: sinon.stub(), error: sinon.stub() } as never, + ); + (manager as unknown as { _initialized: { completed: boolean; promise: Promise } })._initialized = { + completed: true, + promise: Promise.resolve(), + }; + return manager; +} + +suite('PoetryManager environment lifecycle', () => { + let runPoetryStub: sinon.SinonStub; + let resolvePoetryPathStub: sinon.SinonStub; + let setPoetryForGlobalStub: sinon.SinonStub; + let setPoetryForWorkspaceStub: sinon.SinonStub; + + setup(() => { + runPoetryStub = sinon.stub(poetryCommands, 'runPoetry'); + resolvePoetryPathStub = sinon.stub(poetryUtils, 'resolvePoetryPath'); + setPoetryForWorkspaceStub = sinon.stub(poetryUtils, 'setPoetryForWorkspace').resolves(); + setPoetryForGlobalStub = sinon.stub(poetryUtils, 'setPoetryForGlobal').resolves(); + sinon.stub(windowApis, 'withProgress').callsFake(async (_options, task) => + task({ report: sinon.stub() }, { onCancellationRequested: sinon.stub() } as unknown as CancellationToken), + ); + }); + + teardown(() => { + sinon.restore(); + }); + + test('creates, configures, and selects a Poetry environment for a project', async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poetry-manager-')); + try { + const projectUri = Uri.file(path.join(tempRoot, 'project')); + const environmentPath = Uri.file(path.join(tempRoot, 'poetry-cache', 'project-py3.12')).fsPath; + await fs.outputFile(path.join(projectUri.fsPath, 'pyproject.toml'), '[tool.poetry]\nname = "project"\n'); + const project = { name: 'project', uri: projectUri } as PythonProject; + const baseEnvironment = makeEnvironment('python', path.join(tempRoot, 'python'), 'ms-python.python:system'); + const poetryEnvironment = makeEnvironment( + 'project', + environmentPath, + 'ms-python.python:poetry', + ); + const manager = createManager({ + getPythonProject: sinon.stub().returns(project), + getEnvironments: sinon.stub().resolves([baseEnvironment]), + }); + runPoetryStub.onFirstCall().resolves(''); + runPoetryStub.onSecondCall().resolves(`Poetry diagnostic output${os.EOL}${environmentPath}${os.EOL}`); + runPoetryStub.onThirdCall().resolves(''); + resolvePoetryPathStub.resolves(poetryEnvironment); + const collectionEvents: DidChangeEnvironmentsEventArgs[] = []; + const selectionEvents: DidChangeEnvironmentEventArgs[] = []; + manager.onDidChangeEnvironments((event) => collectionEvents.push(event)); + manager.onDidChangeEnvironment((event) => selectionEvents.push(event)); + + const result = await manager.create(projectUri, { additionalPackages: ['pytest', 'ruff'] }); + + assert.strictEqual(result, poetryEnvironment); + assert.deepStrictEqual(runPoetryStub.firstCall.args.slice(0, 2), [ + ['--no-ansi', 'env', 'use', 'python'], + projectUri.fsPath, + ]); + assert.deepStrictEqual(runPoetryStub.secondCall.args.slice(0, 2), [ + ['--no-ansi', 'env', 'info', '--path'], + projectUri.fsPath, + ]); + assert.deepStrictEqual(runPoetryStub.thirdCall.args.slice(0, 2), [ + ['--no-ansi', 'add', 'pytest', 'ruff'], + projectUri.fsPath, + ]); + assert.ok( + setPoetryForWorkspaceStub.calledOnceWithExactly(projectUri.fsPath, poetryEnvironment.environmentPath.fsPath), + ); + assert.strictEqual(collectionEvents.length, 1); + assert.strictEqual(collectionEvents[0][0].kind, EnvironmentChangeKind.add); + assert.strictEqual(collectionEvents[0][0].environment, poetryEnvironment); + assert.deepStrictEqual(selectionEvents[0], { + uri: projectUri, + old: undefined, + new: poetryEnvironment, + }); + } finally { + await fs.remove(tempRoot); + } + }); + + test('requires an existing pyproject.toml', async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poetry-manager-')); + try { + const projectUri = Uri.file(path.join(tempRoot, 'project')); + await fs.mkdirp(projectUri.fsPath); + const project = { name: 'project', uri: projectUri } as PythonProject; + const manager = createManager({ getPythonProject: sinon.stub().returns(project) }); + + await assert.rejects(manager.create(projectUri), /pyproject\.toml/i); + + assert.ok(runPoetryStub.notCalled); + } finally { + await fs.remove(tempRoot); + } + }); + + test('requires a usable global Python 3 environment', async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poetry-manager-')); + try { + const projectUri = Uri.file(path.join(tempRoot, 'project')); + await fs.outputFile(path.join(projectUri.fsPath, 'pyproject.toml'), '[tool.poetry]\nname = "project"\n'); + const project = { name: 'project', uri: projectUri } as PythonProject; + const python2 = createMockPythonEnvironment({ + name: 'python2', + envPath: path.join(tempRoot, 'python2'), + version: '2.7.18', + managerId: 'ms-python.python:system', + }); + const manager = createManager({ + getPythonProject: sinon.stub().returns(project), + getEnvironments: sinon.stub().resolves([python2]), + }); + + await assert.rejects(manager.create(projectUri), /Python 3/i); + + assert.ok(runPoetryStub.notCalled); + } finally { + await fs.remove(tempRoot); + } + }); + + test('does not mutate state when Poetry creation fails', async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poetry-manager-')); + try { + const projectUri = Uri.file(path.join(tempRoot, 'project')); + await fs.outputFile(path.join(projectUri.fsPath, 'pyproject.toml'), '[tool.poetry]\nname = "project"\n'); + const project = { name: 'project', uri: projectUri } as PythonProject; + const baseEnvironment = makeEnvironment('python', path.join(tempRoot, 'python'), 'ms-python.python:system'); + const manager = createManager({ + getPythonProject: sinon.stub().returns(project), + getEnvironments: sinon.stub().resolves([baseEnvironment]), + }); + + runPoetryStub.rejects(new Error('creation failed')); + const events: DidChangeEnvironmentsEventArgs[] = []; + manager.onDidChangeEnvironments((event) => events.push(event)); + + await assert.rejects(manager.create(projectUri), /creation failed/); + + assert.deepStrictEqual((manager as unknown as { collection: PythonEnvironment[] }).collection, []); + assert.strictEqual(events.length, 0); + assert.ok(setPoetryForWorkspaceStub.notCalled); + } finally { + await fs.remove(tempRoot); + } + }); + + test('keeps a created environment tracked when package installation fails', async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poetry-manager-')); + try { + const projectUri = Uri.file(path.join(tempRoot, 'project')); + const environmentPath = Uri.file(path.join(tempRoot, 'poetry-cache', 'project-py3.12')).fsPath; + await fs.outputFile(path.join(projectUri.fsPath, 'pyproject.toml'), '[tool.poetry]\nname = "project"\n'); + const project = { name: 'project', uri: projectUri } as PythonProject; + const baseEnvironment = makeEnvironment('python', path.join(tempRoot, 'python'), 'ms-python.python:system'); + const poetryEnvironment = makeEnvironment( + 'project', + environmentPath, + 'ms-python.python:poetry', + ); + const manager = createManager({ + getPythonProject: sinon.stub().returns(project), + getEnvironments: sinon.stub().resolves([baseEnvironment]), + }); + runPoetryStub.onFirstCall().resolves(''); + runPoetryStub.onSecondCall().resolves(environmentPath); + runPoetryStub.onThirdCall().rejects(new Error('package installation failed')); + resolvePoetryPathStub.resolves(poetryEnvironment); + const events: DidChangeEnvironmentsEventArgs[] = []; + manager.onDidChangeEnvironments((event) => events.push(event)); + + await assert.rejects( + manager.create(projectUri, { additionalPackages: ['pytest'] }), + /package installation failed/, + ); + + assert.deepStrictEqual( + (manager as unknown as { collection: PythonEnvironment[] }).collection, + [poetryEnvironment], + ); + assert.ok( + setPoetryForWorkspaceStub.calledOnceWithExactly(projectUri.fsPath, poetryEnvironment.environmentPath.fsPath), + ); + assert.strictEqual(events[0][0].environment, poetryEnvironment); + } finally { + await fs.remove(tempRoot); + } + }); + + test('reuses the canonical cached environment when Poetry returns an existing path', async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'poetry-manager-')); + try { + const projectUri = Uri.file(path.join(tempRoot, 'project')); + const environmentPath = Uri.file(path.join(tempRoot, 'poetry-cache', 'project-py3.12')).fsPath; + await fs.outputFile(path.join(projectUri.fsPath, 'pyproject.toml'), '[tool.poetry]\nname = "project"\n'); + const project = { name: 'project', uri: projectUri } as PythonProject; + const baseEnvironment = makeEnvironment('python', path.join(tempRoot, 'python'), 'ms-python.python:system'); + const cachedEnvironment = makeEnvironment( + 'cached-project', + environmentPath, + 'ms-python.python:poetry', + ); + const newlyResolvedEnvironment = createMockPythonEnvironment({ + name: 'resolved-project', + envPath: environmentPath, + managerId: 'ms-python.python:poetry', + id: 'different-id', + }); + const manager = createManager({ + getPythonProject: sinon.stub().returns(project), + getEnvironments: sinon.stub().resolves([baseEnvironment]), + }); + (manager as unknown as { collection: PythonEnvironment[] }).collection = [cachedEnvironment]; + runPoetryStub.onFirstCall().resolves(''); + runPoetryStub.onSecondCall().resolves(environmentPath); + resolvePoetryPathStub.resolves(newlyResolvedEnvironment); + const events: DidChangeEnvironmentsEventArgs[] = []; + manager.onDidChangeEnvironments((event) => events.push(event)); + + const result = await manager.create(projectUri); + + assert.strictEqual(result, cachedEnvironment); + assert.deepStrictEqual( + (manager as unknown as { collection: PythonEnvironment[] }).collection, + [cachedEnvironment], + ); + assert.strictEqual(events.length, 0); + } finally { + await fs.remove(tempRoot); + } + }); + + test('removes an associated Poetry environment after the command succeeds', async () => { + const projectUri = Uri.file(path.join(os.tmpdir(), 'poetry-manager-project')); + const environment = makeEnvironment( + 'project', + path.join(os.tmpdir(), 'poetry-cache', 'project-py3.12'), + 'ms-python.python:poetry', + ); + const project = { name: 'project', uri: projectUri } as PythonProject; + const manager = createManager({ getPythonProjects: sinon.stub().returns([project]) }); + const state = manager as unknown as { + collection: PythonEnvironment[]; + fsPathToEnv: Map; + }; + state.collection = [environment]; + state.fsPathToEnv = new Map([[normalizePath(projectUri.fsPath), environment]]); + runPoetryStub.onFirstCall().resolves(environment.environmentPath.fsPath); + runPoetryStub.onSecondCall().resolves(''); + const collectionEvents: DidChangeEnvironmentsEventArgs[] = []; + const selectionEvents: DidChangeEnvironmentEventArgs[] = []; + manager.onDidChangeEnvironments((event) => collectionEvents.push(event)); + manager.onDidChangeEnvironment((event) => selectionEvents.push(event)); + + await manager.remove(environment); + + assert.deepStrictEqual(runPoetryStub.secondCall.args.slice(0, 2), [ + ['--no-ansi', 'env', 'remove', 'python'], + projectUri.fsPath, + ]); + assert.deepStrictEqual(state.collection, []); + assert.strictEqual(state.fsPathToEnv.size, 0); + assert.ok(setPoetryForWorkspaceStub.calledOnceWithExactly(projectUri.fsPath, undefined)); + assert.strictEqual(collectionEvents[0][0].kind, EnvironmentChangeKind.remove); + assert.deepStrictEqual(selectionEvents[0], { + uri: projectUri, + old: environment, + new: undefined, + }); + }); + + test('does not mutate state when Poetry removal fails', async () => { + const projectUri = Uri.file(path.join(os.tmpdir(), 'poetry-manager-project')); + const environment = makeEnvironment( + 'project', + path.join(os.tmpdir(), 'poetry-cache', 'project-py3.12'), + 'ms-python.python:poetry', + ); + const project = { name: 'project', uri: projectUri } as PythonProject; + const manager = createManager({ getPythonProjects: sinon.stub().returns([project]) }); + const state = manager as unknown as { + collection: PythonEnvironment[]; + fsPathToEnv: Map; + }; + state.collection = [environment]; + state.fsPathToEnv = new Map([[normalizePath(projectUri.fsPath), environment]]); + runPoetryStub.onFirstCall().resolves(environment.environmentPath.fsPath); + runPoetryStub.onSecondCall().rejects(new Error('removal failed')); + + await assert.rejects(manager.remove(environment), /removal failed/); + + assert.deepStrictEqual(state.collection, [environment]); + assert.strictEqual(state.fsPathToEnv.size, 1); + assert.ok(setPoetryForWorkspaceStub.notCalled); + }); + + test('clears every project mapped to the removed environment', async () => { + const firstProject = Uri.file(path.join(os.tmpdir(), 'poetry-manager-project-one')); + const secondProject = Uri.file(path.join(os.tmpdir(), 'poetry-manager-project-two')); + const environment = makeEnvironment( + 'project', + path.join(os.tmpdir(), 'poetry-cache', 'project-py3.12'), + 'ms-python.python:poetry', + ); + const projects = [ + { name: 'project-one', uri: firstProject }, + { name: 'project-two', uri: secondProject }, + ] as PythonProject[]; + const manager = createManager({ + getPythonProject: sinon.stub().returns(projects[1]), + getPythonProjects: sinon.stub().returns(projects), + }); + const state = manager as unknown as { + collection: PythonEnvironment[]; + fsPathToEnv: Map; + }; + state.collection = [environment]; + state.fsPathToEnv = new Map([ + [normalizePath(firstProject.fsPath), environment], + [normalizePath(secondProject.fsPath), environment], + ]); + runPoetryStub.onFirstCall().resolves(path.join(os.tmpdir(), 'poetry-cache', 'different-environment')); + runPoetryStub.onSecondCall().resolves(environment.environmentPath.fsPath); + runPoetryStub.onThirdCall().resolves(''); + const selectionEvents: DidChangeEnvironmentEventArgs[] = []; + manager.onDidChangeEnvironment((event) => selectionEvents.push(event)); + + await manager.remove(environment); + + assert.strictEqual(runPoetryStub.thirdCall.args[1], secondProject.fsPath); + assert.strictEqual(state.fsPathToEnv.size, 0); + assert.ok(setPoetryForWorkspaceStub.calledWithExactly(firstProject.fsPath, undefined)); + assert.ok(setPoetryForWorkspaceStub.calledWithExactly(secondProject.fsPath, undefined)); + assert.deepStrictEqual( + selectionEvents.map((event) => event.uri), + [firstProject, secondProject], + ); + }); + + test('rejects removal when the owning Poetry project is unknown', async () => { + const environment = makeEnvironment( + 'project', + path.join(os.tmpdir(), 'poetry-cache', 'project-py3.12'), + 'ms-python.python:poetry', + ); + const manager = createManager(); + + await assert.rejects(manager.remove(environment), /associated/i); + + assert.ok(runPoetryStub.notCalled); + }); + + test('clears a global selection when its Poetry environment is removed', async () => { + const projectUri = Uri.file(path.join(os.tmpdir(), 'poetry-manager-project')); + const environment = makeEnvironment( + 'project', + path.join(os.tmpdir(), 'poetry-cache', 'project-py3.12'), + 'ms-python.python:poetry', + ); + const project = { name: 'project', uri: projectUri } as PythonProject; + const manager = createManager({ + getPythonProject: sinon.stub().returns(project), + getPythonProjects: sinon.stub().returns([project]), + }); + runPoetryStub.onFirstCall().resolves(environment.environmentPath.fsPath); + runPoetryStub.onSecondCall().resolves(''); + await manager.set(undefined, environment); + + await manager.remove(environment); + + assert.strictEqual(await manager.get(undefined), undefined); + assert.ok(setPoetryForGlobalStub.calledWithExactly(environment.environmentPath.fsPath)); + assert.ok(setPoetryForGlobalStub.calledWithExactly(undefined)); + }); +});