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

## [1.6.0]

### Added

- Added optional `CreateEnvironmentOptions.name` so API consumers can request a specific name when creating an environment.

## [1.5.0]

### Added
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.5.0",
"version": "1.6.0",
"author": {
"name": "Microsoft Corporation"
},
Expand Down
9 changes: 6 additions & 3 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,11 +274,13 @@ applies to.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `quickCreate` | `boolean` | No | `true` creates without any prompts. `false` means the user explicitly declined quick create, so prompts are allowed. `undefined` leaves the decision to the manager, which may offer quick create. |
| `additionalPackages` | `string[]` | No | Packages to install in addition to whatever the manager installs by default. |
| `name` | `string` | `false` | Portable path segment to use as the new environment's name. Directory separators, control characters, Windows-reserved filename characters (such as `:` and `?`) and device names (such as `CON` and `NUL`), trailing periods or spaces, `.` and `..` are rejected. When supplied, the manager uses that name without prompting for another. When omitted, the manager may prompt for a name or choose a default. |
| `quickCreate` | `boolean` | `false` | `true` creates without any prompts. `false` means the user explicitly declined quick create, so prompts are allowed. `undefined` leaves the decision to the manager, which may offer quick create. |
| `additionalPackages` | `string[]` | `false` | Packages to install in addition to whatever the manager installs by default. |

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

**Returns** `Promise<PythonEnvironment | undefined>` - `undefined` when no
environment was created, for example because the user cancelled the flow.
Expand All @@ -516,6 +518,7 @@ errors as well as `undefined`.

