diff --git a/src/client/common/vscodeApis/workspaceApis.ts b/src/client/common/vscodeApis/workspaceApis.ts index 98086001fd7a..5172cc1593ce 100644 --- a/src/client/common/vscodeApis/workspaceApis.ts +++ b/src/client/common/vscodeApis/workspaceApis.ts @@ -2,11 +2,16 @@ // Licensed under the MIT License. import { ConfigurationScope, workspace, WorkspaceConfiguration, WorkspaceFolder } from 'vscode'; +import { Resource } from '../types'; export function getWorkspaceFolders(): readonly WorkspaceFolder[] | undefined { return workspace.workspaceFolders; } +export function getWorkspaceFolder(uri: Resource): WorkspaceFolder | undefined { + return uri ? workspace.getWorkspaceFolder(uri) : undefined; +} + export function getWorkspaceFolderPaths(): string[] { return workspace.workspaceFolders?.map((w) => w.uri.fsPath) ?? []; } diff --git a/src/client/proposedApi.ts b/src/client/proposedApi.ts index 2bae98edef11..da79a3fc68e1 100644 --- a/src/client/proposedApi.ts +++ b/src/client/proposedApi.ts @@ -25,7 +25,7 @@ import { getEnvPath } from './pythonEnvironments/base/info/env'; import { IDiscoveryAPI } from './pythonEnvironments/base/locator'; import { IPythonExecutionFactory } from './common/process/types'; import { traceError, traceVerbose } from './logging'; -import { normCasePath } from './common/platform/fs-paths'; +import { isParentPath, normCasePath } from './common/platform/fs-paths'; import { sendTelemetryEvent } from './telemetry'; import { EventName } from './telemetry/constants'; import { @@ -35,7 +35,7 @@ import { } from './deprecatedProposedApi'; import { DeprecatedProposedAPI } from './deprecatedProposedApiTypes'; import { IEnvironmentVariablesProvider } from './common/variables/types'; -import { IWorkspaceService } from './common/application/types'; +import { getWorkspaceFolder, getWorkspaceFolders } from './common/vscodeApis/workspaceApis'; type ActiveEnvironmentChangeEvent = { resource: WorkspaceFolder | undefined; @@ -102,6 +102,19 @@ function getEnvReference(e: Environment) { return envClass; } +function filterUsingVSCodeContext(e: PythonEnvInfo) { + const folders = getWorkspaceFolders(); + if (e.searchLocation) { + // Only return local environments that are in the currently opened workspace folders. + const envFolderUri = e.searchLocation; + if (folders) { + return folders.some((folder) => isParentPath(envFolderUri.fsPath, folder.uri.fsPath)); + } + return false; + } + return true; +} + export function buildProposedApi( discoveryApi: IDiscoveryAPI, serviceContainer: IServiceContainer, @@ -110,7 +123,6 @@ export function buildProposedApi( const configService = serviceContainer.get(IConfigurationService); const disposables = serviceContainer.get(IDisposableRegistry); const extensions = serviceContainer.get(IExtensions); - const workspaceService = serviceContainer.get(IWorkspaceService); const envVarsProvider = serviceContainer.get(IEnvironmentVariablesProvider); function sendApiTelemetry(apiName: string) { extensions @@ -126,6 +138,11 @@ export function buildProposedApi( } disposables.push( discoveryApi.onChanged((e) => { + const env = e.new ?? e.old; + if (!env || !filterUsingVSCodeContext(env)) { + // Filter out environments that are not in the current workspace. + return; + } if (e.old) { if (e.new) { onEnvironmentsChanged.fire({ type: 'update', env: convertEnvInfoAndGetReference(e.new) }); @@ -156,7 +173,7 @@ export function buildProposedApi( }), envVarsProvider.onDidEnvironmentVariablesChange((e) => { onEnvironmentVariablesChanged.fire({ - resource: workspaceService.getWorkspaceFolder(e), + resource: getWorkspaceFolder(e), env: envVarsProvider.getEnvironmentVariablesSync(e), }); }), @@ -235,7 +252,10 @@ export function buildProposedApi( }, get known(): Environment[] { sendApiTelemetry('known'); - return discoveryApi.getEnvs().map((e) => convertEnvInfoAndGetReference(e)); + return discoveryApi + .getEnvs() + .filter((e) => filterUsingVSCodeContext(e)) + .map((e) => convertEnvInfoAndGetReference(e)); }, async refreshEnvironments(options?: RefreshOptions) { await discoveryApi.triggerRefresh(undefined, { @@ -280,7 +300,7 @@ export function convertCompleteEnvInfo(env: PythonEnvInfo): ResolvedEnvironment type: convertEnvType(env.type), name: env.name, folderUri: Uri.file(env.location), - workspaceFolder: env.searchLocation, + workspaceFolder: getWorkspaceFolder(env.searchLocation), } : undefined, version: version as ResolvedEnvironment['version'], diff --git a/src/client/proposedApiTypes.ts b/src/client/proposedApiTypes.ts index b2a2d3d80b97..55c6244eb791 100644 --- a/src/client/proposedApiTypes.ts +++ b/src/client/proposedApiTypes.ts @@ -32,6 +32,8 @@ export interface ProposedExtensionAPI { /** * Carries environments known to the extension at the time of fetching the property. Note this may not * contain all environments in the system as a refresh might be going on. + * + * Only reports environments in the current workspace. */ readonly known: readonly Environment[]; /** @@ -125,7 +127,7 @@ export type Environment = EnvironmentPath & { /** * Any specific workspace folder this environment is created for. */ - readonly workspaceFolder: Uri | undefined; + readonly workspaceFolder: WorkspaceFolder | undefined; } | undefined; /** diff --git a/src/client/pythonEnvironments/index.ts b/src/client/pythonEnvironments/index.ts index fddd647c93b1..3f7bac7d670e 100644 --- a/src/client/pythonEnvironments/index.ts +++ b/src/client/pythonEnvironments/index.ts @@ -3,6 +3,7 @@ import * as vscode from 'vscode'; import { Uri } from 'vscode'; +import { cloneDeep } from 'lodash'; import { getGlobalStorage, IPersistentStorage } from '../common/persistentState'; import { getOSType, OSType } from '../common/utils/platform'; import { ActivationResult, ExtensionState } from '../components'; @@ -34,6 +35,7 @@ import { } from './base/locators/composite/envsCollectionCache'; import { EnvsCollectionService } from './base/locators/composite/envsCollectionService'; import { IDisposable } from '../common/types'; +import { traceError } from '../logging'; /** * Set up the Python environments component (during extension activation).' @@ -192,6 +194,8 @@ function getFromStorage(storage: IPersistentStorage): PythonEnv e.searchLocation = Uri.parse(e.searchLocation); } else if ('scheme' in e.searchLocation && 'path' in e.searchLocation) { e.searchLocation = Uri.parse(`${e.searchLocation.scheme}://${e.searchLocation.path}`); + } else { + traceError('Unexpected search location', JSON.stringify(e.searchLocation)); } } return e; @@ -200,7 +204,8 @@ function getFromStorage(storage: IPersistentStorage): PythonEnv function putIntoStorage(storage: IPersistentStorage, envs: PythonEnvInfo[]): Promise { storage.set( - envs.map((e) => { + // We have to `cloneDeep()` here so that we don't overwrite the original `PythonEnvInfo` objects. + cloneDeep(envs).map((e) => { if (e.searchLocation) { // Make TS believe it is string. This is temporary. We need to serialize this in // a custom way. diff --git a/src/test/proposedApi.unit.test.ts b/src/test/proposedApi.unit.test.ts index 4b9afe8fd453..1a834d62a6a5 100644 --- a/src/test/proposedApi.unit.test.ts +++ b/src/test/proposedApi.unit.test.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import * as typemoq from 'typemoq'; +import * as sinon from 'sinon'; import { assert, expect } from 'chai'; import { Uri, EventEmitter, ConfigurationTarget, WorkspaceFolder } from 'vscode'; import { cloneDeep } from 'lodash'; @@ -11,6 +12,7 @@ import { IExtensions, IInterpreterPathService, IPythonSettings, + Resource, } from '../client/common/types'; import { IServiceContainer } from '../client/ioc/types'; import { @@ -35,8 +37,15 @@ import { } from '../client/proposedApiTypes'; import { IWorkspaceService } from '../client/common/application/types'; import { IEnvironmentVariablesProvider } from '../client/common/variables/types'; +import * as workspaceApis from '../client/common/vscodeApis/workspaceApis'; suite('Proposed Extension API', () => { + const workspacePath = 'path/to/workspace'; + const workspaceFolder = { + name: 'workspace', + uri: Uri.file(workspacePath), + index: 0, + }; let serviceContainer: typemoq.IMock; let discoverAPI: typemoq.IMock; let interpreterPathService: typemoq.IMock; @@ -52,6 +61,13 @@ suite('Proposed Extension API', () => { setup(() => { serviceContainer = typemoq.Mock.ofType(); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([workspaceFolder]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').callsFake((resource: Resource) => { + if (resource?.fsPath === workspaceFolder.uri.fsPath) { + return workspaceFolder; + } + return undefined; + }); discoverAPI = typemoq.Mock.ofType(); extensions = typemoq.Mock.ofType(); workspaceService = typemoq.Mock.ofType(); @@ -85,21 +101,20 @@ suite('Proposed Extension API', () => { teardown(() => { // Verify each API method sends telemetry regarding who called the API. extensions.verifyAll(); + sinon.restore(); }); test('Provide an event to track when environment variables change', async () => { - const resource = Uri.file('x'); - const folder = ({ uri: resource } as unknown) as WorkspaceFolder; + const resource = workspaceFolder.uri; const envVars = { PATH: 'path' }; envVarsProvider.setup((e) => e.getEnvironmentVariablesSync(resource)).returns(() => envVars); - workspaceService.setup((w) => w.getWorkspaceFolder(resource)).returns(() => folder); const events: EnvironmentVariablesChangeEvent[] = []; proposed.environments.onDidEnvironmentVariablesChange((e) => { events.push(e); }); onDidChangeEnvironmentVariables.fire(resource); await sleep(1); - assert.deepEqual(events, [{ env: envVars, resource: folder }]); + assert.deepEqual(events, [{ env: envVars, resource: workspaceFolder }]); }); test('getEnvironmentVariables: No resource', async () => { @@ -196,7 +211,7 @@ suite('Proposed Extension API', () => { kind: PythonEnvKind.System, arch: Architecture.x64, sysPrefix: 'prefix/path', - searchLocation: Uri.file('path/to/project'), + searchLocation: Uri.file(workspacePath), }); discoverAPI.setup((p) => p.resolveEnv(pythonPath)).returns(() => Promise.resolve(env)); @@ -216,13 +231,13 @@ suite('Proposed Extension API', () => { kind: PythonEnvKind.System, arch: Architecture.x64, sysPrefix: 'prefix/path', - searchLocation: Uri.file('path/to/project'), + searchLocation: Uri.file(workspacePath), }); const partialEnv = buildEnvInfo({ executable: pythonPath, kind: PythonEnvKind.System, sysPrefix: 'prefix/path', - searchLocation: Uri.file('path/to/project'), + searchLocation: Uri.file(workspacePath), }); discoverAPI.setup((p) => p.resolveEnv(pythonPath)).returns(() => Promise.resolve(env)); @@ -237,7 +252,7 @@ suite('Proposed Extension API', () => { }); test('environments: python found', async () => { - const envs = [ + const expectedEnvs = [ { executable: { filename: 'this/is/a/test/python/path1', @@ -281,12 +296,37 @@ suite('Proposed Extension API', () => { }, }, ]; + const envs = [ + ...expectedEnvs, + { + executable: { + filename: 'this/is/a/test/python/path3', + ctime: 1, + mtime: 2, + sysPrefix: 'prefix/path', + }, + version: { + major: 3, + minor: -1, + micro: -1, + }, + kind: PythonEnvKind.Venv, + arch: Architecture.x64, + name: '', + location: '', + source: [PythonEnvSource.PathEnvVar], + distro: { + org: '', + }, + searchLocation: Uri.file('path/outside/workspace'), + }, + ]; discoverAPI.setup((d) => d.getEnvs()).returns(() => envs); const actual = proposed.environments.known; const actualEnvs = actual?.map((a) => (a as EnvironmentReference).internal); assert.deepEqual( actualEnvs?.sort((a, b) => a.id.localeCompare(b.id)), - envs.map((e) => convertEnvInfo(e)).sort((a, b) => a.id.localeCompare(b.id)), + expectedEnvs.map((e) => convertEnvInfo(e)).sort((a, b) => a.id.localeCompare(b.id)), ); }); @@ -302,7 +342,7 @@ suite('Proposed Extension API', () => { executable: 'pythonPath', kind: PythonEnvKind.System, sysPrefix: 'prefix/path', - searchLocation: Uri.file('path/to/project'), + searchLocation: Uri.file(workspacePath), }), { executable: {