From 4495ec718ca17aae4238efe2f7bd92dc7bf0eea6 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Mon, 21 Sep 2026 17:52:06 -0700 Subject: [PATCH 1/5] API: create environment with name --- api/CHANGELOG.md | 6 + api/package-lock.json | 4 +- api/package.json | 2 +- docs/README.md | 5 +- src/extensionApi.ts | 11 ++ src/managers/builtin/venvManager.ts | 5 + src/managers/builtin/venvStepBasedFlow.ts | 17 ++- src/managers/builtin/venvUtils.ts | 10 +- src/managers/conda/condaEnvManager.ts | 7 +- src/managers/conda/condaStepBasedFlow.ts | 29 +++- src/managers/conda/condaUtils.ts | 140 +++++++----------- src/test/extensionApi.unit.test.ts | 57 +++++++ .../venvManager.createRemove.unit.test.ts | 62 ++++++++ .../condaEnvManager.createRemove.unit.test.ts | 122 ++++++++++++++- src/types.ts | 11 +- 15 files changed, 378 insertions(+), 110 deletions(-) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 35ad498f7..ebd84fa1f 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.5.0] + +### Added + +- Added optional `CreateEnvironmentOptions.name` so API consumers can request a specific name when creating an environment. + ## [1.4.0] ### Changed diff --git a/api/package-lock.json b/api/package-lock.json index 4c8908c6b..62fda10a8 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vscode/python-environments", - "version": "1.4.0", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vscode/python-environments", - "version": "1.4.0", + "version": "1.5.0", "license": "MIT", "dependencies": { "@renovatebot/pep440": "^3.1.0" diff --git a/api/package.json b/api/package.json index ed2270714..cb5a50e31 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.4.0", + "version": "1.5.0", "author": { "name": "Microsoft Corporation" }, diff --git a/docs/README.md b/docs/README.md index 0d4c1e862..de4d44d94 100644 --- a/docs/README.md +++ b/docs/README.md @@ -274,11 +274,13 @@ applies to. | Field | Type | Required | Description | | --- | --- | --- | --- | +| `name` | `string` | No | Non-empty path segment to use as the new environment's name. Directory separators, `.` 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` | 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. | ```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/extensionApi.ts b/src/extensionApi.ts index 1643e40a6..8f661efd8 100644 --- a/src/extensionApi.ts +++ b/src/extensionApi.ts @@ -156,6 +156,17 @@ export class PythonEnvironmentApiImpl implements PythonEnvironmentApi { scope: CreateEnvironmentScope, options: CreateEnvironmentOptions | undefined, ): Promise { + if ( + options?.name !== undefined && + (options.name.trim().length === 0 || + options.name === '.' || + options.name === '..' || + options.name.includes('/') || + options.name.includes('\\') || + options.name.includes('\0')) + ) { + throw new Error('Environment name must be a non-empty 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 3d7859629..89997bd58 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 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 7b2de8544..3666455bd 100644 --- a/src/managers/builtin/venvUtils.ts +++ b/src/managers/builtin/venvUtils.ts @@ -514,6 +514,7 @@ export async function quickCreateVenv( baseEnv: PythonEnvironment, venvRoot: Uri, additionalPackages?: string[], + name?: string, ): Promise { const project = api.getPythonProject(venvRoot); @@ -535,9 +536,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}`)) { @@ -560,7 +564,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 ad4ce00c1..d540f12fd 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 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/test/extensionApi.unit.test.ts b/src/test/extensionApi.unit.test.ts index d49bc79c3..ad0d97601 100644 --- a/src/test/extensionApi.unit.test.ts +++ b/src/test/extensionApi.unit.test.ts @@ -57,6 +57,63 @@ 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, + 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']) { + await assert.rejects( + api.createEnvironment(Uri.file('workspace'), { name }), + /must be a non-empty 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 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/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 12c93df99..ff65712b7 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; @@ -956,6 +957,12 @@ export type PackageManagementOptions = PackageManagementInteractionOptions & * Options for creating a Python environment. */ export interface CreateEnvironmentOptions { + /** + * Non-empty path segment to use as the new environment's name. Directory + * separators, `.` 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. From 11015a9c386636de1ad4dbd6175035ccf3bfaeb0 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Mon, 28 Sep 2026 13:04:35 -0700 Subject: [PATCH 2/5] Validate API environment names Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a9796987-f9b0-451b-a3cd-35d8a950eac8 --- docs/README.md | 6 ++-- src/common/utils/pathUtils.ts | 26 ++++++++++++++++-- src/extensionApi.ts | 14 ++-------- src/test/common/pathUtils.unit.test.ts | 38 +++++++++++++++++++++++++- src/test/extensionApi.unit.test.ts | 17 ++++++++++-- src/types.ts | 7 +++-- 6 files changed, 86 insertions(+), 22 deletions(-) diff --git a/docs/README.md b/docs/README.md index 7e162c03b..0d0b7aefb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -274,9 +274,9 @@ applies to. | Field | Type | Required | Description | | --- | --- | --- | --- | -| `name` | `string` | No | Non-empty path segment to use as the new environment's name. Directory separators, `.` 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` | 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, { diff --git a/src/common/utils/pathUtils.ts b/src/common/utils/pathUtils.ts index df796e5f7..3cec4244d 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 isReservedWindowsDeviceName(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) && + !isReservedWindowsDeviceName(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() && isReservedWindowsDeviceName(value); } export function getResourceUri(resourcePath: string, root?: string): Uri | undefined { diff --git a/src/extensionApi.ts b/src/extensionApi.ts index bcbdf5c41..50e57ffbd 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,16 +157,8 @@ export class PythonEnvironmentApiImpl implements PythonEnvironmentApi { scope: CreateEnvironmentScope, options: CreateEnvironmentOptions | undefined, ): Promise { - if ( - options?.name !== undefined && - (options.name.trim().length === 0 || - options.name === '.' || - options.name === '..' || - options.name.includes('/') || - options.name.includes('\\') || - options.name.includes('\0')) - ) { - throw new Error('Environment name must be a non-empty path segment'); + 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]); 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 c988bacfb..47b9d8f27 100644 --- a/src/test/extensionApi.unit.test.ts +++ b/src/test/extensionApi.unit.test.ts @@ -77,6 +77,7 @@ suite('PythonEnvironmentApiImpl - createEnvironment', () => { type ApiArgs = ConstructorParameters; const mockEnvManagers = { onDidChangeActiveEnvironment: new EventEmitter().event, + onDidChangePackageProviderPackages: new EventEmitter().event, getEnvironmentManager: sinon.stub().returns({ id: 'ms-python.python:venv', supportsCreate: true, @@ -110,10 +111,22 @@ suite('PythonEnvironmentApiImpl - createEnvironment', () => { const create = sinon.stub(); const api = createApi(create); - for (const name of [' ', '.', '..', '../outside', '..\\outside', 'nested/name', 'nested\\name']) { + 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 non-empty path segment/, + /must be a valid portable path segment/, ); } diff --git a/src/types.ts b/src/types.ts index ea0108738..65da1db4c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -985,9 +985,10 @@ export type PackageManagementOptions = PackageManagementInteractionOptions & */ export interface CreateEnvironmentOptions { /** - * Non-empty path segment to use as the new environment's name. Directory - * separators, `.` and `..` are not allowed. When omitted, the environment manager - * may prompt for a name or choose a default. + * 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; /** From 71a44fddddc9fdb6972e5bb89c1e2fae198f2d5a Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Mon, 28 Sep 2026 13:10:35 -0700 Subject: [PATCH 3/5] Clarify Windows device name predicate Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a9796987-f9b0-451b-a3cd-35d8a950eac8 --- src/common/utils/pathUtils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/common/utils/pathUtils.ts b/src/common/utils/pathUtils.ts index 3cec4244d..fe012651d 100644 --- a/src/common/utils/pathUtils.ts +++ b/src/common/utils/pathUtils.ts @@ -87,7 +87,7 @@ export function isSameOrParentPath(parentPath: string, candidatePath: string): b ); } -function isReservedWindowsDeviceName(value: string): boolean { +function matchesWindowsReservedDeviceName(value: string): boolean { const deviceBaseName = value.split('.')[0]; return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(deviceBaseName); } @@ -106,7 +106,7 @@ export function isValidPortablePathSegment(value: string): boolean { value !== '..' && !/[<>:"/\\|?*\u0000-\u001f]/.test(value) && !/[. ]$/.test(value) && - !isReservedWindowsDeviceName(value) + !matchesWindowsReservedDeviceName(value) ); } @@ -122,7 +122,7 @@ export function isValidPortablePathSegment(value: string): boolean { * @returns `true` on Windows when `value` resolves to a reserved device name. */ export function isWindowsReservedDeviceName(value: string): boolean { - return isWindows() && isReservedWindowsDeviceName(value); + return isWindows() && matchesWindowsReservedDeviceName(value); } export function getResourceUri(resourcePath: string, root?: string): Uri | undefined { From 52a3713278b293bf6e295360dda14ac29688205b Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 29 Sep 2026 16:31:38 -0700 Subject: [PATCH 4/5] Retrigger ci From 11cb63a21757dd50b3b961a4f128795699fc036e Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 29 Sep 2026 16:44:51 -0700 Subject: [PATCH 5/5] Retrigger ci