diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index eb86291b4..c3211b936 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to the `@vscode/python-environments` API package are documen The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.6.0] + +### Added + +- Added optional `CreateEnvironmentOptions.name` so API consumers can request a specific name when creating an environment. +- Added `EnvironmentManager.createCapabilities.customName` so managers explicitly advertise support for exact caller-supplied names. Named creation now rejects before invoking managers that do not support it. + ## [1.5.0] ### Added diff --git a/api/package-lock.json b/api/package-lock.json index 62fda10a8..37039057c 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vscode/python-environments", - "version": "1.5.0", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vscode/python-environments", - "version": "1.5.0", + "version": "1.6.0", "license": "MIT", "dependencies": { "@renovatebot/pep440": "^3.1.0" diff --git a/api/package.json b/api/package.json index ada2db299..b2be12d99 100644 --- a/api/package.json +++ b/api/package.json @@ -1,7 +1,7 @@ { "name": "@vscode/python-environments", "description": "An API facade for the Python Environments extension in VS Code", - "version": "1.5.0", + "version": "1.6.0", "author": { "name": "Microsoft Corporation" }, diff --git a/docs/README.md b/docs/README.md index bbeb1b09a..582235069 100644 --- a/docs/README.md +++ b/docs/README.md @@ -274,11 +274,13 @@ applies to. | Field | Type | Required | Description | | --- | --- | --- | --- | -| `quickCreate` | `boolean` | No | `true` creates without any prompts. `false` means the user explicitly declined quick create, so prompts are allowed. `undefined` leaves the decision to the manager, which may offer quick create. | -| `additionalPackages` | `string[]` | No | Packages to install in addition to whatever the manager installs by default. | +| `name` | `string` | `false` | Portable path segment to use as the new environment's name. Directory separators, control characters, Windows-reserved filename characters (such as `:` and `?`) and device names (such as `CON` and `NUL`), trailing periods or spaces, `.` and `..` are rejected. The selected manager must advertise `createCapabilities.customName`; otherwise creation rejects. When omitted, the manager may prompt for a name or choose a default. | +| `quickCreate` | `boolean` | `false` | `true` creates without any prompts. `false` means the user explicitly declined quick create, so prompts are allowed. `undefined` leaves the decision to the manager, which may offer quick create. | +| `additionalPackages` | `string[]` | `false` | Packages to install in addition to whatever the manager installs by default. | ```typescript const env = await api.createEnvironment(projectUri, { + name: 'analysis-env', quickCreate: true, additionalPackages: ['requests', 'pytest'], }); @@ -506,16 +508,17 @@ createEnvironment( | Parameter | Type | Required | Description | | --- | --- | --- | --- | | `scope` | [`CreateEnvironmentScope`](#scope-types) | Yes | `Uri` or `Uri[]` for the projects the environment is created for; `'global'` creates one outside any project. | -| `options` | [`CreateEnvironmentOptions`](#createenvironmentoptions) | No | Controls prompting (`quickCreate`) and extra packages (`additionalPackages`). | +| `options` | [`CreateEnvironmentOptions`](#createenvironmentoptions) | No | Controls the environment name (`name`), prompting (`quickCreate`), and extra packages (`additionalPackages`). | **Returns** `Promise` - `undefined` when no environment was created, for example because the user cancelled the flow. Rejects when no environment manager is registered for the scope, when the -manager does not support creation, or when creation itself fails - so handle -errors as well as `undefined`. +manager does not support creation, when a supplied name is unsupported, or when +creation itself fails - so handle errors as well as `undefined`. ```typescript const created = await api.createEnvironment(projectUri, { + name: 'analysis-env', quickCreate: true, additionalPackages: ['requests'], }); @@ -1561,6 +1564,7 @@ trigger, as the specification. | `set(scope, environment?)` | `(scope: SetEnvironmentScope, environment?: PythonEnvironment) => Promise` | Yes | Sets or clears the active environment for the scope. Also called at startup to rehydrate persisted state. | | `get(scope)` | `(scope: GetEnvironmentScope) => Promise` | Yes | Returns the active environment for the scope. Called very frequently. | | `resolve(context)` | `(context: ResolveEnvironmentContext) => Promise` | Yes | Turns a `Uri` for an interpreter or environment folder into a fully populated environment with complete `execInfo`. | +| `createCapabilities` | `CreateEnvironmentCapabilities` | No | Declares optional creation behavior. Set `customName: true` only when `create` uses a supplied name exactly or rejects it. Omitted capabilities are unsupported. | | `create(scope, options?)` | `(scope: CreateEnvironmentScope, options?: CreateEnvironmentOptions) => Promise` | No | Creates an environment. Omit the method entirely if creation is unsupported - the UI disables create when `create === undefined`. Add a `.gitignore` when creating a folder inside the workspace. | | `remove(environment, options?)` | `(environment: PythonEnvironment, options?: RemoveEnvironmentOptions) => Promise` | No | Deletes an environment. | | `quickCreateConfig()` | `() => QuickCreateConfig \| undefined` | No | Describes the quick create path. Implementing it enables quick create, which requires `create` too. | diff --git a/src/common/utils/pathUtils.ts b/src/common/utils/pathUtils.ts index df796e5f7..fe012651d 100644 --- a/src/common/utils/pathUtils.ts +++ b/src/common/utils/pathUtils.ts @@ -87,6 +87,29 @@ export function isSameOrParentPath(parentPath: string, candidatePath: string): b ); } +function matchesWindowsReservedDeviceName(value: string): boolean { + const deviceBaseName = value.split('.')[0]; + return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(deviceBaseName); +} + +/** + * Determines whether `value` can be used as a path segment on all supported platforms. + * + * @param value The path segment to validate. + * @returns `true` when the value is valid on Windows, macOS, and Linux. + */ +export function isValidPortablePathSegment(value: string): boolean { + return ( + value.length > 0 && + value.trim().length > 0 && + value !== '.' && + value !== '..' && + !/[<>:"/\\|?*\u0000-\u001f]/.test(value) && + !/[. ]$/.test(value) && + !matchesWindowsReservedDeviceName(value) + ); +} + /** * Determines whether `value` maps to a reserved Windows device name (e.g. `CON`, * `PRN`, `AUX`, `NUL`, `COM1`-`COM9`, `LPT1`-`LPT9`). @@ -99,8 +122,7 @@ export function isSameOrParentPath(parentPath: string, candidatePath: string): b * @returns `true` on Windows when `value` resolves to a reserved device name. */ export function isWindowsReservedDeviceName(value: string): boolean { - const deviceBaseName = value.split('.')[0]; - return isWindows() && /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(deviceBaseName); + return isWindows() && matchesWindowsReservedDeviceName(value); } export function getResourceUri(resourcePath: string, root?: string): Uri | undefined { diff --git a/src/extensionApi.ts b/src/extensionApi.ts index 337322ed1..7cff5ccb1 100644 --- a/src/extensionApi.ts +++ b/src/extensionApi.ts @@ -34,13 +34,13 @@ import type { ResolveEnvironmentContext, SetEnvironmentScope, } from './types'; -import { PackageVersionLookupNotSupportedError } from './publicErrors'; +import { CreateEnvironmentOptionNotSupportedError, PackageVersionLookupNotSupportedError } from './publicErrors'; import { INLINE_SCRIPT_MANAGER_ID } from './common/constants'; import { traceError, traceInfo } from './common/logging'; import { pickEnvironmentManager } from './common/pickers/managers'; import { timeout } from './common/utils/asyncUtils'; import { createDeferred } from './common/utils/deferred'; -import { checkUri } from './common/utils/pathUtils'; +import { checkUri, isValidPortablePathSegment } from './common/utils/pathUtils'; import { handlePythonPath } from './common/utils/pythonPath'; import type { EnvironmentManagers } from './features/envManagers'; import type { ProjectCreators } from './features/creators/projectCreators'; @@ -157,6 +157,9 @@ export class PythonEnvironmentApiImpl implements PythonEnvironmentApi { scope: CreateEnvironmentScope, options: CreateEnvironmentOptions | undefined, ): Promise { + if (options?.name !== undefined && !isValidPortablePathSegment(options.name)) { + throw new Error('Environment name must be a valid portable path segment'); + } if (scope === 'global' || (!Array.isArray(scope) && scope instanceof Uri)) { await waitForEnvManager(scope === 'global' ? undefined : [scope]); const manager = this.envManagers.getEnvironmentManager(scope === 'global' ? undefined : scope); @@ -166,6 +169,12 @@ export class PythonEnvironmentApiImpl implements PythonEnvironmentApi { if (!manager.supportsCreate) { throw new Error(`Environment manager does not support creating environments: ${manager.id}`); } + if (options?.name !== undefined && !manager.supportsCustomName) { + throw new CreateEnvironmentOptionNotSupportedError( + 'name', + `Environment manager does not support named environment creation: ${manager.id}`, + ); + } return manager.create(scope, options); } else if (Array.isArray(scope) && scope.length === 1 && scope[0] instanceof Uri) { return this.createEnvironment(scope[0], options); @@ -183,12 +192,21 @@ export class PythonEnvironmentApiImpl implements PythonEnvironmentApi { throw new Error('No environment managers found'); } - const managerId = await pickEnvironmentManager(managers); + const compatibleManagers = + options?.name === undefined ? managers : managers.filter((manager) => manager.supportsCustomName); + if (compatibleManagers.length === 0) { + throw new CreateEnvironmentOptionNotSupportedError( + 'name', + 'None of the environment managers for the requested scopes support named environment creation.', + ); + } + + const managerId = await pickEnvironmentManager(compatibleManagers); if (!managerId) { throw new Error('No environment manager selected'); } - const manager = managers.find((m) => m.id === managerId); + const manager = compatibleManagers.find((m) => m.id === managerId); if (!manager) { throw new Error('No environment manager found'); } diff --git a/src/features/envCommands.ts b/src/features/envCommands.ts index db71f93a3..3e5577fca 100644 --- a/src/features/envCommands.ts +++ b/src/features/envCommands.ts @@ -209,13 +209,15 @@ export async function createAnyEnvironmentCommand( }, ): Promise { const select = options?.selectEnvironment; + const supportsRequestedCreation = (manager: InternalEnvironmentManager): boolean => + manager.supportsCreate && (options?.name === undefined || manager.supportsCustomName); const projects = pm.getProjects(options?.uri ? [options?.uri] : undefined); if (projects.length === 0) { const managerId = await pickEnvironmentManager( - em.managers.filter((m) => m.supportsCreate), + em.managers.filter(supportsRequestedCreation), undefined, undefined, - true, // showEnterInterpreterPath + options?.name === undefined, ); // Handle "Enter Interpreter Path" selection @@ -243,7 +245,7 @@ export async function createAnyEnvironmentCommand( selected.forEach((p) => { const manager = em.getEnvironmentManager(p.uri); - if (manager && manager.supportsCreate && !defaultManagers.includes(manager)) { + if (manager && supportsRequestedCreation(manager) && !defaultManagers.includes(manager)) { defaultManagers.push(manager); } }); @@ -255,10 +257,10 @@ export async function createAnyEnvironmentCommand( manager = defaultManagers[0]; } else { let managerId = await pickEnvironmentManager( - em.managers.filter((m) => m.supportsCreate), + em.managers.filter(supportsRequestedCreation), defaultManagers, options?.showBackButton, - true, // showEnterInterpreterPath + options?.name === undefined, ); // Handle "Enter Interpreter Path" selection diff --git a/src/managers/builtin/inlineScript/envManager.ts b/src/managers/builtin/inlineScript/envManager.ts index 61eb7ec2d..a8e32b75e 100644 --- a/src/managers/builtin/inlineScript/envManager.ts +++ b/src/managers/builtin/inlineScript/envManager.ts @@ -328,6 +328,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { public readonly displayName = l10n.t('Inline scripts'); public readonly preferredPackageManagerId = 'ms-python.python:pip'; public readonly description: string | undefined = undefined; + public readonly createCapabilities = { customName: false } as const; public readonly tooltip: string | MarkdownString = new MarkdownString( l10n.t('Environments built from PEP 723 inline script metadata.'), true, diff --git a/src/managers/builtin/sysPythonManager.ts b/src/managers/builtin/sysPythonManager.ts index 2b031ba3b..cac592bc2 100644 --- a/src/managers/builtin/sysPythonManager.ts +++ b/src/managers/builtin/sysPythonManager.ts @@ -51,6 +51,7 @@ export class SysPythonManager implements EnvironmentManager { public readonly description: string | undefined; public readonly tooltip: string | MarkdownString; public readonly iconPath: IconPath; + public readonly createCapabilities = { customName: false } as const; constructor( private readonly nativeFinder: NativePythonFinder, diff --git a/src/managers/builtin/venvManager.ts b/src/managers/builtin/venvManager.ts index 3d7859629..08cb5bb16 100644 --- a/src/managers/builtin/venvManager.ts +++ b/src/managers/builtin/venvManager.ts @@ -68,6 +68,7 @@ export class VenvManager implements EnvironmentManager { readonly description?: string | undefined; readonly tooltip?: string | MarkdownString | undefined; readonly iconPath?: IconPath | undefined; + readonly createCapabilities = { customName: true } as const; constructor( private readonly nativeFinder: NativePythonFinder, @@ -185,6 +186,7 @@ export class VenvManager implements EnvironmentManager { this.globalEnv, venvRoot, options?.additionalPackages, + options?.name, ); } } else { @@ -192,6 +194,10 @@ export class VenvManager implements EnvironmentManager { // environment manager View, by selecting the venv manager. result = await createPythonVenv(this.nativeFinder, this.api, this.log, this, globals, venvRoot, { showQuickAndCustomOptions: options?.quickCreate === undefined, + ...(options?.name === undefined ? {} : { name: options.name }), + ...(options?.additionalPackages === undefined + ? {} + : { additionalPackages: options.additionalPackages }), }); } diff --git a/src/managers/builtin/venvStepBasedFlow.ts b/src/managers/builtin/venvStepBasedFlow.ts index b5ca2b4be..f513c1744 100644 --- a/src/managers/builtin/venvStepBasedFlow.ts +++ b/src/managers/builtin/venvStepBasedFlow.ts @@ -31,6 +31,7 @@ interface VenvCreationState { // Name for the venv venvName?: string; + suppliedName?: boolean; // Packages to install in the venv // undefined = not yet set, null = user canceled during package selection @@ -161,8 +162,7 @@ async function selectBasePython(state: VenvCreationState): Promise { // Sort and filter available Python environments const sortedEnvs = ensureGlobalEnv(basePythons, log); @@ -308,6 +307,9 @@ export async function createStepBasedVenvFlow( envCreationErr: 'No suitable Python environments found', }; } + if (options.name !== undefined && (await fse.pathExists(path.join(venvRoot.fsPath, options.name)))) { + return { envCreationErr: VenvManagerStrings.venvNameErrorExists }; + } // Initialize the state object that will track user selections const state: VenvCreationState = { @@ -315,6 +317,8 @@ export async function createStepBasedVenvFlow( api, // Store API reference for package selection project: [api.getPythonProject(venvRoot)].filter(Boolean) as PythonProject[], // Get project for venvRoot venvRoot, // Store venvRoot for path validation + venvName: options.name, + suppliedName: options.name !== undefined, }; try { @@ -335,8 +339,7 @@ export async function createStepBasedVenvFlow( if (state.isQuickCreate && state.basePython) { // Use quick create flow sendTelemetryEvent(EventNames.VENV_CREATION, undefined, { creationType: 'quick' }); - // Use the default .venv name for quick create - const quickEnvPath = path.join(venvRoot.fsPath, '.venv'); + const quickEnvPath = path.join(venvRoot.fsPath, options.name ?? '.venv'); // Get workspace dependencies to install const project = api.getPythonProject(venvRoot); diff --git a/src/managers/builtin/venvUtils.ts b/src/managers/builtin/venvUtils.ts index e70cc79d3..922a3f058 100644 --- a/src/managers/builtin/venvUtils.ts +++ b/src/managers/builtin/venvUtils.ts @@ -521,6 +521,7 @@ export async function quickCreateVenv( baseEnv: PythonEnvironment, venvRoot: Uri, additionalPackages?: string[], + name?: string, ): Promise { const project = api.getPythonProject(venvRoot); @@ -542,9 +543,12 @@ export async function quickCreateVenv( return undefined; } - // Check if .venv already exists - let venvPath = path.join(venvRoot.fsPath, '.venv'); + const requestedName = name ?? '.venv'; + let venvPath = path.join(venvRoot.fsPath, requestedName); if (await fsapi.pathExists(venvPath)) { + if (name !== undefined) { + return { envCreationErr: VenvManagerStrings.venvNameErrorExists }; + } // increment to create a unique name, e.g. .venv-1 let i = 1; while (await fsapi.pathExists(`${venvPath}-${i}`)) { @@ -567,7 +571,7 @@ export async function createPythonVenv( manager: EnvironmentManager, basePythons: PythonEnvironment[], venvRoot: Uri, - options: { showQuickAndCustomOptions: boolean; additionalPackages?: string[] }, + options: { showQuickAndCustomOptions: boolean; additionalPackages?: string[]; name?: string }, ): Promise { return createStepBasedVenvFlow(nativeFinder, api, log, manager, basePythons, venvRoot, options); } diff --git a/src/managers/common/registeredManagers.ts b/src/managers/common/registeredManagers.ts index 4c929eaa2..775db6f50 100644 --- a/src/managers/common/registeredManagers.ts +++ b/src/managers/common/registeredManagers.ts @@ -3,7 +3,10 @@ import type { Pep440Version } from '@renovatebot/pep440'; import { CancellationError, Disposable, Event, LogOutputChannel, MarkdownString, RelativePattern } from 'vscode'; -import { PackageVersionLookupNotSupportedError } from '../../publicErrors'; +import { + CreateEnvironmentOptionNotSupportedError, + PackageVersionLookupNotSupportedError, +} from '../../publicErrors'; import { ISSUES_URL } from '../../common/constants'; import { CreateEnvironmentNotSupported, RemoveEnvironmentNotSupported } from '../../common/errors/NotSupportedError'; import { traceWarn } from '../../common/logging'; @@ -69,15 +72,30 @@ export class InternalEnvironmentManager implements EnvironmentManager { public get log(): LogOutputChannel | undefined { return this.manager.log; } + public get createCapabilities() { + return this.manager.createCapabilities; + } public get supportsCreate(): boolean { return this.manager.create !== undefined; } + public get supportsCustomName(): boolean { + return this.createCapabilities?.customName === true; + } + create( scope: CreateEnvironmentScope, options: CreateEnvironmentOptions | undefined, ): Promise { + if (options?.name !== undefined && !this.supportsCustomName) { + return Promise.reject( + new CreateEnvironmentOptionNotSupportedError( + 'name', + `Environment manager does not support named environment creation: ${this.id}`, + ), + ); + } if (this.manager.create) { return this.manager.create(scope, options); } diff --git a/src/managers/conda/condaEnvManager.ts b/src/managers/conda/condaEnvManager.ts index ad4ce00c1..8d7aa7b80 100644 --- a/src/managers/conda/condaEnvManager.ts +++ b/src/managers/conda/condaEnvManager.ts @@ -83,6 +83,7 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { description?: string; tooltip: string | MarkdownString; iconPath?: IconPath; + readonly createCapabilities = { customName: true } as const; public dispose() { this.collection = []; @@ -217,10 +218,12 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { let result: PythonEnvironment | undefined; if (options?.quickCreate) { let envRoot: string | undefined = undefined; - let name: string | undefined = './.conda'; + let name: string | undefined = options.name ?? './.conda'; if (context === 'global' || (Array.isArray(context) && context.length > 1)) { envRoot = await getDefaultCondaPrefix(); - name = await generateName(envRoot); + if (options.name === undefined) { + name = await generateName(envRoot); + } } else { const folder = this.api.getPythonProject(context instanceof Uri ? context : context[0]); envRoot = folder?.uri.fsPath; @@ -240,6 +243,7 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { this.log, this, context === 'global' ? undefined : context, + options?.name, ); } if (result) { diff --git a/src/managers/conda/condaStepBasedFlow.ts b/src/managers/conda/condaStepBasedFlow.ts index 8b5b2ced7..6cc14f79f 100644 --- a/src/managers/conda/condaStepBasedFlow.ts +++ b/src/managers/conda/condaStepBasedFlow.ts @@ -35,6 +35,8 @@ interface CondaCreationState { // For named environments envName?: string; + suppliedName?: boolean; + cancelled?: boolean; // For prefix environments prefix?: string; @@ -82,6 +84,7 @@ async function selectEnvironmentType(state: CondaCreationState): Promise { // Initialize the state object that will track user selections const state: CondaCreationState = { api: api, uris: Array.isArray(uris) ? uris : uris ? [uris] : [], + envType: name === undefined ? undefined : getCondaNamedLabel(), + envName: name, + suppliedName: name !== undefined, }; try { // Start with the first step - let currentStep: StepFunction | null = selectEnvironmentType; + let currentStep: StepFunction | null = name === undefined ? selectEnvironmentType : selectPythonVersion; // Execute steps until completion or cancellation while (currentStep !== null) { currentStep = await currentStep(state); } + if (state.cancelled) { + return undefined; + } + // If we have all required data, create the environment if (state.envType === getCondaNamedLabel() && state.envName) { return await createNamedCondaEnvironment(api, log, manager, state.envName, state.pythonVersion); } else if (state.envType === CondaStrings.condaPrefix && state.prefix) { - // For prefix environments, we need to pass the fsPath where the environment will be created - return await createPrefixCondaEnvironment(api, log, manager, state.fsPath, state.pythonVersion); + return await createPrefixCondaEnvironment(api, log, manager, state.prefix, state.pythonVersion); } // If we get here, the flow was likely cancelled @@ -310,7 +327,7 @@ export async function createStepBasedCondaFlow( if (ex === QuickInputButtons.Back) { // This should not happen as back navigation is handled within each step // But if it does, restart the flow - return await createStepBasedCondaFlow(api, log, manager, uris); + return await createStepBasedCondaFlow(api, log, manager, uris, name); } throw ex; // Re-throw other errors } diff --git a/src/managers/conda/condaUtils.ts b/src/managers/conda/condaUtils.ts index 9034a8e03..e13add4c1 100644 --- a/src/managers/conda/condaUtils.ts +++ b/src/managers/conda/condaUtils.ts @@ -1003,8 +1003,9 @@ export async function createCondaEnvironment( log: LogOutputChannel, manager: EnvironmentManager, uris?: Uri | Uri[], + name?: string, ): Promise { - return createStepBasedCondaFlow(api, log, manager, uris); + return createStepBasedCondaFlow(api, log, manager, uris, name); } function getCondaCreatePrefix(output: string): string { @@ -1026,22 +1027,22 @@ export async function createNamedCondaEnvironment( name?: string, pythonVersion?: string, ): Promise { - try { - name = await showInputBoxWithButtons({ - prompt: CondaStrings.condaNamedInput, - value: name, - ignoreFocusOut: true, - showBackButton: true, - }); - if (!name) { - return; - } - } catch (ex) { - if (ex === QuickInputButtons.Back) { - // If back button was pressed, go back to the environment type selection - return await createCondaEnvironment(api, log, manager); + if (name === undefined) { + try { + name = await showInputBoxWithButtons({ + prompt: CondaStrings.condaNamedInput, + ignoreFocusOut: true, + showBackButton: true, + }); + if (!name) { + return; + } + } catch (ex) { + if (ex === QuickInputButtons.Back) { + return await createCondaEnvironment(api, log, manager); + } + throw ex; } - throw ex; } const envName: string = name; @@ -1091,81 +1092,50 @@ export async function createPrefixCondaEnvironment( api: PythonEnvironmentApi, log: LogOutputChannel, manager: EnvironmentManager, - fsPath?: string, + prefix?: string, pythonVersion?: string, ): Promise { - try { - if (!fsPath) { - return; - } - - let name = `./.conda`; - if (await fse.pathExists(path.join(fsPath, '.conda'))) { - log.warn(`Environment "${path.join(fsPath, '.conda')}" already exists`); - const newName = await showInputBoxWithButtons({ - prompt: l10n.t('Environment "{0}" already exists. Enter a different name', name), - ignoreFocusOut: true, - showBackButton: true, - validateInput: (value) => { - if (value === name) { - return CondaStrings.condaExists; - } - return undefined; - }, - }); - if (!newName) { - return; - } - name = newName; - } + if (!prefix) { + return; + } - const prefix: string = path.isAbsolute(name) ? name : path.join(fsPath, name); + const runArgs = ['create', '--yes', '--prefix', prefix]; + if (pythonVersion) { + runArgs.push(`python=${pythonVersion}`); + } else { + runArgs.push('python'); + } - const runArgs = ['create', '--yes', '--prefix', prefix]; - if (pythonVersion) { - runArgs.push(`python=${pythonVersion}`); - } else { - runArgs.push('python'); - } + return await withProgress( + { + location: ProgressLocation.Notification, + title: l10n.t('Creating conda environment: {0}', path.basename(prefix)), + }, + async () => { + try { + const bin = os.platform() === 'win32' ? 'python.exe' : path.join('bin', 'python'); + const output = await runCondaExecutable(runArgs); + log.info(output); + const version = await getVersion(prefix); - return await withProgress( - { - location: ProgressLocation.Notification, - title: `Creating conda environment: ${name}`, - }, - async () => { - try { - const bin = os.platform() === 'win32' ? 'python.exe' : path.join('bin', 'python'); - const output = await runCondaExecutable(runArgs); - log.info(output); - const version = await getVersion(prefix); - - const environment = api.createPythonEnvironmentItem( - await getPrefixesCondaPythonInfo( - prefix, - path.join(prefix, bin), - version, - await getConda(), - manager, - ), + return api.createPythonEnvironmentItem( + await getPrefixesCondaPythonInfo( + prefix, + path.join(prefix, bin), + version, + await getConda(), manager, - ); - return environment; - } catch (e) { - log.error('Failed to create conda environment', e); - setImmediate(async () => { - await showErrorMessageWithLogs(CondaStrings.condaCreateFailed, log); - }); - } - }, - ); - } catch (ex) { - if (ex === QuickInputButtons.Back) { - // If back button was pressed, go back to the environment type selection - return await createCondaEnvironment(api, log, manager); - } - throw ex; - } + ), + manager, + ); + } catch (e) { + log.error('Failed to create conda environment', e); + setImmediate(async () => { + await showErrorMessageWithLogs(CondaStrings.condaCreateFailed, log); + }); + } + }, + ); } export async function generateName(fsPath: string): Promise { diff --git a/src/publicErrors.ts b/src/publicErrors.ts index f4799d8ce..ea152864b 100644 --- a/src/publicErrors.ts +++ b/src/publicErrors.ts @@ -56,3 +56,40 @@ export function isPackageVersionLookupNotSupportedError( (error as { code?: unknown }).code === 'PackageVersionLookupNotSupported') ); } + +/** + * Error thrown when an environment manager cannot honor a requested creation option. + */ +export class CreateEnvironmentOptionNotSupportedError extends Error { + /** + * Stable discriminator identifying this error type across bundle boundaries. + */ + public readonly code = 'CreateEnvironmentOptionNotSupported'; + + constructor( + public readonly option: 'name', + message?: string, + ) { + super(message ?? `The environment manager does not support the "${option}" creation option.`); + this.name = 'CreateEnvironmentOptionNotSupportedError'; + Object.setPrototypeOf(this, CreateEnvironmentOptionNotSupportedError.prototype); + } +} + +/** + * Reports whether an error represents an unsupported environment creation option. + * + * @param error The value to test. + * @returns `true` when the error carries the stable unsupported-option discriminator. + */ +export function isCreateEnvironmentOptionNotSupportedError( + error: unknown, +): error is CreateEnvironmentOptionNotSupportedError { + return ( + error instanceof CreateEnvironmentOptionNotSupportedError || + (typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === 'CreateEnvironmentOptionNotSupported') + ); +} diff --git a/src/test/common/pathUtils.unit.test.ts b/src/test/common/pathUtils.unit.test.ts index c9bdd6150..c57079e99 100644 --- a/src/test/common/pathUtils.unit.test.ts +++ b/src/test/common/pathUtils.unit.test.ts @@ -1,7 +1,12 @@ import assert from 'node:assert'; import * as sinon from 'sinon'; import { Uri } from 'vscode'; -import { getResourceUri, isWindowsReservedDeviceName, normalizePath } from '../../common/utils/pathUtils'; +import { + getResourceUri, + isValidPortablePathSegment, + isWindowsReservedDeviceName, + normalizePath, +} from '../../common/utils/pathUtils'; import * as utils from '../../common/utils/platformUtils'; suite('Path Utilities', () => { @@ -164,4 +169,35 @@ suite('Path Utilities', () => { } }); }); + + suite('isValidPortablePathSegment', () => { + test('accepts names that are valid on all supported platforms', () => { + for (const name of ['env', 'python-3.12', '.venv', 'data science', '分析']) { + assert.strictEqual(isValidPortablePathSegment(name), true, `${name} should be valid`); + } + }); + + test('rejects path traversal, reserved characters, and control characters', () => { + for (const name of [ + '', + ' ', + '.', + '..', + '../outside', + '..\\outside', + 'python:3.12', + 'env?', + 'env\0', + 'env\n', + ]) { + assert.strictEqual(isValidPortablePathSegment(name), false, `${JSON.stringify(name)} should be invalid`); + } + }); + + test('rejects Windows device names and trailing periods or spaces on every platform', () => { + for (const name of ['CON', 'nul', 'COM1.txt', 'lpt9.env', 'env.', 'env ']) { + assert.strictEqual(isValidPortablePathSegment(name), false, `${JSON.stringify(name)} should be invalid`); + } + }); + }); }); diff --git a/src/test/extensionApi.unit.test.ts b/src/test/extensionApi.unit.test.ts index 62459fbc3..21cf35337 100644 --- a/src/test/extensionApi.unit.test.ts +++ b/src/test/extensionApi.unit.test.ts @@ -2,9 +2,11 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import { EventEmitter, Uri } from 'vscode'; import { + isCreateEnvironmentOptionNotSupportedError, PythonEnvironment, PythonProject, } from '../api'; +import * as managerPickers from '../common/pickers/managers'; import * as managerReady from '../features/common/managerReady'; import { PythonEnvironmentApiImpl } from '../extensionApi'; import type { PythonProjectManager } from '../features/projectManager'; @@ -64,6 +66,132 @@ suite('PythonEnvironmentApiImpl - onDidChangePythonProjects', () => { }); }); +suite('PythonEnvironmentApiImpl - createEnvironment', () => { + setup(() => { + sinon.stub(managerReady, 'waitForEnvManager').resolves(); + }); + + teardown(() => { + sinon.restore(); + }); + + function createApi(create: sinon.SinonStub, supportsCustomName = true): PythonEnvironmentApiImpl { + type ApiArgs = ConstructorParameters; + const mockEnvManagers = { + onDidChangeActiveEnvironment: new EventEmitter().event, + onDidChangePackageProviderPackages: new EventEmitter().event, + getEnvironmentManager: sinon.stub().returns({ + id: 'ms-python.python:venv', + supportsCreate: true, + supportsCustomName, + create, + }), + } as unknown as ApiArgs[0]; + + return new PythonEnvironmentApiImpl( + mockEnvManagers, + { getProjects: () => [], onDidChangeProjects: new EventEmitter().event } as unknown as ApiArgs[1], + {} as unknown as ApiArgs[2], + {} as unknown as ApiArgs[3], + { onDidChangeEnvironmentVariables: new EventEmitter().event } as unknown as ApiArgs[4], + ); + } + + test('forwards an explicit environment name to the selected manager', async () => { + const created = {} as PythonEnvironment; + const create = sinon.stub().resolves(created); + const api = createApi(create); + const scope = Uri.file('workspace'); + const options = { name: 'analysis-env', quickCreate: true }; + + const result = await api.createEnvironment(scope, options); + + assert.strictEqual(result, created); + assert.ok(create.calledOnceWithExactly(scope, options)); + }); + + test('rejects invalid environment names', async () => { + const create = sinon.stub(); + const api = createApi(create); + + for (const name of [ + ' ', + '.', + '..', + '../outside', + '..\\outside', + 'nested/name', + 'nested\\name', + 'python:3.12', + 'CON', + 'env.', + 'env ', + ]) { + await assert.rejects( + api.createEnvironment(Uri.file('workspace'), { name }), + /must be a valid portable path segment/, + ); + } + + assert.ok(create.notCalled); + }); + + test('rejects a name before calling a manager that does not support custom names', async () => { + const create = sinon.stub(); + const api = createApi(create, false); + + await assert.rejects( + api.createEnvironment(Uri.file('workspace'), { name: 'analysis-env' }), + isCreateEnvironmentOptionNotSupportedError, + ); + + assert.ok(create.notCalled); + }); + + test('only offers managers supporting custom names for multi-scope creation', async () => { + type ApiArgs = ConstructorParameters; + const unsupportedCreate = sinon.stub(); + const supportedCreate = sinon.stub().resolves({} as PythonEnvironment); + const unsupportedManager = { + id: 'example:immutable', + supportsCreate: true, + supportsCustomName: false, + create: unsupportedCreate, + }; + const supportedManager = { + id: 'example:venv', + supportsCreate: true, + supportsCustomName: true, + create: supportedCreate, + }; + const firstScope = Uri.file('first'); + const secondScope = Uri.file('second'); + const mockEnvManagers = { + onDidChangeActiveEnvironment: new EventEmitter().event, + onDidChangePackageProviderPackages: new EventEmitter().event, + getEnvironmentManager: sinon.stub(), + }; + mockEnvManagers.getEnvironmentManager.withArgs(firstScope).returns(unsupportedManager); + mockEnvManagers.getEnvironmentManager.withArgs(secondScope).returns(supportedManager); + const pickManager = sinon.stub(managerPickers, 'pickEnvironmentManager').resolves(supportedManager.id); + const api = new PythonEnvironmentApiImpl( + mockEnvManagers as unknown as ApiArgs[0], + { getProjects: () => [], onDidChangeProjects: new EventEmitter().event } as unknown as ApiArgs[1], + {} as unknown as ApiArgs[2], + {} as unknown as ApiArgs[3], + { onDidChangeEnvironmentVariables: new EventEmitter().event } as unknown as ApiArgs[4], + ); + const options = { name: 'analysis-env' }; + + await api.createEnvironment([firstScope, secondScope], options); + + assert.strictEqual(pickManager.callCount, 1); + assert.deepStrictEqual(pickManager.firstCall.args[0], [supportedManager]); + assert.ok(supportedCreate.calledOnceWithExactly([firstScope, secondScope], options)); + assert.ok(unsupportedCreate.notCalled); + }); +}); + suite('PythonEnvironmentApiImpl - getEnvironment timeout fallback', () => { let clock: sinon.SinonFakeTimers; diff --git a/src/test/features/envCommands.unit.test.ts b/src/test/features/envCommands.unit.test.ts index 64808d382..f45190216 100644 --- a/src/test/features/envCommands.unit.test.ts +++ b/src/test/features/envCommands.unit.test.ts @@ -163,6 +163,27 @@ suite('Create Any Environment Command Tests', () => { manager.verifyAll(); }); + test('Named creation only offers managers that support custom names', async () => { + const unsupportedManager = typeMoq.Mock.ofType(); + unsupportedManager.setup((m) => m.supportsCreate).returns(() => true); + unsupportedManager.setup((m) => m.supportsCustomName).returns(() => false); + manager.setup((m) => m.supportsCustomName).returns(() => true); + em.setup((e) => e.managers).returns(() => [unsupportedManager.object, manager.object]); + pm.setup((p) => p.getProjects(typeMoq.It.isAny())).returns(() => []); + manager + .setup((m) => m.create('global', typeMoq.It.isValue({ name: 'analysis-env' }))) + .returns(() => Promise.resolve(env.object)) + .verifiable(typeMoq.Times.once()); + pickEnvironmentManagerStub.resolves(manager.object.id); + + const result = await createAnyEnvironmentCommand(em.object, pm.object, { name: 'analysis-env' }); + + assert.strictEqual(result, env.object); + assert.deepStrictEqual(pickEnvironmentManagerStub.firstCall.args[0], [manager.object]); + assert.strictEqual(pickEnvironmentManagerStub.firstCall.args[3], false); + manager.verifyAll(); + }); + test('Create global venv (no-workspace): select', async () => { pm.setup((p) => p.getProjects(typeMoq.It.isAny())).returns(() => []); manager diff --git a/src/test/managers/builtin/venvManager.createRemove.unit.test.ts b/src/test/managers/builtin/venvManager.createRemove.unit.test.ts index 12bf1c7b3..3c68484fd 100644 --- a/src/test/managers/builtin/venvManager.createRemove.unit.test.ts +++ b/src/test/managers/builtin/venvManager.createRemove.unit.test.ts @@ -19,6 +19,7 @@ import { normalizePath } from '../../../common/utils/pathUtils'; import * as windowApis from '../../../common/window.apis'; import * as envCommands from '../../../features/envCommands'; import { VenvManager } from '../../../managers/builtin/venvManager'; +import { createStepBasedVenvFlow } from '../../../managers/builtin/venvStepBasedFlow'; import * as venvUtils from '../../../managers/builtin/venvUtils'; import { NativePythonFinder } from '../../../managers/common/nativePythonFinder'; import { createMockPythonEnvironment } from '../../mocks/pythonEnvironment'; @@ -143,6 +144,67 @@ suite('VenvManager.create - orchestration', () => { assert.deepStrictEqual(quickCreateVenvStub.firstCall.args[6], ['pytest']); }); + test('quick create forwards an explicit environment name', async () => { + const globalEnv = createMockPythonEnvironment({ + name: 'global', + envPath: testPath('global', 'python3'), + version: '3.12.0', + }); + const manager = createManager({ getEnvironments: sinon.stub().resolves([globalEnv]) }); + (manager as any).globalEnv = globalEnv; + quickCreateVenvStub.resolves({ environment: createdEnvironment() }); + + await manager.create(Uri.file(path.join(tmpRoot, 'project')), { + name: 'analysis-env', + quickCreate: true, + }); + + assert.strictEqual(quickCreateVenvStub.firstCall.args[7], 'analysis-env'); + }); + + test('custom create forwards an explicit environment name', async () => { + const globalEnv = createMockPythonEnvironment({ + name: 'global', + envPath: testPath('global', 'python3'), + version: '3.12.0', + }); + const manager = createManager({ getEnvironments: sinon.stub().resolves([globalEnv]) }); + createPythonVenvStub.resolves({ environment: createdEnvironment() }); + + await manager.create(Uri.file(path.join(tmpRoot, 'project')), { + name: 'analysis-env', + additionalPackages: ['pytest'], + }); + + assert.deepStrictEqual(createPythonVenvStub.firstCall.args[6], { + showQuickAndCustomOptions: true, + name: 'analysis-env', + additionalPackages: ['pytest'], + }); + }); + + test('custom create rejects an explicit name whose destination exists', async () => { + const globalEnv = createMockPythonEnvironment({ + name: 'global', + envPath: testPath('global', 'python3'), + version: '3.12.0', + }); + const venvRoot = Uri.file(path.join(tmpRoot, 'project')); + await fse.mkdirp(path.join(venvRoot.fsPath, 'analysis-env')); + + const result = await createStepBasedVenvFlow( + {} as NativePythonFinder, + { getPythonProject: sinon.stub().returns(undefined) } as unknown as PythonEnvironmentApi, + { error: sinon.stub() } as any, + {} as EnvironmentManager, + [globalEnv], + venvRoot, + { showQuickAndCustomOptions: false, name: 'analysis-env' }, + ); + + assert.strictEqual(result?.envCreationErr, 'A folder with the same name already exists'); + }); + test('reports creation errors without adding an environment', async () => { const manager = createManager({ getEnvironments: sinon.stub().resolves([ diff --git a/src/test/managers/common/registeredManagers.create.unit.test.ts b/src/test/managers/common/registeredManagers.create.unit.test.ts new file mode 100644 index 000000000..9ded3d58d --- /dev/null +++ b/src/test/managers/common/registeredManagers.create.unit.test.ts @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import * as sinon from 'sinon'; +import { Uri } from 'vscode'; +import { + EnvironmentManager, + isCreateEnvironmentOptionNotSupportedError, + PythonEnvironment, +} from '../../../api'; +import { InternalEnvironmentManager } from '../../../managers/common/registeredManagers'; + +function createManager( + create: sinon.SinonStub, + customName?: boolean, +): InternalEnvironmentManager { + return new InternalEnvironmentManager('example:manager', { + name: 'manager', + preferredPackageManagerId: 'example:pip', + createCapabilities: customName === undefined ? undefined : { customName }, + create, + } as unknown as EnvironmentManager); +} + +suite('InternalEnvironmentManager.create', () => { + test('forwards names when the manager advertises custom-name support', async () => { + const environment = {} as PythonEnvironment; + const create = sinon.stub().resolves(environment); + const manager = createManager(create, true); + const scope = Uri.file('workspace'); + const options = { name: 'analysis-env' }; + + assert.strictEqual(await manager.create(scope, options), environment); + assert.ok(create.calledOnceWithExactly(scope, options)); + }); + + test('rejects names when the capability is false or omitted', async () => { + for (const customName of [false, undefined]) { + const create = sinon.stub(); + const manager = createManager(create, customName); + + await assert.rejects( + manager.create(Uri.file('workspace'), { name: 'analysis-env' }), + isCreateEnvironmentOptionNotSupportedError, + ); + assert.ok(create.notCalled); + } + }); + + test('preserves unnamed creation for managers without the capability', async () => { + const environment = {} as PythonEnvironment; + const create = sinon.stub().resolves(environment); + const manager = createManager(create); + const scope = Uri.file('workspace'); + + assert.strictEqual(await manager.create(scope, undefined), environment); + assert.ok(create.calledOnceWithExactly(scope, undefined)); + }); +}); diff --git a/src/test/managers/conda/condaEnvManager.createRemove.unit.test.ts b/src/test/managers/conda/condaEnvManager.createRemove.unit.test.ts index 19d4385ff..01d56d50c 100644 --- a/src/test/managers/conda/condaEnvManager.createRemove.unit.test.ts +++ b/src/test/managers/conda/condaEnvManager.createRemove.unit.test.ts @@ -4,7 +4,7 @@ import * as fse from 'fs-extra'; import * as os from 'os'; import * as path from 'path'; import * as sinon from 'sinon'; -import { QuickPickItem, Uri } from 'vscode'; +import { QuickInputButtons, QuickPickItem, Uri } from 'vscode'; import { DidChangeEnvironmentEventArgs, DidChangeEnvironmentsEventArgs, @@ -116,6 +116,97 @@ suite('CondaEnvManager.create - step-based flow', () => { assert.ok(createNamedStub.calledOnceWithExactly(api, sinon.match.any, sinon.match.any, 'fallback-env', '3.12')); assert.ok(createPrefixStub.notCalled); }); + + test('uses a supplied name without showing the environment type or name prompts', async () => { + const createdEnvironment = {} as PythonEnvironment; + const showQuickPickStub = sinon + .stub(windowApis, 'showQuickPickWithButtons') + .resolves({ label: 'Python', description: '3.12' } as QuickPickItem); + const showInputBoxStub = sinon.stub(windowApis, 'showInputBoxWithButtons'); + const createNamedStub = sinon.stub(condaUtils, 'createNamedCondaEnvironment').resolves(createdEnvironment); + const api = { + getEnvironments: sinon.stub().resolves([]), + getPythonProject: sinon.stub().returns(undefined), + } as unknown as PythonEnvironmentApi; + + const result = await createStepBasedCondaFlow( + api, + createMockLogOutputChannel(), + {} as EnvironmentManager, + Uri.file('workspace'), + 'analysis-env', + ); + + assert.strictEqual(result, createdEnvironment); + assert.ok(showQuickPickStub.calledOnce); + assert.ok(showInputBoxStub.notCalled); + assert.ok(createNamedStub.calledOnceWithExactly(api, sinon.match.any, sinon.match.any, 'analysis-env', '3.12')); + }); + + test('does not replace a supplied name when navigating back from Python selection', async () => { + const showQuickPickStub = sinon.stub(windowApis, 'showQuickPickWithButtons').rejects(QuickInputButtons.Back); + const showInputBoxStub = sinon.stub(windowApis, 'showInputBoxWithButtons'); + const createNamedStub = sinon.stub(condaUtils, 'createNamedCondaEnvironment'); + const api = { + getEnvironments: sinon.stub().resolves([]), + getPythonProject: sinon.stub().returns(undefined), + } as unknown as PythonEnvironmentApi; + + const result = await createStepBasedCondaFlow( + api, + createMockLogOutputChannel(), + {} as EnvironmentManager, + Uri.file('workspace'), + 'analysis-env', + ); + + assert.strictEqual(result, undefined); + assert.ok(showQuickPickStub.calledOnce); + assert.ok(showInputBoxStub.notCalled); + assert.ok(createNamedStub.notCalled); + }); + + test('uses the prefix name selected by the user', async () => { + const tempRoot = await fse.mkdtemp(path.join(os.tmpdir(), 'conda-prefix-flow-')); + try { + await fse.mkdirp(path.join(tempRoot, '.conda')); + const showQuickPickStub = sinon.stub(windowApis, 'showQuickPickWithButtons'); + showQuickPickStub + .onFirstCall() + .resolves({ label: CondaStrings.condaPrefix, description: 'Prefix' } as QuickPickItem); + showQuickPickStub.onSecondCall().resolves({ label: 'Python', description: '3.12' } as QuickPickItem); + sinon.stub(windowApis, 'showInputBoxWithButtons').resolves('analysis-env'); + sinon.stub(condaUtils, 'getLocation').resolves(tempRoot); + const createdEnvironment = {} as PythonEnvironment; + const createPrefixStub = sinon + .stub(condaUtils, 'createPrefixCondaEnvironment') + .resolves(createdEnvironment); + const api = { + getEnvironments: sinon.stub().resolves([]), + getPythonProject: sinon.stub().returns(undefined), + } as unknown as PythonEnvironmentApi; + + const result = await createStepBasedCondaFlow( + api, + createMockLogOutputChannel(), + {} as EnvironmentManager, + Uri.file('workspace'), + ); + + assert.strictEqual(result, createdEnvironment); + assert.ok( + createPrefixStub.calledOnceWithExactly( + api, + sinon.match.any, + sinon.match.any, + path.join(tempRoot, 'analysis-env'), + '3.12', + ), + ); + } finally { + await fse.remove(tempRoot); + } + }); }); suite('CondaEnvManager.create - orchestration', () => { @@ -174,6 +265,35 @@ suite('CondaEnvManager.create - orchestration', () => { assert.deepStrictEqual(quickCreateStub.firstCall.args[5], ['pytest']); }); + test('global quick create uses an explicit name instead of generating one', async () => { + const manager = createManager(); + const env = makeEnv('analysis-env', testPath('miniconda3', 'envs', 'analysis-env'), '3.12.0'); + quickCreateStub.resolves(env); + + const result = await manager.create('global', { + name: 'analysis-env', + quickCreate: true, + }); + + assert.strictEqual(result, env); + assert.ok(getDefaultPrefixStub.calledOnce); + assert.ok(generateNameStub.notCalled); + assert.strictEqual(quickCreateStub.firstCall.args[4], 'analysis-env'); + }); + + test('custom create forwards an explicit name to the interactive flow', async () => { + const manager = createManager(); + const env = makeEnv('analysis-env', testPath('miniconda3', 'envs', 'analysis-env'), '3.12.0'); + createCondaStub.resolves(env); + const scope = Uri.file(testPath('workspace', 'project')); + + const result = await manager.create(scope, { name: 'analysis-env' }); + + assert.strictEqual(result, env); + assert.strictEqual(createCondaStub.firstCall.args[3], scope); + assert.strictEqual(createCondaStub.firstCall.args[4], 'analysis-env'); + }); + test('project quick create uses the project root and writes .gitignore', async () => { const tempRoot = await fse.mkdtemp(path.join(os.tmpdir(), 'condamgr-')); try { diff --git a/src/types.ts b/src/types.ts index 8699ce618..0609e51e0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -281,6 +281,19 @@ export type RefreshEnvironmentsScope = Uri | undefined; */ export type GetEnvironmentsScope = Uri | 'all' | 'global'; +/** + * Capabilities supported when creating an environment. + */ +export interface CreateEnvironmentCapabilities { + /** + * Whether the manager can create an environment with an exact caller-supplied name. + * + * When `true`, the manager must use {@link CreateEnvironmentOptions.name} exactly or + * reject the request. When `false` or omitted, named creation is unsupported. + */ + readonly customName?: boolean; +} + /** * Event arguments for when the current Python environment changes. */ @@ -411,6 +424,13 @@ export interface EnvironmentManager { */ readonly log?: LogOutputChannel; + /** + * Capabilities supported by this manager's {@link EnvironmentManager.create} implementation. + * + * Omitted capabilities are treated as unsupported. + */ + readonly createCapabilities?: CreateEnvironmentCapabilities; + /** * The quick create details for the environment manager. Having this method also enables the quick create feature * for the environment manager. Should Implement {@link EnvironmentManager.create} to support quick create. @@ -420,13 +440,16 @@ export interface EnvironmentManager { /** * Creates a new Python environment within the specified scope. Create should support adding a .gitignore file if it creates a folder within the workspace. If a manager does not support environment creation, do not implement this method; the UI disables "create" options when `this.manager.create === undefined`. * @param scope - The scope within which to create the environment. - * @param options - Optional parameters for creating the Python environment. + * @param options - Optional parameters for creating the Python environment, including its name. * @returns A promise that resolves to the created Python environment, or undefined if creation failed. * * @remarks * Invoked when an environment of this manager's type should be created for the given * scope. Typical triggers include user-initiated environment-creation flows and - * programmatic creation via the API. + * programmatic creation via the API. Managers advertising + * `createCapabilities.customName` must use a supplied {@link CreateEnvironmentOptions.name} + * exactly or reject it. The API rejects named creation before invoking managers that do + * not advertise this capability. */ create?(scope: CreateEnvironmentScope, options?: CreateEnvironmentOptions): Promise; @@ -983,6 +1006,14 @@ export type PackageManagementOptions = PackageManagementInteractionOptions & * Options for creating a Python environment. */ export interface CreateEnvironmentOptions { + /** + * Portable path segment to use as the new environment's name. Directory separators, + * control characters, Windows-reserved characters and device names, trailing periods + * or spaces, `.` and `..` are not allowed. The selected manager must advertise + * {@link CreateEnvironmentCapabilities.customName}; otherwise creation rejects. When + * omitted, the environment manager may prompt for a name or choose a default. + */ + name?: string; /** * Provides some context about quick create based on user input. * - if true, the environment should be created without any user input or prompts.