Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions api/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ All notable changes to the `@vscode/python-environments` API package are documen
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.8.0]

### Added

- Added optional `CreateEnvironmentOptions.name` so API consumers can request a specific name when creating an environment.
- 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]

### Added
Expand Down
4 changes: 2 additions & 2 deletions api/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion api/package.json
Original file line number Diff line number Diff line change
@@ -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"
},
Expand Down
22 changes: 16 additions & 6 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,11 +277,13 @@ applies to.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `quickCreate` | `boolean` | No | `true` creates without any prompts. `false` means the user explicitly declined quick create, so prompts are allowed. `undefined` leaves the decision to the manager, which may offer quick create. |
| `additionalPackages` | `string[]` | No | Packages to install in addition to whatever the manager installs by default. |
| `name` | `string` | `false` | Portable path segment to use as the new environment's name. Directory separators, control characters, Windows-reserved filename characters (such as `:` and `?`) and device names (such as `CON` and `NUL`), trailing periods or spaces, `.` and `..` are rejected. The selected manager must advertise `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. |

```typescript
const env = await api.createEnvironment(projectUri, {
name: 'analysis-env',
quickCreate: true,
additionalPackages: ['requests', 'pytest'],
});
Expand Down Expand Up @@ -509,16 +511,17 @@ createEnvironment(
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `scope` | [`CreateEnvironmentScope`](#scope-types) | Yes | `Uri` or `Uri[]` for the projects the environment is created for; `'global'` creates one outside any project. |
| `options` | [`CreateEnvironmentOptions`](#createenvironmentoptions) | No | Controls prompting (`quickCreate`) and extra packages (`additionalPackages`). |
| `options` | [`CreateEnvironmentOptions`](#createenvironmentoptions) | No | Controls the environment name (`name`), prompting (`quickCreate`), and extra packages (`additionalPackages`). |

**Returns** `Promise<PythonEnvironment | undefined>` - `undefined` when no
environment was created, for example because the user cancelled the flow.
Rejects when no environment manager is registered for the scope, when the
manager does not support creation, or when creation itself fails - so handle
errors as well as `undefined`.
manager does not support creation, when a supplied name is unsupported, or when
creation itself fails - so handle errors as well as `undefined`.

```typescript
const created = await api.createEnvironment(projectUri, {
name: 'analysis-env',
quickCreate: true,
additionalPackages: ['requests'],
});
Expand Down Expand Up @@ -1864,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.
Expand Down Expand Up @@ -1909,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.
Expand Down
2 changes: 2 additions & 0 deletions src/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
26 changes: 24 additions & 2 deletions src/common/utils/pathUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,29 @@ export function isSameOrParentPath(parentPath: string, candidatePath: string): b
);
}

function matchesWindowsReservedDeviceName(value: string): boolean {
const deviceBaseName = value.split('.')[0];
return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(deviceBaseName);
}

