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).

## [Unreleased]

### Added

- Added the flat `PythonEnvironmentApi.getAllExtraSearchPaths()` method, which returns the canonical legacy, global, and workspace-scoped environment search paths used by native discovery.

## [1.8.0]

### Added
Expand Down
5 changes: 4 additions & 1 deletion api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ export async function activate() {
for (const env of envs) {
console.log(env.displayName, env.version);
}

// Reuse the extension's canonical configured environment search paths
const searchPaths = await api.getAllExtraSearchPaths();
}
```

Expand Down Expand Up @@ -92,7 +95,7 @@ The complete manual documents every method and data type, organized by domain -
environments, packages, projects, execution, environment variables, and
extensibility - with field tables, parameter tables, return types, and examples.

- [Environments](https://github.com/microsoft/vscode-python-environments/blob/main/docs/README.md#environments) - discover, resolve, select, create, and remove interpreters
- [Environments](https://github.com/microsoft/vscode-python-environments/blob/main/docs/README.md#environments) - discover, resolve, select, create, and remove interpreters, and read configured search paths
- [Packages](https://github.com/microsoft/vscode-python-environments/blob/main/docs/README.md#packages) - list, install, uninstall, and look up versions
- [Projects](https://github.com/microsoft/vscode-python-environments/blob/main/docs/README.md#projects) - the folders the extension tracks
- [Execution](https://github.com/microsoft/vscode-python-environments/blob/main/docs/README.md#execution) - run Python in terminals, tasks, and background processes
Expand Down
1 change: 1 addition & 0 deletions api/test/consumer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ api.getPackageAvailableVersions(environment, 'example', {
errorMode: 'legacy',
}) satisfies Promise<Pep440Version[] | undefined>;
api.getPackageAvailableVersions(environment, 'example', { errorMode: 'throw' }) satisfies Promise<Pep440Version[]>;
api.getAllExtraSearchPaths() satisfies Promise<string[]>;

// Public error construction, discriminator, and narrowing.
const lookupError = new PackageVersionLookupNotSupportedError('unsupported');
Expand Down
36 changes: 34 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ every platform.

| Domain | Use it to | Representative members |
| --- | --- | --- |
| [Environments](#environments) | Discover, resolve, select, create, and remove interpreters. | `getEnvironments`, `resolveEnvironment`, `setEnvironment`, `createEnvironment` |
| [Environments](#environments) | Discover, resolve, select, create, and remove interpreters, and read configured search paths. | `getEnvironments`, `getAllExtraSearchPaths`, `resolveEnvironment`, `setEnvironment`, `createEnvironment` |
| [Packages](#packages) | Read and change what is installed in an environment. | `getPackages`, `managePackages`, `getPackageAvailableVersions` |
| [Projects](#projects) | Read and modify the set of Python projects. | `getPythonProjects`, `getPythonProject`, `addPythonProject` |
| [Execution](#execution) | Run Python with an environment already activated. | `createTerminal`, `runInTerminal`, `runAsTask`, `runInBackground` |
Expand Down Expand Up @@ -375,6 +375,38 @@ const bases = await api.getEnvironments('global');
const forProject = await api.getEnvironments(projectUri);
```

#### `getAllExtraSearchPaths`

Returns the canonical extra search paths used by the extension's native
environment discovery. This reads configuration only; it does not discover
environments or check whether returned directories exist.

```typescript
getAllExtraSearchPaths(): Promise<string[]>;
```

**Returns** `Promise<string[]>`, combining:

- legacy `python.venvPath` and `python.venvFolders` values;
- user-level `python-envs.globalSearchPaths`; and
- the effective workspace-folder or workspace-level
`python-envs.workspaceSearchPaths` for every open workspace folder.

Relative workspace paths are resolved against the folder that defines them, so
different values in a multi-root workspace stay associated with the correct
root. A user-level `workspaceSearchPaths` value is invalid and ignored. Empty
entries and exact duplicates are removed, Windows separators are normalized to
`/`, and glob patterns are preserved for the native discovery tool.

```typescript
if (typeof api.getAllExtraSearchPaths === 'function') {
const configuredSearchPaths = await api.getAllExtraSearchPaths();
}
```

Feature-detect the method when the consumer can run with an older installed
version of the Python Environments extension.

#### `refreshEnvironments`

Asks the relevant environment managers to re-discover environments.
Expand Down Expand Up @@ -1838,7 +1870,7 @@ directly on the single flat API object.

