diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index eb86291b..4b8ba436 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -5,6 +5,12 @@ 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. + ## [1.5.0] ### Added diff --git a/api/package-lock.json b/api/package-lock.json index 62fda10a..37039057 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 ada2db29..b2be12d9 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 bbeb1b09..0d0b7aef 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. When supplied, the manager uses that name without prompting for another. 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,7 +508,7 @@ 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. @@ -516,6 +518,7 @@ errors as well as `undefined`. ```typescript const created = await api.createEnvironment(projectUri, { + name: 'analysis-env', quickCreate: true, additionalPackages: ['requests'], }); diff --git a/src/common/utils/pathUtils.ts b/src/common/utils/pathUtils.ts index df796e5f..fe012651 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 337322ed..50e57ffb 100644 --- a/src/extensionApi.ts +++ b/src/extensionApi.ts @@ -40,7 +40,7 @@ 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); diff --git a/src/managers/builtin/venvManager.ts b/src/managers/builtin/venvManager.ts index 3d785962..89997bd5 100644 --- a/src/managers/builtin/venvManager.ts +++ b/src/managers/builtin/venvManager.ts @@ -185,6 +185,7 @@ export class VenvManager implements EnvironmentManager { this.globalEnv, venvRoot, options?.additionalPackages, + options?.name, ); } } else { @@ -192,6 +193,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 b5ca2b4b..f513c174 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 e70cc79d..922a3f05 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/conda/condaEnvManager.ts b/src/managers/conda/condaEnvManager.ts index ad4ce00c..d540f12f 100644 --- a/src/managers/conda/condaEnvManager.ts +++ b/src/managers/conda/condaEnvManager.ts @@ -217,10 +217,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 +242,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 8b5b2ced..6cc14f79 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 9034a8e0..e13add4c 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/test/common/pathUtils.unit.test.ts b/src/test/common/pathUtils.unit.test.ts index c9bdd615..c57079e9 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 62459fbc..47b9d8f2 100644 --- a/src/test/extensionApi.unit.test.ts +++ b/src/test/extensionApi.unit.test.ts @@ -64,6 +64,76 @@ suite('PythonEnvironmentApiImpl - onDidChangePythonProjects', () => { }); }); +suite('PythonEnvironmentApiImpl - createEnvironment', () => { + setup(() => { + sinon.stub(managerReady, 'waitForEnvManager').resolves(); + }); + + teardown(() => { + sinon.restore(); + }); + + function createApi(create: sinon.SinonStub): 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, + 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); + }); +}); + suite('PythonEnvironmentApiImpl - getEnvironment timeout fallback', () => { let clock: sinon.SinonFakeTimers; diff --git a/src/test/managers/builtin/venvManager.createRemove.unit.test.ts b/src/test/managers/builtin/venvManager.createRemove.unit.test.ts index 12bf1c7b..3c68484f 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/conda/condaEnvManager.createRemove.unit.test.ts b/src/test/managers/conda/condaEnvManager.createRemove.unit.test.ts index 19d4385f..01d56d50 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 8699ce61..65da1db4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -420,13 +420,14 @@ 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. Implementations should use a supplied + * {@link CreateEnvironmentOptions.name} or reject it when the requested name is invalid. */ create?(scope: CreateEnvironmentScope, options?: CreateEnvironmentOptions): Promise; @@ -983,6 +984,13 @@ 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. 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.