11import * as path from 'path' ;
2- import { Disposable , EventEmitter , MarkdownString , ProgressLocation , Uri , workspace } from 'vscode ' ;
2+ import * as fs from 'fs-extra ' ;
33import {
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';
2438import { createDeferred , Deferred } from '../../common/utils/deferred' ;
2539import { normalizePath } from '../../common/utils/pathUtils' ;
2640import { withProgress } from '../../common/window.apis' ;
41+ import { findParentIfFile } from '../../features/envCommands' ;
2742import { PythonProjectManager } from '../../internal.api' ;
2843import { NativePythonFinder } from '../common/nativePythonFinder' ;
2944import { getLatest , notifyMissingManagerIfDefault } from '../common/utils' ;
45+ import { runPoetry } from './commands/runPoetry' ;
3046import {
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