| Interface | Members grouped by the interface | Description |
| --- | --- | --- |
| `PythonEnvironmentsApi` | Environment discovery, resolution, and capabilities | Lists and refreshes discovered environments, resolves environment URIs, queries manager support, and reports discovery changes. |
| `PythonEnvironmentsApi` | Environment discovery, search-path configuration, resolution, and capabilities | Lists and refreshes discovered environments, returns configured search paths, resolves environment URIs, queries manager support, and reports discovery changes. |
| `PythonProjectEnvironmentApi` | Selected environment get/set | Reads, updates, and observes the selected environment for URI or global scopes. |
| `PythonEnvironmentManagementApi` | Environment creation/removal | Creates and removes environments through their associated environment managers. |
| `PythonEnvironmentItemApi` | Environment item creation | Converts provider-supplied environment information into an identified `PythonEnvironment`. |
Expand Down
9 changes: 9 additions & 0 deletions src/extensionApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import type { ProjectCreators } from './features/creators/projectCreators';
import type { PythonProjectManager } from './features/projectManager';
import type { InternalEnvironmentManager, InternalPackageManager } from './managers/common/registeredManagers';
import { PythonEnvironmentImpl, PythonPackageImpl } from './managers/common/models';
import { getAllExtraSearchPaths as resolveAllExtraSearchPaths } from './managers/common/nativePythonFinder';
import { waitForAllEnvManagers, waitForEnvManager, waitForEnvManagerId } from './features/common/managerReady';
import { EnvVarManager } from './features/execution/envVariableManager';
import { runAsTask } from './features/execution/runAsTask';
Expand Down Expand Up @@ -416,6 +417,14 @@ export class PythonEnvironmentApiImpl implements PythonEnvironmentApi {
const items = await manager.getEnvironments(currentScope);
return items;
}

/**
* Returns the canonical configured search paths used by native environment discovery.
*/
getAllExtraSearchPaths(): Promise<string[]> {
return resolveAllExtraSearchPaths();
}

onDidChangeEnvironments: Event<DidChangeEnvironmentsEventArgs> = this._onDidChangeEnvironments.event;
async setEnvironment(scope: SetEnvironmentScope, environment?: PythonEnvironment): Promise<void> {
const currentScope = checkUri(scope) as SetEnvironmentScope;
Expand Down
32 changes: 32 additions & 0 deletions src/test/extensionApi.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as managerReady from '../features/common/managerReady';
import { PythonEnvironmentApiImpl } from '../extensionApi';
import type { PythonProjectManager } from '../features/projectManager';
import { VENV_MANAGER_ID, INLINE_SCRIPT_MANAGER_ID } from '../common/constants';
import * as nativePythonFinder from '../managers/common/nativePythonFinder';
import { InternalEnvironmentManager, InternalPackageManager } from '../managers/common/registeredManagers';
import { createMockPythonEnvironment } from './mocks/pythonEnvironment';

Expand Down Expand Up @@ -79,6 +80,37 @@ suite('PythonEnvironmentApiImpl - onDidChangePythonProjects', () => {
});
});

suite('PythonEnvironmentApiImpl - getAllExtraSearchPaths', () => {
teardown(() => {
sinon.restore();
});

test('delegates to the canonical native finder helper', async () => {
const expected = ['configured-envs', 'workspace-envs'];
const getAllExtraSearchPaths = sinon
.stub(nativePythonFinder, 'getAllExtraSearchPaths')
.resolves(expected);

type ApiArgs = ConstructorParameters<typeof PythonEnvironmentApiImpl>;
const api = new PythonEnvironmentApiImpl(
{
onDidChangeActiveEnvironment: new EventEmitter().event,
onDidChangePackageProviderPackages: new EventEmitter().event,
} as unknown as ApiArgs[0],
{
getProjects: () => [],
onDidChangeProjects: new EventEmitter<void>().event,
} as unknown as ApiArgs[1],
{} as ApiArgs[2],
{} as ApiArgs[3],
{ onDidChangeEnvironmentVariables: new EventEmitter().event } as unknown as ApiArgs[4],
);

assert.strictEqual(await api.getAllExtraSearchPaths(), expected);
assert.ok(getAllExtraSearchPaths.calledOnceWithExactly());
});
});

