Skip to content

Commit be32fc9

Browse files
committed
feat: add Poetry environment lifecycle support
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6b2fe9b5-38ea-442f-b07a-b6c71134d480
1 parent 57f4248 commit be32fc9

4 files changed

Lines changed: 690 additions & 2 deletions

File tree

src/common/localize.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,22 @@ export namespace PoetryStrings {
198198
export const poetryManager = l10n.t('Manages Poetry environments');
199199
export const poetryDiscovering = l10n.t('Discovering Poetry environments');
200200
export const poetryRefreshing = l10n.t('Refreshing Poetry environments');
201+
export namespace create {
202+
export const description = l10n.t('Create a Poetry environment for the current project');
203+
export const progress = (path: string) => l10n.t('Creating Poetry environment for {0}', path);
204+
export const singleProject = l10n.t('Poetry environments can only be created for one project at a time.');
205+
export const noPyproject = (path: string) => l10n.t('No pyproject.toml was found in {0}.', path);
206+
export const noPython = l10n.t('No usable global Python 3 environment was found.');
207+
export const missingPath = l10n.t('Poetry did not report the path of the created environment.');
208+
export const resolveFailed = (path: string) => l10n.t('The Poetry environment at {0} could not be resolved.', path);
209+
}
210+
export namespace remove {
211+
export const progress = (path: string) => l10n.t('Removing Poetry environment at {0}', path);
212+
export const noProject = (path: string) =>
213+
l10n.t('The Poetry project associated with the environment at {0} could not be determined.', path);
214+
export const noExecutable = (path: string) =>
215+
l10n.t('The Python executable for the Poetry environment at {0} could not be determined.', path);
216+
}
201217
}
202218

