From 265a8ad9d03d85053ccdc3b5e89e57c59260f656 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Mon, 21 Sep 2026 17:52:06 -0700 Subject: [PATCH 1/7] 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 d89d31ea6..ce6d27ee8 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.8.0] + +### Added + +- Added optional `CreateEnvironmentOptions.name` so API consumers can request a specific name when creating an environment. + ## [1.7.0] ### Added diff --git a/api/package-lock.json b/api/package-lock.json index 85fa61167..c0551d3e4 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vscode/python-environments", - "version": "1.7.0", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vscode/python-environments", - "version": "1.7.0", + "version": "1.8.0", "license": "MIT", "dependencies": { "@renovatebot/pep440": "^3.1.0" diff --git a/api/package.json b/api/package.json index 821a5353f..b04f88c45 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.7.0", + "version": "1.8.0", "author": { "name": "Microsoft Corporation" }, diff --git a/docs/README.md b/docs/README.md index 843db29b2..56b453fa6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -277,11 +277,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'], }); @@ -509,7 +511,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. @@ -519,6 +521,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 de407f52e..98f42cea1 100644 --- a/src/extensionApi.ts +++ b/src/extensionApi.ts @@ -308,6 +308,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 c05669d14..244be2872 100644 --- a/src/managers/builtin/venvManager.ts +++ b/src/managers/builtin/venvManager.ts @@ -334,6 +334,7 @@ export class VenvManager implements EnvironmentManager { this.globalEnv, venvRoot, options?.additionalPackages, + options?.name, ); } } else { @@ -341,6 +342,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 31f24447a..94c62df2e 100644 --- a/src/managers/builtin/venvUtils.ts +++ b/src/managers/builtin/venvUtils.ts @@ -598,6 +598,7 @@ export async function quickCreateVenv( venvRoot: Uri, additionalPackages?: string[], operation?: PythonToolOperation, + name?: string, ): Promise { const project = api.getPythonProject(venvRoot); @@ -631,8 +632,8 @@ 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 (operation) { throw new PythonToolError( @@ -643,6 +644,9 @@ export async function quickCreateVenv( ), ); } + 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}`)) { @@ -677,7 +681,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 413034d9e..08c23ea6a 100644 --- a/src/managers/conda/condaEnvManager.ts +++ b/src/managers/conda/condaEnvManager.ts @@ -364,10 +364,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; @@ -387,6 +389,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 426137adb..019a1618f 100644 --- a/src/managers/conda/condaUtils.ts +++ b/src/managers/conda/condaUtils.ts @@ -1066,8 +1066,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 { @@ -1089,22 +1090,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; @@ -1154,81 +1155,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 f47ee1896..72e2ddaa5 100644 --- a/src/test/extensionApi.unit.test.ts +++ b/src/test/extensionApi.unit.test.ts @@ -66,6 +66,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 7c8319641..dd2e4d2b8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -440,13 +440,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; @@ -1010,6 +1011,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 ac830f6b5ad9aff2038f7a59732fb61e3302dda3 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Mon, 28 Sep 2026 13:04:35 -0700 Subject: [PATCH 2/7] 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/managers/builtin/venvManager.ts | 1 + src/test/common/pathUtils.unit.test.ts | 38 +++++++++++++++++++++++++- src/test/extensionApi.unit.test.ts | 17 ++++++++++-- src/types.ts | 7 +++-- 7 files changed, 87 insertions(+), 22 deletions(-) diff --git a/docs/README.md b/docs/README.md index 56b453fa6..4d4b5d9ab 100644 --- a/docs/README.md +++ b/docs/README.md @@ -277,9 +277,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 98f42cea1..44249c971 100644 --- a/src/extensionApi.ts +++ b/src/extensionApi.ts @@ -46,7 +46,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, normalizePath } from './common/utils/pathUtils'; +import { checkUri, isValidPortablePathSegment, normalizePath } from './common/utils/pathUtils'; import { handlePythonPath } from './common/utils/pythonPath'; import type { EnvironmentManagers } from './features/envManagers'; import type { ProjectCreators } from './features/creators/projectCreators'; @@ -308,16 +308,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/managers/builtin/venvManager.ts b/src/managers/builtin/venvManager.ts index 244be2872..cb4a110f5 100644 --- a/src/managers/builtin/venvManager.ts +++ b/src/managers/builtin/venvManager.ts @@ -334,6 +334,7 @@ export class VenvManager implements EnvironmentManager { this.globalEnv, venvRoot, options?.additionalPackages, + undefined, options?.name, ); } 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 72e2ddaa5..8e2a48f51 100644 --- a/src/test/extensionApi.unit.test.ts +++ b/src/test/extensionApi.unit.test.ts @@ -79,6 +79,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, @@ -112,10 +113,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 dd2e4d2b8..cbd3974f6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1012,9 +1012,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 5a2454a16d07544ce80cb34682c5d9b1e0da0058 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Mon, 28 Sep 2026 13:10:35 -0700 Subject: [PATCH 3/7] 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 b9e9b094efacc7f1f49fd378b0da49982e540bfe Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 29 Sep 2026 16:31:38 -0700 Subject: [PATCH 4/7] Retrigger ci From 95daea03310b6664b6f8835d8c06cf8ee1a40513 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Tue, 29 Sep 2026 16:44:51 -0700 Subject: [PATCH 5/7] Retrigger ci From 466a412c7cf1fb9efc5654b5c445f4971ce38f2a Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 30 Sep 2026 14:43:12 -0700 Subject: [PATCH 6/7] feat: advertise named environment creation capability Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/CHANGELOG.md | 1 + docs/README.md | 7 ++- src/extensionApi.ts | 21 ++++++- src/features/envCommands.ts | 12 ++-- .../builtin/inlineScript/envManager.ts | 1 + src/managers/builtin/sysPythonManager.ts | 1 + src/managers/builtin/venvManager.ts | 1 + src/managers/common/registeredManagers.ts | 20 ++++++- src/managers/conda/condaEnvManager.ts | 1 + src/publicErrors.ts | 37 ++++++++++++ src/test/extensionApi.unit.test.ts | 60 ++++++++++++++++++- src/test/features/envCommands.unit.test.ts | 21 +++++++ .../registeredManagers.create.unit.test.ts | 60 +++++++++++++++++++ src/types.ts | 31 ++++++++-- 14 files changed, 257 insertions(+), 17 deletions(-) create mode 100644 src/test/managers/common/registeredManagers.create.unit.test.ts diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index ce6d27ee8..bc5c75a52 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.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.7.0] diff --git a/docs/README.md b/docs/README.md index 4d4b5d9ab..bf11a3f25 100644 --- a/docs/README.md +++ b/docs/README.md @@ -277,7 +277,7 @@ applies to. | Field | Type | Required | Description | | --- | --- | --- | --- | -| `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. | +| `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. | @@ -516,8 +516,8 @@ createEnvironment( **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, { @@ -1569,6 +1569,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/extensionApi.ts b/src/extensionApi.ts index 44249c971..15f0dfdd2 100644 --- a/src/extensionApi.ts +++ b/src/extensionApi.ts @@ -40,7 +40,7 @@ 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'; @@ -320,6 +320,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); @@ -337,12 +343,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 ab168e752..2c346f638 100644 --- a/src/managers/builtin/inlineScript/envManager.ts +++ b/src/managers/builtin/inlineScript/envManager.ts @@ -364,6 +364,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 2803674b3..37f4c2bed 100644 --- a/src/managers/builtin/sysPythonManager.ts +++ b/src/managers/builtin/sysPythonManager.ts @@ -65,6 +65,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 cb4a110f5..b76b70c06 100644 --- a/src/managers/builtin/venvManager.ts +++ b/src/managers/builtin/venvManager.ts @@ -98,6 +98,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, diff --git a/src/managers/common/registeredManagers.ts b/src/managers/common/registeredManagers.ts index 23d6470e9..2a300e4f4 100644 --- a/src/managers/common/registeredManagers.ts +++ b/src/managers/common/registeredManagers.ts @@ -3,12 +3,15 @@ import type { Pep440Version } from '@renovatebot/pep440'; import { CancellationError, Disposable, Event, LogOutputChannel, MarkdownString, RelativePattern } from 'vscode'; -import { PackageVersionLookupNotSupportedError } from '../../publicErrors'; import { resolveEnvironmentManagerCapability, resolvePackageManagerCapability, } from '../../capabilities'; import type { CapabilityContext, EnvironmentManagerCapability, PackageManagerCapability, Support } from '../../capabilities'; +import { + CreateEnvironmentOptionNotSupportedError, + PackageVersionLookupNotSupportedError, +} from '../../publicErrors'; import { ISSUES_URL } from '../../common/constants'; import { CreateEnvironmentNotSupported, RemoveEnvironmentNotSupported } from '../../common/errors/NotSupportedError'; import { traceWarn } from '../../common/logging'; @@ -85,6 +88,9 @@ export class InternalEnvironmentManager implements EnvironmentManager { public get log(): LogOutputChannel | undefined { return this.manager.log; } + public get createCapabilities() { + return this.manager.createCapabilities; + } public getVersion(): Promise { return this.manager.getVersion ? this.manager.getVersion() : Promise.resolve(undefined); @@ -103,10 +109,22 @@ export class InternalEnvironmentManager implements EnvironmentManager { return resolveEnvironmentManagerCapability(this.manager, capability, context); } + 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 08c23ea6a..13062ab46 100644 --- a/src/managers/conda/condaEnvManager.ts +++ b/src/managers/conda/condaEnvManager.ts @@ -111,6 +111,7 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { description?: string; tooltip: string | MarkdownString; iconPath?: IconPath; + readonly createCapabilities = { customName: true } as const; public dispose() { this.collection = []; 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/extensionApi.unit.test.ts b/src/test/extensionApi.unit.test.ts index 8e2a48f51..05fa3f6c2 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'; @@ -75,7 +77,7 @@ suite('PythonEnvironmentApiImpl - createEnvironment', () => { sinon.restore(); }); - function createApi(create: sinon.SinonStub): PythonEnvironmentApiImpl { + function createApi(create: sinon.SinonStub, supportsCustomName = true): PythonEnvironmentApiImpl { type ApiArgs = ConstructorParameters; const mockEnvManagers = { onDidChangeActiveEnvironment: new EventEmitter().event, @@ -83,6 +85,7 @@ suite('PythonEnvironmentApiImpl - createEnvironment', () => { getEnvironmentManager: sinon.stub().returns({ id: 'ms-python.python:venv', supportsCreate: true, + supportsCustomName, create, }), } as unknown as ApiArgs[0]; @@ -134,6 +137,61 @@ suite('PythonEnvironmentApiImpl - createEnvironment', () => { 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', () => { diff --git a/src/test/features/envCommands.unit.test.ts b/src/test/features/envCommands.unit.test.ts index 1fe9e997d..13f4d97a8 100644 --- a/src/test/features/envCommands.unit.test.ts +++ b/src/test/features/envCommands.unit.test.ts @@ -225,6 +225,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/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/types.ts b/src/types.ts index cbd3974f6..6ea9a6bbc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -288,6 +288,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. */ @@ -430,6 +443,13 @@ export interface EnvironmentManager { * @returns A promise that resolves to a {@link Pep440Version} object, or `undefined` if not available. */ getVersion?(): Promise; + + /** + * 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 @@ -446,8 +466,10 @@ export interface EnvironmentManager { * @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. Implementations should use a supplied - * {@link CreateEnvironmentOptions.name} or reject it when the requested name is invalid. + * 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; @@ -1014,8 +1036,9 @@ 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. + * 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; /** From f806dac9c739c562d904179d1152283eb0e7f27e Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Wed, 14 Oct 2026 13:36:04 -0700 Subject: [PATCH 7/7] feat: integrate named environment creation with manager capabilities Advertise environment.create.named for venv and Conda, keep legacy and unsupported managers disabled, and route named creation through scoped capability checks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9606a9cf-3e6e-458f-a6e1-6707e4a490c7 --- api/CHANGELOG.md | 2 +- docs/README.md | 12 +++++-- src/capabilities.ts | 2 ++ src/extensionApi.ts | 9 +++-- src/features/envCommands.ts | 18 +++++++--- src/legacyCapabilities.ts | 2 ++ .../builtin/inlineScript/envManager.ts | 10 ++++-- src/managers/builtin/sysPythonManager.ts | 10 ++++-- src/managers/builtin/venvManager.ts | 2 +- src/managers/common/registeredManagers.ts | 22 +++++------- src/managers/conda/condaEnvManager.ts | 2 +- src/managers/pipenv/pipenvManager.ts | 9 ++++- src/managers/poetry/poetryManager.ts | 9 ++++- src/managers/pyenv/pyenvManager.ts | 9 ++++- src/test/capabilities.unit.test.ts | 22 ++++++++++++ src/test/capabilityFixtures.ts | 1 + .../extensionApi.capabilities.unit.test.ts | 22 ++++++++++++ src/test/extensionApi.unit.test.ts | 8 +++-- src/test/features/envCommands.unit.test.ts | 4 +-- .../managerCapabilities.integration.test.ts | 23 ++++++++++++ src/test/legacyCapabilities.unit.test.ts | 6 +++- .../venvManager.createRemove.unit.test.ts | 3 +- .../managers/builtinCapabilities.unit.test.ts | 34 ++++++++++++++++++ .../registeredManagers.create.unit.test.ts | 35 ++++++++++++++++++- src/types.ts | 24 ++----------- 25 files changed, 236 insertions(+), 64 deletions(-) diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index bc5c75a52..dcc94c8bb 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.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. +- Added `EnvironmentCapability.CreateNamed` (`environment.create.named`) so managers explicitly advertise support for exact caller-supplied names through their `capabilities` maps. Named creation defaults to unsupported, including for legacy providers, and rejects before invoking managers that do not support it. Built-in venv and Conda managers advertise support. ## [1.7.0] diff --git a/docs/README.md b/docs/README.md index bf11a3f25..9c82fae97 100644 --- a/docs/README.md +++ b/docs/README.md @@ -277,7 +277,7 @@ applies to. | Field | Type | Required | Description | | --- | --- | --- | --- | -| `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. | +| `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 `EnvironmentCapability.CreateNamed` (`environment.create.named`); 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. | @@ -1569,7 +1569,6 @@ 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. | @@ -1868,12 +1867,18 @@ invoke operations, validate requests, or guarantee success. | `environments.setSelected` | `packages.upgrade` | | `environments.create` | `packages.list.direct` | | `environments.remove` | `packages.fetchversions` | +| `environment.create.named` | | Listing includes refresh. Package installation, uninstallation, and upgrades use the existing `manage()` method. `packages.list.direct` is best-effort classification, not exact installation intent. Selection is scoped environment selection, not shell activation. Pip reports installation, uninstallation, and upgrades as unsupported for Python 2 environments. +`EnvironmentCapability.CreateNamed` (`environment.create.named`) requires explicit +advertisement and is unsupported by default, including for legacy managers with a +`create()` method. Supporting managers must use a supplied `CreateEnvironmentOptions.name` +exactly or reject it. The built-in venv and Conda managers advertise this capability; +system Python, inline-script, Poetry, Pipenv, and Pyenv managers do not support it. Use the exported `EnvironmentCapability` and `PackageCapability` constants or their literal string values. @@ -1913,7 +1918,8 @@ Legacy inference uses `getLegacyPackageManagerCapability(manager, capability)` or `getLegacyEnvironmentManagerCapability(manager, capability)`. They inspect method presence without executing methods or reading advertisements. Listing requires both listing and refresh methods; package install, uninstall, and upgrade require `manage`. -Other capabilities require their corresponding methods. This does not verify tool +Named creation is always unsupported under legacy inference; other capabilities +require their corresponding methods. This does not verify tool versions or option behavior. Regular queries use this fallback only when the map is omitted. Legacy helper results include `isLegacy: true`; other `Support` results may omit the field, which means false. diff --git a/src/capabilities.ts b/src/capabilities.ts index 7f4ab2778..bd53bffd6 100644 --- a/src/capabilities.ts +++ b/src/capabilities.ts @@ -24,6 +24,8 @@ export const EnvironmentCapability = { SetSelected: 'environments.setSelected', /** Create a new Python environment. */ Create: 'environments.create', + /** Create an environment with an exact caller-supplied name; unsupported unless explicitly advertised. */ + CreateNamed: 'environment.create.named', /** Remove an existing Python environment. */ Remove: 'environments.remove', } as const; diff --git a/src/extensionApi.ts b/src/extensionApi.ts index 15f0dfdd2..77b98e8cc 100644 --- a/src/extensionApi.ts +++ b/src/extensionApi.ts @@ -320,7 +320,7 @@ 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) { + if (options?.name !== undefined && !(await manager.supportsCustomName(scope))) { throw new CreateEnvironmentOptionNotSupportedError( 'name', `Environment manager does not support named environment creation: ${manager.id}`, @@ -343,8 +343,11 @@ export class PythonEnvironmentApiImpl implements PythonEnvironmentApi { throw new Error('No environment managers found'); } - const compatibleManagers = - options?.name === undefined ? managers : managers.filter((manager) => manager.supportsCustomName); + const namedSupport = + options?.name === undefined + ? undefined + : await Promise.all(managers.map((manager) => manager.supportsCustomName(scope))); + const compatibleManagers = managers.filter((_, index) => namedSupport?.[index] ?? true); if (compatibleManagers.length === 0) { throw new CreateEnvironmentOptionNotSupportedError( 'name', diff --git a/src/features/envCommands.ts b/src/features/envCommands.ts index 3e5577fca..c9153ebf3 100644 --- a/src/features/envCommands.ts +++ b/src/features/envCommands.ts @@ -13,6 +13,7 @@ import { } from 'vscode'; import { CreateEnvironmentOptions, + CreateEnvironmentScope, Pep440Version, PythonEnvironment, PythonEnvironmentApi, @@ -209,12 +210,18 @@ export async function createAnyEnvironmentCommand( }, ): Promise { const select = options?.selectEnvironment; - const supportsRequestedCreation = (manager: InternalEnvironmentManager): boolean => - manager.supportsCreate && (options?.name === undefined || manager.supportsCustomName); + const getCompatibleManagers = async (scope: CreateEnvironmentScope): Promise => { + const managers = em.managers.filter((manager) => manager.supportsCreate); + if (options?.name === undefined) { + return managers; + } + const support = await Promise.all(managers.map((manager) => manager.supportsCustomName(scope))); + return managers.filter((_, index) => support[index]); + }; const projects = pm.getProjects(options?.uri ? [options?.uri] : undefined); if (projects.length === 0) { const managerId = await pickEnvironmentManager( - em.managers.filter(supportsRequestedCreation), + await getCompatibleManagers('global'), undefined, undefined, options?.name === undefined, @@ -241,11 +248,12 @@ export async function createAnyEnvironmentCommand( const selected = await pickProjectMany(projects, options?.showBackButton); if (selected && selected.length > 0) { + const compatibleManagers = await getCompatibleManagers(selected.map((p) => p.uri)); const defaultManagers: InternalEnvironmentManager[] = []; selected.forEach((p) => { const manager = em.getEnvironmentManager(p.uri); - if (manager && supportsRequestedCreation(manager) && !defaultManagers.includes(manager)) { + if (manager && compatibleManagers.includes(manager) && !defaultManagers.includes(manager)) { defaultManagers.push(manager); } }); @@ -257,7 +265,7 @@ export async function createAnyEnvironmentCommand( manager = defaultManagers[0]; } else { let managerId = await pickEnvironmentManager( - em.managers.filter(supportsRequestedCreation), + compatibleManagers, defaultManagers, options?.showBackButton, options?.name === undefined, diff --git a/src/legacyCapabilities.ts b/src/legacyCapabilities.ts index c1ccac531..5cdd4bbf2 100644 --- a/src/legacyCapabilities.ts +++ b/src/legacyCapabilities.ts @@ -59,6 +59,8 @@ export async function getLegacyEnvironmentManagerCapability( return getMethodSupport(environmentManager.set); case EnvironmentCapability.Create: return getMethodSupport(environmentManager.create); + case EnvironmentCapability.CreateNamed: + return getMethodSupport(); case EnvironmentCapability.Remove: return getMethodSupport(environmentManager.remove); default: diff --git a/src/managers/builtin/inlineScript/envManager.ts b/src/managers/builtin/inlineScript/envManager.ts index 2c346f638..b341dc59e 100644 --- a/src/managers/builtin/inlineScript/envManager.ts +++ b/src/managers/builtin/inlineScript/envManager.ts @@ -34,7 +34,13 @@ import { ResolveEnvironmentContext, SetEnvironmentScope, } from '../../../api'; -import { Capabilities, EnvironmentManagerCapability, supportedCapability, EnvironmentCapability } from '../../../capabilities'; +import { + Capabilities, + EnvironmentManagerCapability, + supportedCapability, + unsupportedCapability, + EnvironmentCapability, +} from '../../../capabilities'; import { CONDA_MANAGER_ID, INLINE_SCRIPT_MANAGER_ID, @@ -301,6 +307,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { [EnvironmentCapability.GetSelected]: supportedCapability, [EnvironmentCapability.SetSelected]: supportedCapability, [EnvironmentCapability.Remove]: supportedCapability, + [EnvironmentCapability.CreateNamed]: unsupportedCapability, [EnvironmentCapability.Create]: async ({ scope }) => { const scriptUri = scope !== undefined && scope !== 'all' ? this.getScriptUri(scope) : undefined; if (!scriptUri) { @@ -364,7 +371,6 @@ 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 37f4c2bed..26821f7d3 100644 --- a/src/managers/builtin/sysPythonManager.ts +++ b/src/managers/builtin/sysPythonManager.ts @@ -17,7 +17,13 @@ import { ResolveEnvironmentContext, SetEnvironmentScope, } from '../../api'; -import { Capabilities, EnvironmentManagerCapability, supportedCapability, EnvironmentCapability } from '../../capabilities'; +import { + Capabilities, + EnvironmentManagerCapability, + supportedCapability, + unsupportedCapability, + EnvironmentCapability, +} from '../../capabilities'; import { SysManagerStrings } from '../../common/localize'; import { createDeferred, Deferred } from '../../common/utils/deferred'; import { normalizePath } from '../../common/utils/pathUtils'; @@ -43,6 +49,7 @@ export class SysPythonManager implements EnvironmentManager { [EnvironmentCapability.GetSelected]: supportedCapability, [EnvironmentCapability.SetSelected]: supportedCapability, [EnvironmentCapability.Create]: supportedCapability, + [EnvironmentCapability.CreateNamed]: unsupportedCapability, [EnvironmentCapability.Remove]: async () => ({ supported: false, reason: l10n.t('This environment manager does not support removing environments.'), @@ -65,7 +72,6 @@ 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 b76b70c06..1e095fea2 100644 --- a/src/managers/builtin/venvManager.ts +++ b/src/managers/builtin/venvManager.ts @@ -76,6 +76,7 @@ export class VenvManager implements EnvironmentManager { [EnvironmentCapability.GetSelected]: supportedCapability, [EnvironmentCapability.SetSelected]: supportedCapability, [EnvironmentCapability.Create]: supportedCapability, + [EnvironmentCapability.CreateNamed]: supportedCapability, [EnvironmentCapability.Remove]: supportedCapability, }; @@ -98,7 +99,6 @@ 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, diff --git a/src/managers/common/registeredManagers.ts b/src/managers/common/registeredManagers.ts index 2a300e4f4..b3c092057 100644 --- a/src/managers/common/registeredManagers.ts +++ b/src/managers/common/registeredManagers.ts @@ -4,6 +4,7 @@ import type { Pep440Version } from '@renovatebot/pep440'; import { CancellationError, Disposable, Event, LogOutputChannel, MarkdownString, RelativePattern } from 'vscode'; import { + EnvironmentCapability, resolveEnvironmentManagerCapability, resolvePackageManagerCapability, } from '../../capabilities'; @@ -88,10 +89,6 @@ export class InternalEnvironmentManager implements EnvironmentManager { public get log(): LogOutputChannel | undefined { return this.manager.log; } - public get createCapabilities() { - return this.manager.createCapabilities; - } - public getVersion(): Promise { return this.manager.getVersion ? this.manager.getVersion() : Promise.resolve(undefined); } @@ -109,20 +106,19 @@ export class InternalEnvironmentManager implements EnvironmentManager { return resolveEnvironmentManagerCapability(this.manager, capability, context); } - public get supportsCustomName(): boolean { - return this.createCapabilities?.customName === true; + /** Checks advertised support for exact caller-supplied names within the creation scope. */ + public async supportsCustomName(scope: CreateEnvironmentScope): Promise { + return (await this.getCapability(EnvironmentCapability.CreateNamed, { scope })).supported; } - create( + async 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 (options?.name !== undefined && !(await this.supportsCustomName(scope))) { + throw new CreateEnvironmentOptionNotSupportedError( + 'name', + `Environment manager does not support named environment creation: ${this.id}`, ); } if (this.manager.create) { diff --git a/src/managers/conda/condaEnvManager.ts b/src/managers/conda/condaEnvManager.ts index 13062ab46..cabdb85ed 100644 --- a/src/managers/conda/condaEnvManager.ts +++ b/src/managers/conda/condaEnvManager.ts @@ -78,6 +78,7 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { [EnvironmentCapability.GetSelected]: supportedCapability, [EnvironmentCapability.SetSelected]: supportedCapability, [EnvironmentCapability.Create]: supportedCapability, + [EnvironmentCapability.CreateNamed]: supportedCapability, [EnvironmentCapability.Remove]: supportedCapability, }; @@ -111,7 +112,6 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { description?: string; tooltip: string | MarkdownString; iconPath?: IconPath; - readonly createCapabilities = { customName: true } as const; public dispose() { this.collection = []; diff --git a/src/managers/pipenv/pipenvManager.ts b/src/managers/pipenv/pipenvManager.ts index abbe6b240..8c9a909bc 100644 --- a/src/managers/pipenv/pipenvManager.ts +++ b/src/managers/pipenv/pipenvManager.ts @@ -16,7 +16,13 @@ import { ResolveEnvironmentContext, SetEnvironmentScope, } from '../../api'; -import { Capabilities, EnvironmentManagerCapability, supportedCapability, EnvironmentCapability } from '../../capabilities'; +import { + Capabilities, + EnvironmentManagerCapability, + supportedCapability, + unsupportedCapability, + EnvironmentCapability, +} from '../../capabilities'; import { PipenvStrings } from '../../common/localize'; import { traceError, traceInfo } from '../../common/logging'; import { StopWatch } from '../../common/stopWatch'; @@ -55,6 +61,7 @@ export class PipenvManager implements EnvironmentManager, Disposable { [EnvironmentCapability.Resolve]: supportedCapability, [EnvironmentCapability.GetSelected]: supportedCapability, [EnvironmentCapability.SetSelected]: supportedCapability, + [EnvironmentCapability.CreateNamed]: unsupportedCapability, [EnvironmentCapability.Create]: async () => ({ supported: false, reason: l10n.t('{0} does not support creating environments.', 'Pipenv'), diff --git a/src/managers/poetry/poetryManager.ts b/src/managers/poetry/poetryManager.ts index 8de448b3e..9a82a66bb 100644 --- a/src/managers/poetry/poetryManager.ts +++ b/src/managers/poetry/poetryManager.ts @@ -16,7 +16,13 @@ import { ResolveEnvironmentContext, SetEnvironmentScope, } from '../../api'; -import { Capabilities, EnvironmentManagerCapability, supportedCapability, EnvironmentCapability } from '../../capabilities'; +import { + Capabilities, + EnvironmentManagerCapability, + supportedCapability, + unsupportedCapability, + EnvironmentCapability, +} from '../../capabilities'; import { PoetryStrings } from '../../common/localize'; import { traceError, traceInfo } from '../../common/logging'; import { StopWatch } from '../../common/stopWatch'; @@ -55,6 +61,7 @@ export class PoetryManager implements EnvironmentManager, Disposable { [EnvironmentCapability.Resolve]: supportedCapability, [EnvironmentCapability.GetSelected]: supportedCapability, [EnvironmentCapability.SetSelected]: supportedCapability, + [EnvironmentCapability.CreateNamed]: unsupportedCapability, [EnvironmentCapability.Create]: async () => ({ supported: false, reason: l10n.t('{0} does not support creating environments.', 'Poetry'), diff --git a/src/managers/pyenv/pyenvManager.ts b/src/managers/pyenv/pyenvManager.ts index 1042c9b70..0c584c8f1 100644 --- a/src/managers/pyenv/pyenvManager.ts +++ b/src/managers/pyenv/pyenvManager.ts @@ -17,7 +17,13 @@ import { ResolveEnvironmentContext, SetEnvironmentScope, } from '../../api'; -import { Capabilities, EnvironmentManagerCapability, supportedCapability, EnvironmentCapability } from '../../capabilities'; +import { + Capabilities, + EnvironmentManagerCapability, + supportedCapability, + unsupportedCapability, + EnvironmentCapability, +} from '../../capabilities'; import { PYENV_MANAGER_ID } from '../../common/constants'; import { PyenvStrings } from '../../common/localize'; import { traceError, traceInfo } from '../../common/logging'; @@ -53,6 +59,7 @@ export class PyEnvManager implements EnvironmentManager, Disposable { [EnvironmentCapability.Resolve]: supportedCapability, [EnvironmentCapability.GetSelected]: supportedCapability, [EnvironmentCapability.SetSelected]: supportedCapability, + [EnvironmentCapability.CreateNamed]: unsupportedCapability, [EnvironmentCapability.Create]: async () => ({ supported: false, reason: l10n.t('{0} does not support creating environments.', 'Pyenv'), diff --git a/src/test/capabilities.unit.test.ts b/src/test/capabilities.unit.test.ts index 0e0b5937c..e415fe3d8 100644 --- a/src/test/capabilities.unit.test.ts +++ b/src/test/capabilities.unit.test.ts @@ -51,6 +51,28 @@ function packageManager(capabilities: Partial { suite('Optional advertisements with legacy fallback', () => { + test('named creation requires an explicit advertisement, not just a create method', async () => { + const manager = environmentManager(); + manager.create = unexpectedOperation; + for (const capabilities of [undefined, {}]) { + Reflect.set(manager, 'capabilities', capabilities); + assert.deepStrictEqual( + await environmentCapability(manager, EnvironmentCapability.CreateNamed), + capabilities === undefined ? { ...notImplemented, isLegacy: true } : notImplemented, + ); + } + const context: CapabilityContext = { scope: Uri.file('.') }; + Reflect.set(manager, 'capabilities', { + [EnvironmentCapability.CreateNamed]: async (received: CapabilityContext) => { + assert.strictEqual(received, context); + return supported; + }, + }); + assert.strictEqual( + await environmentCapability(manager, EnvironmentCapability.CreateNamed, context), + supported, + ); + }); test('advertised checks are honored in either direction', async () => { const env = environmentManager({ [EnvironmentCapability.List]: async () => denied, [EnvironmentCapability.Create]: async () => supported }); assert.strictEqual(await environmentCapability(env, EnvironmentCapability.List), denied); diff --git a/src/test/capabilityFixtures.ts b/src/test/capabilityFixtures.ts index e47013ed9..291df40b7 100644 --- a/src/test/capabilityFixtures.ts +++ b/src/test/capabilityFixtures.ts @@ -16,6 +16,7 @@ export const allSupportedEnvironmentCapabilities: Capabilities { assert.strictEqual(clock.countTimers(), 0); }); + test('queries advertised named creation support through the public API', async () => { + registerOwner({ + capabilities: { + ...allSupportedEnvironmentCapabilities, + [EnvironmentCapability.CreateNamed]: envProbe, + }, + }); + const context = { scope: project.uri }; + assert.deepStrictEqual( + await api.getEnvironmentManagerCapability(ownerId, EnvironmentCapability.CreateNamed, context), + { supported: true }, + ); + assert.ok(envProbe.calledOnceWithExactly(context)); + await clock.tickAsync(0); + assert.ok( + sendTelemetry.calledWithMatch(EventNames.MANAGER_CAPABILITY_QUERY, undefined, { + capability: 'environment.create.named', + outcome: 'supported', + }), + ); + }); + test('registered legacy providers infer supported and unsupported capabilities without advertised checks', async () => { registerOwner({ capabilities: undefined }); api.registerPackageManager({ ...packageProvider(), capabilities: undefined }, { extensionId }); diff --git a/src/test/extensionApi.unit.test.ts b/src/test/extensionApi.unit.test.ts index 05fa3f6c2..7ee911653 100644 --- a/src/test/extensionApi.unit.test.ts +++ b/src/test/extensionApi.unit.test.ts @@ -85,7 +85,7 @@ suite('PythonEnvironmentApiImpl - createEnvironment', () => { getEnvironmentManager: sinon.stub().returns({ id: 'ms-python.python:venv', supportsCreate: true, - supportsCustomName, + supportsCustomName: sinon.stub().resolves(supportsCustomName), create, }), } as unknown as ApiArgs[0]; @@ -157,13 +157,13 @@ suite('PythonEnvironmentApiImpl - createEnvironment', () => { const unsupportedManager = { id: 'example:immutable', supportsCreate: true, - supportsCustomName: false, + supportsCustomName: sinon.stub().resolves(false), create: unsupportedCreate, }; const supportedManager = { id: 'example:venv', supportsCreate: true, - supportsCustomName: true, + supportsCustomName: sinon.stub().resolves(true), create: supportedCreate, }; const firstScope = Uri.file('first'); @@ -191,6 +191,8 @@ suite('PythonEnvironmentApiImpl - createEnvironment', () => { assert.deepStrictEqual(pickManager.firstCall.args[0], [supportedManager]); assert.ok(supportedCreate.calledOnceWithExactly([firstScope, secondScope], options)); assert.ok(unsupportedCreate.notCalled); + assert.ok(supportedManager.supportsCustomName.calledOnceWithExactly([firstScope, secondScope])); + assert.ok(unsupportedManager.supportsCustomName.calledOnceWithExactly([firstScope, secondScope])); }); }); diff --git a/src/test/features/envCommands.unit.test.ts b/src/test/features/envCommands.unit.test.ts index 13f4d97a8..91547431e 100644 --- a/src/test/features/envCommands.unit.test.ts +++ b/src/test/features/envCommands.unit.test.ts @@ -228,8 +228,8 @@ suite('Create Any Environment Command Tests', () => { 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); + unsupportedManager.setup((m) => m.supportsCustomName('global')).returns(() => Promise.resolve(false)); + manager.setup((m) => m.supportsCustomName('global')).returns(() => Promise.resolve(true)); em.setup((e) => e.managers).returns(() => [unsupportedManager.object, manager.object]); pm.setup((p) => p.getProjects(typeMoq.It.isAny())).returns(() => []); manager diff --git a/src/test/integration/managerCapabilities.integration.test.ts b/src/test/integration/managerCapabilities.integration.test.ts index 527449b36..cfb51a9a3 100644 --- a/src/test/integration/managerCapabilities.integration.test.ts +++ b/src/test/integration/managerCapabilities.integration.test.ts @@ -100,6 +100,29 @@ suite('Manager capabilities integration', function () { ); }); + test('named creation is unsupported by default and honors explicit advertisements', async () => { + const scope = Uri.file('.'); + const initial = await api.getEnvironmentManagerCapability( + environment.envId.managerId, + EnvironmentCapability.CreateNamed, + { scope }, + ); + assert.strictEqual(initial.supported, false); + environmentCapabilities = { + ...noEnvironmentCapabilities, + [EnvironmentCapability.CreateNamed]: async (context) => { + assert.strictEqual(context.scope, scope); + return { supported: true }; + }, + }; + assert.deepStrictEqual( + await api.getEnvironmentManagerCapability(environment.envId.managerId, EnvironmentCapability.CreateNamed, { + scope, + }), + { supported: true }, + ); + }); + test('package queries reach the configured project-bound provider, not its root', async () => { const folder = workspace.workspaceFolders?.[0]; assert.ok(folder, 'The integration workspace must be open'); diff --git a/src/test/legacyCapabilities.unit.test.ts b/src/test/legacyCapabilities.unit.test.ts index 68c8f118e..4e430780d 100644 --- a/src/test/legacyCapabilities.unit.test.ts +++ b/src/test/legacyCapabilities.unit.test.ts @@ -71,11 +71,15 @@ suite('Legacy manager capabilities', () => { [EnvironmentCapability.GetSelected]: ['get'], [EnvironmentCapability.SetSelected]: ['set'], [EnvironmentCapability.Create]: ['create'], + [EnvironmentCapability.CreateNamed]: [], [EnvironmentCapability.Remove]: ['remove'], }; for (const capability of Object.values(EnvironmentCapability)) { - assert.deepStrictEqual(await getLegacyEnvironmentManagerCapability(manager, capability), legacySupported); + assert.deepStrictEqual( + await getLegacyEnvironmentManagerCapability(manager, capability), + capability === EnvironmentCapability.CreateNamed ? legacyUnsupported : legacySupported, + ); for (const method of methods[capability]) { for (const value of [undefined, false]) { Reflect.set(manager, method, value); diff --git a/src/test/managers/builtin/venvManager.createRemove.unit.test.ts b/src/test/managers/builtin/venvManager.createRemove.unit.test.ts index 3c68484fd..441afe3a0 100644 --- a/src/test/managers/builtin/venvManager.createRemove.unit.test.ts +++ b/src/test/managers/builtin/venvManager.createRemove.unit.test.ts @@ -159,7 +159,8 @@ suite('VenvManager.create - orchestration', () => { quickCreate: true, }); - assert.strictEqual(quickCreateVenvStub.firstCall.args[7], 'analysis-env'); + assert.strictEqual(quickCreateVenvStub.firstCall.args[7], undefined); + assert.strictEqual(quickCreateVenvStub.firstCall.args[8], 'analysis-env'); }); test('custom create forwards an explicit environment name', async () => { diff --git a/src/test/managers/builtinCapabilities.unit.test.ts b/src/test/managers/builtinCapabilities.unit.test.ts index 86ef3ebc8..24e1538f2 100644 --- a/src/test/managers/builtinCapabilities.unit.test.ts +++ b/src/test/managers/builtinCapabilities.unit.test.ts @@ -18,9 +18,13 @@ import * as metadata from '../../common/inlineScript/metadata'; import * as workspaceApis from '../../common/workspace.apis'; import { InlineScriptEnvManager } from '../../managers/builtin/inlineScript/envManager'; import { SysPythonManager } from '../../managers/builtin/sysPythonManager'; +import { VenvManager } from '../../managers/builtin/venvManager'; +import { CondaEnvManager } from '../../managers/conda/condaEnvManager'; import { NativePythonFinder } from '../../managers/common/nativePythonFinder'; +import { PipenvManager } from '../../managers/pipenv/pipenvManager'; import { PoetryManager } from '../../managers/poetry/poetryManager'; import { PoetryPackageManager } from '../../managers/poetry/poetryPackageManager'; +import { PyEnvManager } from '../../managers/pyenv/pyenvManager'; import { createMockLogOutputChannel } from '../mocks/helper'; import { createMockPythonEnvironment } from '../mocks/pythonEnvironment'; @@ -66,6 +70,29 @@ suite('Built-in manager capabilities', () => { }); }); + test('only venv and Conda advertise named creation without invoking operations', async () => { + const finder = {} as NativePythonFinder; + const system = new SysPythonManager(finder, api, log); + const conda = new CondaEnvManager(finder, api, log); + const supported = [new VenvManager(finder, api, system, log), conda]; + const otherManagers = [ + new PipenvManager(finder, api), + new PoetryManager(finder, api), + new PyEnvManager(finder, api), + ]; + disposables.push(conda, ...otherManagers); + for (const manager of supported) { + const create = sinon.stub(manager, 'create').throws(new Error('Unexpected creation')); + assert.deepStrictEqual(await environmentCapability(manager, EnvironmentCapability.CreateNamed), { + supported: true, + }); + assert.ok(create.notCalled); + } + for (const manager of [system, ...otherManagers]) { + assertUnsupported(await environmentCapability(manager, EnvironmentCapability.CreateNamed)); + } + }); + suite('Inline-script creation', () => { let manager: InlineScriptEnvManager; let readMetadata: sinon.SinonStub; @@ -108,6 +135,13 @@ suite('Built-in manager capabilities', () => { assert.ok(create.notCalled); }); + test('does not support caller-supplied names even for scripts with valid metadata', async () => { + assertUnsupported( + await environmentCapability(manager, EnvironmentCapability.CreateNamed, { scope: script }), + ); + assert.ok(readMetadata.notCalled); + }); + test('rejects unsupported scopes before reading metadata', async () => { const scopes: CapabilityContext['scope'][] = [ undefined, diff --git a/src/test/managers/common/registeredManagers.create.unit.test.ts b/src/test/managers/common/registeredManagers.create.unit.test.ts index 9ded3d58d..b5bffc93c 100644 --- a/src/test/managers/common/registeredManagers.create.unit.test.ts +++ b/src/test/managers/common/registeredManagers.create.unit.test.ts @@ -10,6 +10,8 @@ import { PythonEnvironment, } from '../../../api'; import { InternalEnvironmentManager } from '../../../managers/common/registeredManagers'; +import { EnvironmentCapability, supportedCapability, unsupportedCapability } from '../../../capabilities'; +import { allSupportedEnvironmentCapabilities } from '../../capabilityFixtures'; function createManager( create: sinon.SinonStub, @@ -18,7 +20,13 @@ function createManager( return new InternalEnvironmentManager('example:manager', { name: 'manager', preferredPackageManagerId: 'example:pip', - createCapabilities: customName === undefined ? undefined : { customName }, + capabilities: + customName === undefined + ? undefined + : { + ...allSupportedEnvironmentCapabilities, + [EnvironmentCapability.CreateNamed]: customName ? supportedCapability : unsupportedCapability, + }, create, } as unknown as EnvironmentManager); } @@ -57,4 +65,29 @@ suite('InternalEnvironmentManager.create', () => { assert.strictEqual(await manager.create(scope, undefined), environment); assert.ok(create.calledOnceWithExactly(scope, undefined)); }); + + test('passes the scope to contextual named-creation checks and propagates probe failures', async () => { + const create = sinon.stub().resolves(undefined); + const scope = Uri.file('workspace'); + const check = sinon.stub().resolves({ supported: true }); + const manager = new InternalEnvironmentManager('example:manager', { + name: 'manager', + preferredPackageManagerId: 'example:pip', + refresh: async () => {}, + getEnvironments: async () => [], + get: async () => undefined, + set: async () => {}, + resolve: async () => undefined, + create, + capabilities: { ...allSupportedEnvironmentCapabilities, [EnvironmentCapability.CreateNamed]: check }, + }); + await manager.create(scope, { name: 'analysis-env' }); + assert.ok(check.calledOnceWithExactly({ scope })); + + const failure = new Error('Capability probe failed'); + check.rejects(failure); + create.resetHistory(); + await assert.rejects(manager.create(scope, { name: 'analysis-env' }), (error) => error === failure); + assert.ok(create.notCalled); + }); }); diff --git a/src/types.ts b/src/types.ts index 6ea9a6bbc..0c1d419bd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -288,19 +288,6 @@ 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. */ @@ -443,13 +430,6 @@ export interface EnvironmentManager { * @returns A promise that resolves to a {@link Pep440Version} object, or `undefined` if not available. */ getVersion?(): Promise; - - /** - * 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 @@ -467,7 +447,7 @@ export interface EnvironmentManager { * 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. Managers advertising - * `createCapabilities.customName` must use a supplied {@link CreateEnvironmentOptions.name} + * `environment.create.named` 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. */ @@ -1037,7 +1017,7 @@ 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 + * `environment.create.named` in its capabilities map; otherwise creation rejects. When * omitted, the environment manager may prompt for a name or choose a default. */ name?: string;