suite('PythonEnvironmentApiImpl - getEnvironment timeout fallback', () => {
let clock: sinon.SinonFakeTimers;

Expand Down
53 changes: 53 additions & 0 deletions src/test/integration/multiroot/multiWorkspace.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
*/

import * as assert from 'assert';
import * as path from 'path';
import * as vscode from 'vscode';
import { PythonEnvironment, PythonEnvironmentApi } from '../../../api';
import { ENVS_EXTENSION_ID } from '../../constants';
Expand Down Expand Up @@ -162,6 +163,58 @@ suite('Integration: Multi-Root Workspace', function () {
assert.strictEqual(inspect2.key, 'python-envs.pythonProjects', 'Key should be python-envs.pythonProjects');
});

test('getAllExtraSearchPaths resolves folder settings against their owning roots', async function () {
const workspaceFolders = vscode.workspace.workspaceFolders!;
const firstFolder = workspaceFolders[0];
const secondFolder = workspaceFolders[1];
const firstConfig = vscode.workspace.getConfiguration('python-envs', firstFolder.uri);
const secondConfig = vscode.workspace.getConfiguration('python-envs', secondFolder.uri);

const originalFirstFolderValue =
firstConfig.inspect<string[]>('workspaceSearchPaths')?.workspaceFolderValue;
const originalSecondFolderValue =
secondConfig.inspect<string[]>('workspaceSearchPaths')?.workspaceFolderValue;

const firstRelativePath = '.api-first-root-envs';
const secondRelativePath = '.api-second-root-envs';
const normalize = (value: string) => value.replace(/\\/g, '/');

try {
await firstConfig.update(
'workspaceSearchPaths',
[firstRelativePath],
vscode.ConfigurationTarget.WorkspaceFolder,
);
await secondConfig.update(
'workspaceSearchPaths',
[secondRelativePath],
vscode.ConfigurationTarget.WorkspaceFolder,
);

const result = await api.getAllExtraSearchPaths();
const expectedFirst = normalize(path.resolve(firstFolder.uri.fsPath, firstRelativePath));
const expectedSecond = normalize(path.resolve(secondFolder.uri.fsPath, secondRelativePath));
const wrongFirstRoot = normalize(path.resolve(secondFolder.uri.fsPath, firstRelativePath));
const wrongSecondRoot = normalize(path.resolve(firstFolder.uri.fsPath, secondRelativePath));

assert.ok(result.includes(expectedFirst), 'First folder path should resolve against the first root');
assert.ok(result.includes(expectedSecond), 'Second folder path should resolve against the second root');
assert.ok(!result.includes(wrongFirstRoot), 'First folder path should not resolve against the second root');
assert.ok(!result.includes(wrongSecondRoot), 'Second folder path should not resolve against the first root');
} finally {
await secondConfig.update(
'workspaceSearchPaths',
originalSecondFolderValue,
vscode.ConfigurationTarget.WorkspaceFolder,
);
await firstConfig.update(
'workspaceSearchPaths',
originalFirstFolderValue,
vscode.ConfigurationTarget.WorkspaceFolder,
);
}
});

/**
* Test: Creation with multiple URIs selects manager
*
Expand Down
94 changes: 94 additions & 0 deletions src/test/integration/settingsBehavior.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
*/

import * as assert from 'assert';
import * as path from 'path';
import * as vscode from 'vscode';
import { PythonEnvironmentApi } from '../../api';
import { ENVS_EXTENSION_ID } from '../constants';
Expand Down Expand Up @@ -171,6 +172,99 @@ suite('Integration: Settings Behavior', function () {
// These tests verify settings and API work together correctly.
// =========================================================================

test('getAllExtraSearchPaths resolves configured settings through the public API', async function () {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
if (!workspaceFolder) {
this.skip();
return;
}

const pythonConfig = vscode.workspace.getConfiguration('python');
const globalEnvConfig = vscode.workspace.getConfiguration('python-envs');
const workspaceEnvConfig = vscode.workspace.getConfiguration('python-envs', workspaceFolder.uri);

const originalVenvPath = pythonConfig.inspect<string>('venvPath')?.globalValue;
const originalVenvFolders = pythonConfig.inspect<string[]>('venvFolders')?.globalValue;
const originalGlobalSearchPaths =
globalEnvConfig.inspect<string[]>('globalSearchPaths')?.globalValue;
const originalInvalidWorkspaceSearchPaths =
globalEnvConfig.inspect<string[]>('workspaceSearchPaths')?.globalValue;
const originalWorkspaceSearchPaths =
workspaceEnvConfig.inspect<string[]>('workspaceSearchPaths')?.workspaceValue;

const legacyPath = path.join(workspaceFolder.uri.fsPath, '.api-legacy-envs');
const duplicatePath = path.join(workspaceFolder.uri.fsPath, '.api-shared-envs');
const globalPath = path.join(workspaceFolder.uri.fsPath, '.api-global-envs');
const invalidGlobalWorkspacePath = path.join(
workspaceFolder.uri.fsPath,
'.api-invalid-global-workspace-envs',
);
const relativeWorkspacePath = '.api-workspace-envs';
const normalize = (value: string) => value.replace(/\\/g, '/');

try {
await pythonConfig.update('venvPath', legacyPath, vscode.ConfigurationTarget.Global);
await pythonConfig.update('venvFolders', [duplicatePath], vscode.ConfigurationTarget.Global);
await globalEnvConfig.update(
'globalSearchPaths',
[globalPath, duplicatePath, '', ' '],
vscode.ConfigurationTarget.Global,
);
await globalEnvConfig.update(
'workspaceSearchPaths',
[invalidGlobalWorkspacePath],
vscode.ConfigurationTarget.Global,
);
await workspaceEnvConfig.update(
'workspaceSearchPaths',
[relativeWorkspacePath, duplicatePath, '', ' '],
vscode.ConfigurationTarget.Workspace,
);

const result = await api.getAllExtraSearchPaths();
const expected = new Set([
normalize(legacyPath),
normalize(duplicatePath),
normalize(globalPath),
normalize(path.resolve(workspaceFolder.uri.fsPath, relativeWorkspacePath)),
]);

assert.deepStrictEqual(new Set(result), expected);
assert.strictEqual(
result.filter((item) => item === normalize(duplicatePath)).length,
1,
'An exact path shared by legacy, global, and workspace settings should be returned once',
);
assert.ok(
!result.includes(normalize(invalidGlobalWorkspacePath)),
'A user-level workspaceSearchPaths value should be ignored',
);
assert.ok(result.every((item) => item.trim().length > 0), 'Empty search paths should be ignored');
} finally {
await workspaceEnvConfig.update(
'workspaceSearchPaths',
originalWorkspaceSearchPaths,
vscode.ConfigurationTarget.Workspace,
);
await globalEnvConfig.update(
'workspaceSearchPaths',
originalInvalidWorkspaceSearchPaths,
vscode.ConfigurationTarget.Global,
);
await globalEnvConfig.update(
'globalSearchPaths',
originalGlobalSearchPaths,
vscode.ConfigurationTarget.Global,
);
await pythonConfig.update(
'venvFolders',
originalVenvFolders,
vscode.ConfigurationTarget.Global,
);
await pythonConfig.update('venvPath', originalVenvPath, vscode.ConfigurationTarget.Global);
}
});

/**
* Test: Settings and API are connected
*
Expand Down
12 changes: 12 additions & 0 deletions src/test/smoke/functional.smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ suite('Smoke: Functional Checks', function () {
// ENVIRONMENT DISCOVERY - Core feature must work
// =========================================================================

test('getAllExtraSearchPaths is exposed through the real extension API', async function () {
assert.strictEqual(typeof api.getAllExtraSearchPaths, 'function');

const searchPaths = await api.getAllExtraSearchPaths();

assert.ok(Array.isArray(searchPaths), 'getAllExtraSearchPaths should return an array');
for (const searchPath of searchPaths) {
assert.ok(searchPath.trim().length > 0, 'Search paths should not contain empty entries');
assert.ok(!searchPath.includes('\\'), `Search path should use forward slashes: ${searchPath}`);
}
});

test('getEnvironments returns an array', async function () {
// This test verifies discovery machinery works
// Even if no Python is installed, it should return an empty array, not throw
Expand Down
19 changes: 19 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1141,6 +1141,25 @@ export interface PythonEnvironmentsApi {
*/
getEnvironments(scope: GetEnvironmentsScope): Promise<PythonEnvironment[]>;

/**
* Returns the canonical extra search paths used by native environment discovery.
*
* The result combines the legacy `python.venvPath` and `python.venvFolders`
* settings with `python-envs.globalSearchPaths` and the effective
* `python-envs.workspaceSearchPaths` value for every workspace folder. Relative
* workspace paths are resolved against the folder that defines them, including in
* multi-root workspaces. A user/global `workspaceSearchPaths` value and empty
* entries are ignored, exact duplicate entries are removed, and path separators

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning · Non-blocking recommendation

📍 src/types.ts:1151
Verified isolated production-source probes returned empty and whitespace-only python.venvFolders entries, and whitespace-only python.venvPath, despite this guarantee. Filter blank strings in the canonical resolver before deduplication, preserving nonblank values, and add legacy-settings coverage so native discovery and the public API remain aligned.

[verified]

* are normalized to `/` for glob support.
*
* Returned entries may contain glob patterns and are not checked for existence.
* Consumers that support older installed extension versions should feature-detect
* this method before calling it.
*
* @returns A promise that resolves to the configured search paths.
*/
getAllExtraSearchPaths(): Promise<string[]>;

/**
* Event that is fired when the list of Python environments changes.
* @see {@link DidChangeEnvironmentsEventArgs}
Expand Down
Loading