```typescript
const created = await api.createEnvironment(projectUri, {
name: 'analysis-env',
quickCreate: true,
additionalPackages: ['requests'],
});
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
5 changes: 4 additions & 1 deletion src/extensionApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import { traceError, traceInfo } from './common/logging';
import { pickEnvironmentManager } from './common/pickers/managers';
import { timeout } from './common/utils/asyncUtils';
import { createDeferred } from './common/utils/deferred';
import { checkUri } from './common/utils/pathUtils';
import { checkUri, isValidPortablePathSegment } from './common/utils/pathUtils';
import { handlePythonPath } from './common/utils/pythonPath';
import type { EnvironmentManagers } from './features/envManagers';
import type { ProjectCreators } from './features/creators/projectCreators';
Expand Down Expand Up @@ -157,6 +157,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 Down
5 changes: 5 additions & 0 deletions src/managers/builtin/venvManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,13 +185,18 @@ export class VenvManager implements EnvironmentManager {
this.globalEnv,
venvRoot,
options?.additionalPackages,
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
17 changes: 10 additions & 7 deletions src/managers/builtin/venvStepBasedFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -161,8 +162,7 @@ async function selectBasePython(state: VenvCreationState): Promise<StepFunction

state.basePython = basePython;

// Next step: input venv name
return enterEnvironmentName;
return state.venvName === undefined ? enterEnvironmentName : selectPackages;
} catch (ex) {
if (ex === QuickInputButtons.Back) {
// Go back to create type selection if we came from there
Expand Down Expand Up @@ -269,8 +269,7 @@ async function selectPackages(state: VenvCreationState): Promise<StepFunction |
return null;
} catch (ex) {
if (ex === QuickInputButtons.Back) {
// Go back to environment name input
return enterEnvironmentName;
return state.suppliedName ? selectBasePython : enterEnvironmentName;
}
throw ex;
}
Expand Down Expand Up @@ -299,7 +298,7 @@ export async function createStepBasedVenvFlow(
manager: EnvironmentManager,
basePythons: PythonEnvironment[],
venvRoot: Uri,
options: { showQuickAndCustomOptions: boolean; additionalPackages?: string[] },
options: { showQuickAndCustomOptions: boolean; additionalPackages?: string[]; name?: string },
): Promise<CreateEnvironmentResult | undefined> {
// Sort and filter available Python environments
const sortedEnvs = ensureGlobalEnv(basePythons, log);
Expand All @@ -308,13 +307,18 @@ export async function createStepBasedVenvFlow(
envCreationErr: 'No suitable Python environments found',
};
}
if (options.name !== undefined && (await fse.pathExists(path.join(venvRoot.fsPath, options.name)))) {
Comment thread
edvilme marked this conversation as resolved.
return { envCreationErr: VenvManagerStrings.venvNameErrorExists };
}

// Initialize the state object that will track user selections
const state: VenvCreationState = {
sortedEnvs, // Store sorted environments in state to avoid re-sorting
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 {
Expand All @@ -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);
Expand Down
10 changes: 7 additions & 3 deletions src/managers/builtin/venvUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,7 @@ export async function quickCreateVenv(
baseEnv: PythonEnvironment,
venvRoot: Uri,
additionalPackages?: string[],
name?: string,
): Promise<CreateEnvironmentResult | undefined> {
const project = api.getPythonProject(venvRoot);

Expand All @@ -542,9 +543,12 @@ export async function quickCreateVenv(
return undefined;
}

// Check if .venv already exists
let venvPath = path.join(venvRoot.fsPath, '.venv');
const requestedName = name ?? '.venv';
let venvPath = path.join(venvRoot.fsPath, requestedName);
if (await fsapi.pathExists(venvPath)) {
if (name !== undefined) {
return { envCreationErr: VenvManagerStrings.venvNameErrorExists };
}
// increment to create a unique name, e.g. .venv-1
let i = 1;
while (await fsapi.pathExists(`${venvPath}-${i}`)) {
Expand All @@ -567,7 +571,7 @@ export async function createPythonVenv(
manager: EnvironmentManager,
basePythons: PythonEnvironment[],
venvRoot: Uri,
options: { showQuickAndCustomOptions: boolean; additionalPackages?: string[] },
options: { showQuickAndCustomOptions: boolean; additionalPackages?: string[]; name?: string },
): Promise<CreateEnvironmentResult | undefined> {
return createStepBasedVenvFlow(nativeFinder, api, log, manager, basePythons, venvRoot, options);
}
Expand Down
7 changes: 5 additions & 2 deletions src/managers/conda/condaEnvManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,10 +217,12 @@ export class CondaEnvManager implements EnvironmentManager, Disposable {
let result: PythonEnvironment | undefined;
if (options?.quickCreate) {
let envRoot: string | undefined = undefined;
let name: string | undefined = './.conda';
let name: string | undefined = options.name ?? './.conda';
if (context === 'global' || (Array.isArray(context) && context.length > 1)) {
envRoot = await getDefaultCondaPrefix();
name = await generateName(envRoot);
if (options.name === undefined) {
name = await generateName(envRoot);
}
} else {
const folder = this.api.getPythonProject(context instanceof Uri ? context : context[0]);
envRoot = folder?.uri.fsPath;
Expand All @@ -240,6 +242,7 @@ export class CondaEnvManager implements EnvironmentManager, Disposable {
this.log,
this,
context === 'global' ? undefined : context,
options?.name,
);
}
if (result) {
Expand Down
29 changes: 23 additions & 6 deletions src/managers/conda/condaStepBasedFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ interface CondaCreationState {

// For named environments
envName?: string;
suppliedName?: boolean;
cancelled?: boolean;

// For prefix environments
prefix?: string;
Expand Down Expand Up @@ -82,6 +84,7 @@ async function selectEnvironmentType(state: CondaCreationState): Promise<StepFun
)) as QuickPickItem | undefined;

if (!selection) {
state.cancelled = true;
return null;
}

Expand Down Expand Up @@ -128,16 +131,23 @@ async function selectPythonVersion(state: CondaCreationState): Promise<StepFunct
});

if (!selection) {
state.cancelled = true;
return null;
}

state.pythonVersion = (selection as QuickPickItem).description;

// Next step depends on environment type
return state.envType === getCondaNamedLabel() ? enterEnvironmentName : selectLocation;
if (state.envType === getCondaNamedLabel()) {
return state.envName === undefined ? enterEnvironmentName : null;
}
return selectLocation;
} catch (ex) {
if (ex === QuickInputButtons.Back) {
// Go back to environment type selection
if (state.suppliedName) {
state.cancelled = true;
return null;
}
return selectEnvironmentType;
}
throw ex;
Expand Down Expand Up @@ -280,28 +290,35 @@ export async function createStepBasedCondaFlow(
log: LogOutputChannel,
manager: EnvironmentManager,
uris?: Uri | Uri[],
name?: string,
): Promise<PythonEnvironment | undefined> {
// 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
Expand All @@ -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
}
Expand Down
Loading
Loading