203219
export namespace ProjectCreatorString {

src/managers/poetry/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export async function registerPoetryFeatures(
1717
const api: PythonEnvironmentApi = await getPythonApi();
1818

1919
traceInfo('Registering poetry manager (environments will be discovered lazily)');
20-
const envManager = new PoetryManager(nativeFinder, api, projectManager);
20+
const envManager = new PoetryManager(nativeFinder, api, outputChannel, projectManager);
2121
const pkgManager = new PoetryPackageManager(api, outputChannel, envManager);
2222

2323
disposables.push(

src/managers/poetry/poetryManager.ts

Lines changed: 252 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
11
import * as path from 'path';
2-
import { Disposable, EventEmitter, MarkdownString, ProgressLocation, Uri, workspace } from 'vscode';
2+
import * as fs from 'fs-extra';
33
import {
4+
CancellationError,
5+
CancellationToken,
6+
Disposable,
7+
EventEmitter,
8+
LogOutputChannel,
9+
MarkdownString,
10+
ProgressLocation,
11+
Uri,
12+
workspace,
13+
} from 'vscode';
14+
import {
15+
CreateEnvironmentOptions,
16+
CreateEnvironmentScope,
417
DidChangeEnvironmentEventArgs,
518
DidChangeEnvironmentsEventArgs,
619
EnvironmentChangeKind,
@@ -11,6 +24,7 @@ import {
1124
PythonEnvironment,
1225
PythonEnvironmentApi,
1326
PythonProject,
27+
QuickCreateConfig,
1428
RefreshEnvironmentsScope,
1529
ResolveEnvironmentContext,
1630
SetEnvironmentScope,
@@ -24,9 +38,11 @@ import { sendTelemetryEvent } from '../../common/telemetry/sender';
2438
import { createDeferred, Deferred } from '../../common/utils/deferred';
2539
import { normalizePath } from '../../common/utils/pathUtils';
2640
import { withProgress } from '../../common/window.apis';
41+
import { findParentIfFile } from '../../features/envCommands';
2742
import { PythonProjectManager } from '../../internal.api';
2843
import { NativePythonFinder } from '../common/nativePythonFinder';
2944
import { getLatest, notifyMissingManagerIfDefault } from '../common/utils';
45+
import { runPoetry } from './commands/runPoetry';
3046
import {
3147
clearPoetryCache,
3248
getPoetry,
@@ -54,6 +70,7 @@ export class PoetryManager implements EnvironmentManager, Disposable {
5470
constructor(
5571
private readonly nativeFinder: NativePythonFinder,
5672
private readonly api: PythonEnvironmentApi,
73+
public readonly log: LogOutputChannel,
5774
private readonly projectManager?: PythonProjectManager,
5875
) {
5976
this.name = 'poetry';
@@ -69,6 +86,145 @@ export class PoetryManager implements EnvironmentManager, Disposable {
6986
tooltip: string | MarkdownString;
7087
iconPath?: IconPath;
7188

89+
/**
90+
* Returns the configuration used to offer Poetry as a quick-create option.
91+
*/
92+
public quickCreateConfig(): QuickCreateConfig {
93+
return {
94+
description: PoetryStrings.create.description,
95+
};
96+
}
97+
98+
/**
99+
* Creates and selects a Poetry environment for a single existing Python project.
100+
*/
101+
public async create(
102+
scope: CreateEnvironmentScope,
103+
options: CreateEnvironmentOptions = {},
104+
): Promise<PythonEnvironment | undefined> {
105+
await this.initialize();
106+
const projectRoot = await this.getCreateProjectRoot(scope);
107+
const pyprojectPath = path.join(projectRoot.fsPath, 'pyproject.toml');
108+
if (!(await fs.pathExists(pyprojectPath))) {
109+
throw new Error(PoetryStrings.create.noPyproject(projectRoot.fsPath));
110+
}
111+
112+
const baseEnvironment = await this.getBaseEnvironment();
113+
const pythonExecutable = baseEnvironment.execInfo?.run?.executable;
114+
if (!pythonExecutable) {
115+
throw new Error(PoetryStrings.create.noPython);
116+
}
117+
118+
return withProgress(
119+
{
120+
location: ProgressLocation.Notification,
121+
title: PoetryStrings.create.progress(projectRoot.fsPath),
122+
},
123+
async (_, token) => {
124+
await runPoetry(['--no-ansi', 'env', 'use', pythonExecutable], projectRoot.fsPath, this.log, token);
125+
const result = await runPoetry(
126+
['--no-ansi', 'env', 'info', '--path'],
127+
projectRoot.fsPath,
128+
this.log,
129+
token,
130+
);
131+
const environmentPath = this.parseEnvironmentPath(result);
132+
const resolvedEnvironment = await resolvePoetryPath(environmentPath, this.nativeFinder, this.api, this);
133+
if (!resolvedEnvironment) {
134+
throw new Error(PoetryStrings.create.resolveFailed(environmentPath));
135+
}
136+
137+
const existingEnvironment = this.collection.find((item) =>
138+
this.sameEnvironment(item, resolvedEnvironment),
139+
);
140+
const environment = existingEnvironment ?? resolvedEnvironment;
141+
const previousEnvironment = this.fsPathToEnv.get(normalizePath(projectRoot.fsPath));
142+
await setPoetryForWorkspace(projectRoot.fsPath, environment.environmentPath.fsPath);
143+
if (!existingEnvironment) {
144+
this.collection.push(environment);
145+
}
146+
this.fsPathToEnv.set(normalizePath(projectRoot.fsPath), environment);
147+
148+
if (!existingEnvironment) {
149+
this._onDidChangeEnvironments.fire([{ kind: EnvironmentChangeKind.add, environment }]);
150+
}
151+
this._onDidChangeEnvironment.fire({
152+
uri: projectRoot,
153+
old: previousEnvironment,
154+
new: environment,
155+
});
156+
157+
if (options.additionalPackages?.length) {
158+
await runPoetry(
159+
['--no-ansi', 'add', ...options.additionalPackages],
160+
projectRoot.fsPath,
161+
this.log,
162+
token,
163+
);
164+
}
165+
166+
return environment;
167+
},
168+
);
169+
}
170+
171+
/**
172+
* Removes a Poetry environment from its associated project and clears the cached selection.
173+
*/
174+
public async remove(environment: PythonEnvironment): Promise<void> {
175+
await this.initialize();
176+
const projectRoots = this.getAssociatedProjectRoots(environment);
177+
if (projectRoots.length === 0) {
178+
throw new Error(PoetryStrings.remove.noProject(environment.environmentPath.fsPath));
179+
}
180+
181+
const pythonExecutable = environment.execInfo?.run?.executable;
182+
if (!pythonExecutable) {
183+
throw new Error(PoetryStrings.remove.noExecutable(environment.environmentPath.fsPath));
184+
}
185+
186+
await withProgress(
187+
{
188+
location: ProgressLocation.Notification,
189+
title: PoetryStrings.remove.progress(environment.environmentPath.fsPath),
190+
},
191+
async (_, token) => {
192+
const projectRoot = await this.findOwningProjectRoot(environment, projectRoots, token);
193+
await runPoetry(
194+
['--no-ansi', 'env', 'remove', pythonExecutable],
195+
projectRoot.fsPath,
196+
this.log,
197+
token,
198+
);
199+
200+
this.collection = this.collection.filter((item) => !this.sameEnvironment(item, environment));
201+
for (const root of projectRoots) {
202+
const previousEnvironment = this.fsPathToEnv.get(normalizePath(root.fsPath));
203+
this.fsPathToEnv.delete(normalizePath(root.fsPath));
204+
await setPoetryForWorkspace(root.fsPath, undefined);
205+
this._onDidChangeEnvironment.fire({
206+
uri: root,
207+
old: previousEnvironment ?? environment,
208+
new: undefined,
209+
});
210+
}
211+
212+
if (this.globalEnv && this.sameEnvironment(this.globalEnv, environment)) {
213+
const previousEnvironment = this.globalEnv;
214+
this.globalEnv = undefined;
215+
await setPoetryForGlobal(undefined);
216+
this._onDidChangeEnvironment.fire({
217+
uri: undefined,
218+
old: previousEnvironment,
219+
new: undefined,
220+
});
221+
}
222+
223+
this._onDidChangeEnvironments.fire([{ kind: EnvironmentChangeKind.remove, environment }]);
224+
},
225+
);
226+
}
227+
72228
public dispose() {
73229
this.collection = [];
74230
this.fsPathToEnv.clear();
@@ -208,7 +364,12 @@ export class PoetryManager implements EnvironmentManager, Disposable {
208364

209365
async set(scope: SetEnvironmentScope, environment?: PythonEnvironment | undefined): Promise<void> {
210366
if (scope === undefined) {
367+
const previousEnvironment = this.globalEnv;
211368
await setPoetryForGlobal(environment?.environmentPath?.fsPath);
369+
this.globalEnv = environment;
370+
if (previousEnvironment?.envId.id !== environment?.envId.id) {
371+
this._onDidChangeEnvironment.fire({ uri: undefined, old: previousEnvironment, new: environment });
372+
}
212373
} else if (scope instanceof Uri) {
213374
const folder = this.api.getPythonProject(scope);
214375
const fsPath = folder?.uri?.fsPath ?? scope.fsPath;
@@ -388,4 +549,94 @@ export class PoetryManager implements EnvironmentManager, Disposable {
388549
);
389550
});
390551
}
552+
553+
private async getCreateProjectRoot(scope: CreateEnvironmentScope): Promise<Uri> {
554+
if (scope === 'global' || (Array.isArray(scope) && scope.length !== 1)) {
555+
throw new Error(PoetryStrings.create.singleProject);
556+
}
557+
const projectScope = Array.isArray(scope) ? scope[0] : scope;
558+
const project = this.api.getPythonProject(projectScope);
559+
return project?.uri ?? Uri.file(await findParentIfFile(projectScope.fsPath));
560+
}
561+
562+
private async getBaseEnvironment(): Promise<PythonEnvironment> {
563+
const environments = await this.api.getEnvironments('global');
564+
const baseEnvironment = getLatest(
565+
environments.filter(
566+
(environment) =>
567+
environment.version?.startsWith('3.') &&
568+
!!environment.execInfo?.run?.executable &&
569+
environment.envId.managerId !== this.preferredPackageManagerId,
570+
),
571+
);
572+
if (!baseEnvironment) {
573+
throw new Error(PoetryStrings.create.noPython);
574+
}
575+
return baseEnvironment;
576+
}
577+
578+
private parseEnvironmentPath(output: string): string {
579+
const environmentPath = output
580+
.split(/\r?\n/)
581+
.map((line) => line.trim())
582+
.reverse()
583+
.find((line) => path.isAbsolute(line));
584+
if (!environmentPath) {
585+
throw new Error(PoetryStrings.create.missingPath);
586+
}
587+
return environmentPath;
588+
}
589+
590+
private getAssociatedProjectRoots(environment: PythonEnvironment): Uri[] {
591+
const projects = this.api.getPythonProjects();
592+
const mappedRoots = Array.from(this.fsPathToEnv.entries())
593+
.filter(([, item]) => this.sameEnvironment(item, environment))
594+
.map(([projectPath]) => {
595+
const project = projects.find((item) => normalizePath(item.uri.fsPath) === projectPath);
596+
return project?.uri ?? Uri.file(projectPath);
597+
});
598+
const owningProject = this.api.getPythonProject(environment.environmentPath);
599+
if (owningProject) {
600+
const owningProjectKey = normalizePath(owningProject.uri.fsPath);
601+
const mappedEnvironment = this.fsPathToEnv.get(owningProjectKey);
602+
if (
603+
(!mappedEnvironment || this.sameEnvironment(mappedEnvironment, environment)) &&
604+
!mappedRoots.some((root) => normalizePath(root.fsPath) === owningProjectKey)
605+
) {
606+
mappedRoots.push(owningProject.uri);
607+
}
608+
}
609+
return mappedRoots;
610+
}
611+
612+
private async findOwningProjectRoot(
613+
environment: PythonEnvironment,
614+
projectRoots: Uri[],
615+
token: CancellationToken,
616+
): Promise<Uri> {
617+
for (const projectRoot of projectRoots) {
618+
try {
619+
const output = await runPoetry(
620+
['--no-ansi', 'env', 'info', '--path'],
621+
projectRoot.fsPath,
622+
this.log,
623+
token,
624+
);
625+
const environmentPath = this.parseEnvironmentPath(output);
626+
if (normalizePath(environmentPath) === normalizePath(environment.environmentPath.fsPath)) {
627+
return projectRoot;
628+
}
629+
} catch (error) {
630+
if (error instanceof CancellationError) {
631+
throw error;
632+
}
633+
traceInfo(`Poetry project at ${projectRoot.fsPath} does not own the environment being removed`);
634+
}
635+
}
636+
throw new Error(PoetryStrings.remove.noProject(environment.environmentPath.fsPath));
637+
}
638+
639+
private sameEnvironment(left: PythonEnvironment, right: PythonEnvironment): boolean {
640+
return normalizePath(left.environmentPath.fsPath) === normalizePath(right.environmentPath.fsPath);
641+
}
391642
}

0 commit comments

Comments
 (0)