/**
* Determines whether `value` can be used as a path segment on all supported platforms.
*
* @param value The path segment to validate.
* @returns `true` when the value is valid on Windows, macOS, and Linux.
*/
export function isValidPortablePathSegment(value: string): boolean {
return (
value.length > 0 &&
value.trim().length > 0 &&
value !== '.' &&
value !== '..' &&
!/[<>:"/\\|?*\u0000-\u001f]/.test(value) &&
!/[. ]$/.test(value) &&
!matchesWindowsReservedDeviceName(value)
);
}

/**
* Determines whether `value` maps to a reserved Windows device name (e.g. `CON`,
* `PRN`, `AUX`, `NUL`, `COM1`-`COM9`, `LPT1`-`LPT9`).
Expand All @@ -99,8 +122,7 @@ export function isSameOrParentPath(parentPath: string, candidatePath: string): b
* @returns `true` on Windows when `value` resolves to a reserved device name.
*/
export function isWindowsReservedDeviceName(value: string): boolean {
const deviceBaseName = value.split('.')[0];
return isWindows() && /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(deviceBaseName);
return isWindows() && matchesWindowsReservedDeviceName(value);
}

export function getResourceUri(resourcePath: string, root?: string): Uri | undefined {
Expand Down
29 changes: 25 additions & 4 deletions src/extensionApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,13 @@ import type {
ResolveEnvironmentContext,
SetEnvironmentScope,
} from './types';
import { PackageVersionLookupNotSupportedError } from './publicErrors';
import { CreateEnvironmentOptionNotSupportedError, PackageVersionLookupNotSupportedError } from './publicErrors';
import { INLINE_SCRIPT_MANAGER_ID } from './common/constants';
import { traceError, traceInfo } from './common/logging';
import { pickEnvironmentManager } from './common/pickers/managers';
import { timeout } from './common/utils/asyncUtils';
import { createDeferred } from './common/utils/deferred';
import { checkUri, 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';
Expand Down Expand Up @@ -308,6 +308,9 @@ export class PythonEnvironmentApiImpl implements PythonEnvironmentApi {
scope: CreateEnvironmentScope,
options: CreateEnvironmentOptions | undefined,
): Promise<PythonEnvironment | undefined> {
if (options?.name !== undefined && !isValidPortablePathSegment(options.name)) {
throw new Error('Environment name must be a valid portable path segment');
}
if (scope === 'global' || (!Array.isArray(scope) && scope instanceof Uri)) {
await waitForEnvManager(scope === 'global' ? undefined : [scope]);
const manager = this.envManagers.getEnvironmentManager(scope === 'global' ? undefined : scope);
Expand All @@ -317,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 && !(await manager.supportsCustomName(scope))) {
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);
Expand All @@ -334,12 +343,24 @@ export class PythonEnvironmentApiImpl implements PythonEnvironmentApi {
throw new Error('No environment managers found');
}

const managerId = await pickEnvironmentManager(managers);
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',
'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');
}
Expand Down
20 changes: 15 additions & 5 deletions src/features/envCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from 'vscode';
import {
CreateEnvironmentOptions,
CreateEnvironmentScope,
Pep440Version,
PythonEnvironment,
PythonEnvironmentApi,
Expand Down Expand Up @@ -209,13 +210,21 @@ export async function createAnyEnvironmentCommand(
},
): Promise<PythonEnvironment | undefined> {
const select = options?.selectEnvironment;
const getCompatibleManagers = async (scope: CreateEnvironmentScope): Promise<InternalEnvironmentManager[]> => {
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((m) => m.supportsCreate),
await getCompatibleManagers('global'),
undefined,
undefined,
true, // showEnterInterpreterPath
options?.name === undefined,
);

// Handle "Enter Interpreter Path" selection
Expand All @@ -239,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 && manager.supportsCreate && !defaultManagers.includes(manager)) {
if (manager && compatibleManagers.includes(manager) && !defaultManagers.includes(manager)) {
defaultManagers.push(manager);
}
});
Expand All @@ -255,10 +265,10 @@ export async function createAnyEnvironmentCommand(
manager = defaultManagers[0];
} else {
let managerId = await pickEnvironmentManager(
em.managers.filter((m) => m.supportsCreate),
compatibleManagers,
defaultManagers,
options?.showBackButton,
true, // showEnterInterpreterPath
options?.name === undefined,
);

// Handle "Enter Interpreter Path" selection
Expand Down
2 changes: 2 additions & 0 deletions src/legacyCapabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 8 additions & 1 deletion src/managers/builtin/inlineScript/envManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 8 additions & 1 deletion src/managers/builtin/sysPythonManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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.'),
Expand Down
7 changes: 7 additions & 0 deletions src/managers/builtin/venvManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export class VenvManager implements EnvironmentManager {
[EnvironmentCapability.GetSelected]: supportedCapability,
[EnvironmentCapability.SetSelected]: supportedCapability,
[EnvironmentCapability.Create]: supportedCapability,
[EnvironmentCapability.CreateNamed]: supportedCapability,
[EnvironmentCapability.Remove]: supportedCapability,
};

Expand Down Expand Up @@ -334,13 +335,19 @@ export class VenvManager implements EnvironmentManager {
this.globalEnv,
venvRoot,
options?.additionalPackages,
undefined,
options?.name,
);
}
} else {
// If quickCreate is not set that means the user triggered this method from
// 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 }),
});
}

Expand Down
Loading
Loading