From 193b929f7cb1d890dc79423bbab7ddaabcf4b54c Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Wed, 17 Jul 2024 11:39:35 -0700 Subject: [PATCH] Use Native Python Finder as the main locator (#23823) Closes https://github.com/microsoft/vscode-python/issues/23719 --------- Co-authored-by: Don Jayamanne --- src/client/environmentApi.ts | 7 +- .../interpreter/display/progressDisplay.ts | 2 + src/client/pythonEnvironments/base/locator.ts | 1 + .../locators/common/nativePythonFinder.ts | 23 +- .../composite/envsCollectionService.ts | 8 +- .../base/locators/lowLevel/nativeLocator.ts | 6 +- src/client/pythonEnvironments/index.ts | 18 + src/client/pythonEnvironments/nativeAPI.ts | 338 ++++++++++++++++++ .../envsCollectionService.unit.test.ts | 21 +- .../pythonEnvironments/nativeAPI.unit.test.ts | 286 +++++++++++++++ 10 files changed, 687 insertions(+), 23 deletions(-) create mode 100644 src/client/pythonEnvironments/nativeAPI.ts create mode 100644 src/test/pythonEnvironments/nativeAPI.unit.test.ts diff --git a/src/client/environmentApi.ts b/src/client/environmentApi.ts index 5b77ecde1a9d..6c4b5cf94d92 100644 --- a/src/client/environmentApi.ts +++ b/src/client/environmentApi.ts @@ -9,7 +9,7 @@ import { Architecture } from './common/utils/platform'; import { IServiceContainer } from './ioc/types'; import { PythonEnvInfo, PythonEnvKind, PythonEnvType } from './pythonEnvironments/base/info'; import { getEnvPath } from './pythonEnvironments/base/info/env'; -import { IDiscoveryAPI } from './pythonEnvironments/base/locator'; +import { IDiscoveryAPI, ProgressReportStage } from './pythonEnvironments/base/locator'; import { IPythonExecutionFactory } from './common/process/types'; import { traceError, traceVerbose } from './logging'; import { isParentPath, normCasePath } from './common/platform/fs-paths'; @@ -147,6 +147,11 @@ export function buildEnvironmentApi( .ignoreErrors(); } disposables.push( + discoveryApi.onProgress((e) => { + if (e.stage === ProgressReportStage.discoveryFinished) { + knownCache = initKnownCache(); + } + }), discoveryApi.onChanged((e) => { const env = e.new ?? e.old; if (!env || !filterUsingVSCodeContext(env)) { diff --git a/src/client/interpreter/display/progressDisplay.ts b/src/client/interpreter/display/progressDisplay.ts index 5194dd8a5103..d9e85b4caf44 100644 --- a/src/client/interpreter/display/progressDisplay.ts +++ b/src/client/interpreter/display/progressDisplay.ts @@ -39,6 +39,8 @@ export class InterpreterLocatorProgressStatubarHandler implements IExtensionSing if (refreshPromise) { refreshPromise.then(() => this.hideProgress()); } + } else if (event.stage === ProgressReportStage.discoveryFinished) { + this.hideProgress(); } }, this, diff --git a/src/client/pythonEnvironments/base/locator.ts b/src/client/pythonEnvironments/base/locator.ts index da73735cb323..0c7307f32471 100644 --- a/src/client/pythonEnvironments/base/locator.ts +++ b/src/client/pythonEnvironments/base/locator.ts @@ -68,6 +68,7 @@ export interface IPythonEnvsIterator extends IAsyncIterableIt } export enum ProgressReportStage { + idle = 'idle', discoveryStarted = 'discoveryStarted', allPathsDiscovered = 'allPathsDiscovered', discoveryFinished = 'discoveryFinished', diff --git a/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts b/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts index 0905540fd50f..6e83742c27b3 100644 --- a/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts +++ b/src/client/pythonEnvironments/base/locators/common/nativePythonFinder.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { Disposable, EventEmitter, Event, Uri } from 'vscode'; +import { Disposable, EventEmitter, Event, Uri, LogOutputChannel } from 'vscode'; import * as ch from 'child_process'; import * as path from 'path'; import * as rpc from 'vscode-jsonrpc/node'; @@ -29,7 +29,7 @@ export interface NativeEnvInfo { displayName?: string; name?: string; executable?: string; - kind: string; + kind?: string; version?: string; prefix?: string; manager?: NativeEnvManagerInfo; @@ -57,10 +57,11 @@ export type NativeCondaInfo = { environmentsFromTxt: string[]; }; -export interface NativeGlobalPythonFinder extends Disposable { +export interface NativePythonFinder extends Disposable { resolve(executable: string): Promise; refresh(): AsyncIterable; categoryToKind(category?: string): PythonEnvKind; + logger(): LogOutputChannel; getCondaInfo(): Promise; find(searchPath: string): Promise; } @@ -70,7 +71,7 @@ interface NativeLog { message: string; } -class NativeGlobalPythonFinderImpl extends DisposableBase implements NativeGlobalPythonFinder { +class NativeGlobalPythonFinderImpl extends DisposableBase implements NativePythonFinder { private readonly connection: rpc.MessageConnection; private firstRefreshResults: undefined | (() => AsyncGenerator); @@ -172,6 +173,10 @@ class NativeGlobalPythonFinderImpl extends DisposableBase implements NativeGloba } } + logger(): LogOutputChannel { + return this.outputChannel; + } + refreshFirstTime() { const result = this.doRefresh(); const completed = createDeferredFrom(result.completed); @@ -304,7 +309,6 @@ class NativeGlobalPythonFinderImpl extends DisposableBase implements NativeGloba disposable.add( this.connection.onNotification('environment', (data: NativeEnvInfo) => { this.outputChannel.info(`Discovered env: ${data.executable || data.prefix}`); - this.outputChannel.trace(`Discovered env info:\n ${JSON.stringify(data, undefined, 4)}`); // We know that in the Python extension if either Version of Prefix is not provided by locator // Then we end up resolving the information. // Lets do that here, @@ -321,7 +325,6 @@ class NativeGlobalPythonFinderImpl extends DisposableBase implements NativeGloba }) .then((environment) => { this.outputChannel.info(`Resolved ${environment.executable}`); - this.outputChannel.trace(`Environment resolved:\n ${JSON.stringify(data, undefined, 4)}`); discovered.fire(environment); }) .catch((ex) => this.outputChannel.error(`Error in Resolving ${JSON.stringify(data)}`, ex)); @@ -419,6 +422,10 @@ function getPythonSettingAndUntildify(name: string, scope?: Uri): T | undefin return value; } -export function createNativeGlobalPythonFinder(): NativeGlobalPythonFinder { - return new NativeGlobalPythonFinderImpl(); +let _finder: NativePythonFinder | undefined; +export function getNativePythonFinder(): NativePythonFinder { + if (!_finder) { + _finder = new NativeGlobalPythonFinderImpl(); + } + return _finder; } diff --git a/src/client/pythonEnvironments/base/locators/composite/envsCollectionService.ts b/src/client/pythonEnvironments/base/locators/composite/envsCollectionService.ts index 4d6805a4609e..c1ac9304c388 100644 --- a/src/client/pythonEnvironments/base/locators/composite/envsCollectionService.ts +++ b/src/client/pythonEnvironments/base/locators/composite/envsCollectionService.ts @@ -25,11 +25,7 @@ import { import { getQueryFilter } from '../../locatorUtils'; import { PythonEnvCollectionChangedEvent, PythonEnvsWatcher } from '../../watcher'; import { IEnvsCollectionCache } from './envsCollectionCache'; -import { - createNativeGlobalPythonFinder, - NativeEnvInfo, - NativeGlobalPythonFinder as NativePythonFinder, -} from '../common/nativePythonFinder'; +import { getNativePythonFinder, NativeEnvInfo, NativePythonFinder } from '../common/nativePythonFinder'; import { pathExists } from '../../../../common/platform/fs-paths'; import { noop } from '../../../../common/utils/misc'; import { parseVersion } from '../../info/pythonVersion'; @@ -55,7 +51,7 @@ export class EnvsCollectionService extends PythonEnvsWatcher(); - private nativeFinder = createNativeGlobalPythonFinder(); + private nativeFinder = getNativePythonFinder(); public refreshState = ProgressReportStage.discoveryFinished; diff --git a/src/client/pythonEnvironments/base/locators/lowLevel/nativeLocator.ts b/src/client/pythonEnvironments/base/locators/lowLevel/nativeLocator.ts index 27dd9828c229..8d17a3488e47 100644 --- a/src/client/pythonEnvironments/base/locators/lowLevel/nativeLocator.ts +++ b/src/client/pythonEnvironments/base/locators/lowLevel/nativeLocator.ts @@ -10,7 +10,7 @@ import { Conda } from '../../../common/environmentManagers/conda'; import { traceError } from '../../../../logging'; import type { KnownEnvironmentTools } from '../../../../api/types'; import { setPyEnvBinary } from '../../../common/environmentManagers/pyenv'; -import { NativeGlobalPythonFinder, createNativeGlobalPythonFinder } from '../common/nativePythonFinder'; +import { NativePythonFinder, getNativePythonFinder } from '../common/nativePythonFinder'; import { disposeAll } from '../../../../common/utils/resourceLifecycle'; import { Architecture } from '../../../../common/utils/platform'; @@ -54,11 +54,11 @@ export class NativeLocator implements ILocator, IDisposable { private readonly disposables: IDisposable[] = []; - private readonly finder: NativeGlobalPythonFinder; + private readonly finder: NativePythonFinder; constructor() { this.onChanged = this.onChangedEmitter.event; - this.finder = createNativeGlobalPythonFinder(); + this.finder = getNativePythonFinder(); this.disposables.push(this.onChangedEmitter, this.finder); } diff --git a/src/client/pythonEnvironments/index.ts b/src/client/pythonEnvironments/index.ts index 0bd766b4553d..91064bb67599 100644 --- a/src/client/pythonEnvironments/index.ts +++ b/src/client/pythonEnvironments/index.ts @@ -40,9 +40,17 @@ import { traceError } from '../logging'; import { ActiveStateLocator } from './base/locators/lowLevel/activeStateLocator'; import { CustomWorkspaceLocator } from './base/locators/lowLevel/customWorkspaceLocator'; import { PixiLocator } from './base/locators/lowLevel/pixiLocator'; +import { getConfiguration } from '../common/vscodeApis/workspaceApis'; +import { getNativePythonFinder } from './base/locators/common/nativePythonFinder'; +import { createNativeEnvironmentsApi } from './nativeAPI'; const PYTHON_ENV_INFO_CACHE_KEY = 'PYTHON_ENV_INFO_CACHEv2'; +export function shouldUseNativeLocator(): boolean { + const config = getConfiguration('python'); + return config.get('locator', 'js') === 'native'; +} + /** * Set up the Python environments component (during extension activation).' */ @@ -50,6 +58,16 @@ export async function initialize(ext: ExtensionState): Promise { // Set up the legacy IOC container before api is created. initializeLegacyExternalDependencies(ext.legacyIOC.serviceContainer); + if (shouldUseNativeLocator()) { + const finder = getNativePythonFinder(); + const api = createNativeEnvironmentsApi(finder); + registerNewDiscoveryForIOC( + // These are what get wrapped in the legacy adapter. + ext.legacyIOC.serviceManager, + api, + ); + return api; + } const api = await createPythonEnvironments(() => createLocator(ext)); registerNewDiscoveryForIOC( // These are what get wrapped in the legacy adapter. diff --git a/src/client/pythonEnvironments/nativeAPI.ts b/src/client/pythonEnvironments/nativeAPI.ts new file mode 100644 index 000000000000..dca9b17d4dc1 --- /dev/null +++ b/src/client/pythonEnvironments/nativeAPI.ts @@ -0,0 +1,338 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as path from 'path'; +import { Disposable, Event, EventEmitter } from 'vscode'; +import { PythonEnvInfo, PythonEnvKind, PythonEnvType, PythonVersion } from './base/info'; +import { + GetRefreshEnvironmentsOptions, + IDiscoveryAPI, + ProgressNotificationEvent, + ProgressReportStage, + PythonLocatorQuery, + TriggerRefreshOptions, +} from './base/locator'; +import { PythonEnvCollectionChangedEvent } from './base/watcher'; +import { NativeEnvInfo, NativePythonFinder } from './base/locators/common/nativePythonFinder'; +import { createDeferred, Deferred } from '../common/utils/async'; +import { Architecture } from '../common/utils/platform'; +import { parseVersion } from './base/info/pythonVersion'; +import { cache } from '../common/utils/decorators'; +import { traceError, traceLog } from '../logging'; +import { StopWatch } from '../common/utils/stopWatch'; +import { FileChangeType } from '../common/platform/fileSystemWatcher'; + +function makeExecutablePath(prefix?: string): string { + if (!prefix) { + return process.platform === 'win32' ? 'python.exe' : 'python'; + } + return process.platform === 'win32' ? path.join(prefix, 'python.exe') : path.join(prefix, 'python'); +} + +function toArch(a: string | undefined): Architecture { + switch (a) { + case 'x86': + return Architecture.x86; + case 'x64': + return Architecture.x64; + default: + return Architecture.Unknown; + } +} + +function getLocation(nativeEnv: NativeEnvInfo): string { + if (nativeEnv.prefix) { + return nativeEnv.prefix; + } + if (nativeEnv.executable) { + return nativeEnv.executable; + } + // We should not get here: either prefix or executable should always be available + return ''; +} + +function kindToShortString(kind: PythonEnvKind): string | undefined { + switch (kind) { + case PythonEnvKind.Poetry: + return 'poetry'; + case PythonEnvKind.Pyenv: + return 'pyenv'; + case PythonEnvKind.VirtualEnv: + case PythonEnvKind.Venv: + case PythonEnvKind.VirtualEnvWrapper: + case PythonEnvKind.OtherVirtual: + return 'venv'; + case PythonEnvKind.Pipenv: + return 'pipenv'; + case PythonEnvKind.Conda: + return 'conda'; + case PythonEnvKind.ActiveState: + return 'active-state'; + case PythonEnvKind.MicrosoftStore: + return 'Microsoft Store'; + case PythonEnvKind.Hatch: + return 'hatch'; + case PythonEnvKind.Pixi: + return 'pixi'; + case PythonEnvKind.System: + case PythonEnvKind.Unknown: + case PythonEnvKind.OtherGlobal: + case PythonEnvKind.Custom: + default: + return undefined; + } +} + +function toShortVersionString(version: PythonVersion): string { + return `${version.major}.${version.minor}.${version.micro}`.trim(); +} + +function getDisplayName(version: PythonVersion, kind: PythonEnvKind, arch: Architecture, name?: string): string { + const versionStr = toShortVersionString(version); + const kindStr = kindToShortString(kind); + if (arch === Architecture.x86) { + if (kindStr) { + return name ? `Python ${versionStr} 32-bit ('${name}')` : `Python ${versionStr} 32-bit (${kindStr})`; + } + return name ? `Python ${versionStr} 32-bit ('${name}')` : `Python ${versionStr} 32-bit`; + } + if (kindStr) { + return name ? `Python ${versionStr} ('${name}')` : `Python ${versionStr} (${kindStr})`; + } + return name ? `Python ${versionStr} ('${name}')` : `Python ${versionStr}`; +} + +function validEnv(finder: NativePythonFinder, nativeEnv: NativeEnvInfo): boolean { + if (nativeEnv.prefix === undefined && nativeEnv.executable === undefined) { + finder.logger().error(`Invalid environment [native]: ${JSON.stringify(nativeEnv)}`); + return false; + } + return true; +} + +function getEnvType(kind: PythonEnvKind): PythonEnvType | undefined { + switch (kind) { + case PythonEnvKind.Poetry: + case PythonEnvKind.Pyenv: + case PythonEnvKind.VirtualEnv: + case PythonEnvKind.Venv: + case PythonEnvKind.VirtualEnvWrapper: + case PythonEnvKind.OtherVirtual: + case PythonEnvKind.Pipenv: + case PythonEnvKind.ActiveState: + case PythonEnvKind.Hatch: + case PythonEnvKind.Pixi: + return PythonEnvType.Virtual; + + case PythonEnvKind.Conda: + return PythonEnvType.Conda; + + case PythonEnvKind.System: + case PythonEnvKind.Unknown: + case PythonEnvKind.OtherGlobal: + case PythonEnvKind.Custom: + case PythonEnvKind.MicrosoftStore: + default: + return undefined; + } +} + +function getName(nativeEnv: NativeEnvInfo, kind: PythonEnvKind): string { + if (nativeEnv.name) { + return nativeEnv.name; + } + + const envType = getEnvType(kind); + if (nativeEnv.prefix && (envType === PythonEnvType.Conda || envType === PythonEnvType.Virtual)) { + return path.basename(nativeEnv.prefix); + } + return ''; +} + +function toPythonEnvInfo(finder: NativePythonFinder, nativeEnv: NativeEnvInfo): PythonEnvInfo | undefined { + if (!validEnv(finder, nativeEnv)) { + return undefined; + } + const kind = finder.categoryToKind(nativeEnv.kind); + const arch = toArch(nativeEnv.arch); + const version: PythonVersion = parseVersion(nativeEnv.version ?? ''); + const name = getName(nativeEnv, kind); + const displayName = nativeEnv.version + ? getDisplayName(version, kind, arch, name) + : nativeEnv.displayName ?? 'Python'; + + return { + name, + location: getLocation(nativeEnv), + kind, + executable: { + filename: nativeEnv.executable ?? makeExecutablePath(nativeEnv.prefix), + sysPrefix: nativeEnv.prefix ?? '', + ctime: -1, + mtime: -1, + }, + version: { + sysVersion: nativeEnv.version, + major: version.major, + minor: version.minor, + micro: version.micro, + }, + arch, + distro: { + org: '', + }, + source: [], + detailedDisplayName: displayName, + display: displayName, + type: getEnvType(kind), + }; +} + +class NativePythonEnvironments implements IDiscoveryAPI, Disposable { + private _onProgress: EventEmitter; + + private _onChanged: EventEmitter; + + private _refreshPromise?: Deferred; + + private _envs: PythonEnvInfo[] = []; + + constructor(private readonly finder: NativePythonFinder) { + this._onProgress = new EventEmitter(); + this._onChanged = new EventEmitter(); + this.onProgress = this._onProgress.event; + this.onChanged = this._onChanged.event; + this.refreshState = ProgressReportStage.idle; + } + + refreshState: ProgressReportStage; + + onProgress: Event; + + onChanged: Event; + + getRefreshPromise(_options?: GetRefreshEnvironmentsOptions): Promise | undefined { + return this._refreshPromise?.promise; + } + + triggerRefresh(_query?: PythonLocatorQuery, _options?: TriggerRefreshOptions): Promise { + const stopwatch = new StopWatch(); + traceLog('Native locator: Refresh started'); + if (this.refreshState === ProgressReportStage.discoveryStarted && this._refreshPromise?.promise) { + return this._refreshPromise?.promise; + } + + this.refreshState = ProgressReportStage.discoveryStarted; + this._onProgress.fire({ stage: this.refreshState }); + this._refreshPromise = createDeferred(); + + setImmediate(async () => { + try { + for await (const native of this.finder.refresh()) { + if (!validEnv(this.finder, native)) { + // eslint-disable-next-line no-continue + continue; + } + try { + const envPath = native.executable ?? native.prefix; + const version = native.version ? parseVersion(native.version) : undefined; + + if (this.finder.categoryToKind(native.kind) === PythonEnvKind.Conda && !native.executable) { + // This is a conda env without python, no point trying to resolve this. + // There is nothing to resolve + this.addEnv(native); + } else if ( + envPath && + (!version || version.major < 0 || version.minor < 0 || version.micro < 0) + ) { + // We have a path, but no version info, try to resolve the environment. + this.finder + .resolve(envPath) + .then((env) => { + if (env) { + this.addEnv(env); + } + }) + .ignoreErrors(); + } else if ( + envPath && + version && + version.major >= 0 && + version.minor >= 0 && + version.micro >= 0 + ) { + this.addEnv(native); + } else { + traceError(`Failed to process environment: ${JSON.stringify(native)}`); + } + } catch (err) { + traceError(`Failed to process environment: ${err}`); + } + } + this._refreshPromise?.resolve(); + } catch (error) { + this._refreshPromise?.reject(error); + } finally { + traceLog(`Native locator: Refresh finished in ${stopwatch.elapsedTime} ms`); + this.refreshState = ProgressReportStage.discoveryFinished; + this._refreshPromise = undefined; + this._onProgress.fire({ stage: this.refreshState }); + } + }); + + return this._refreshPromise?.promise; + } + + getEnvs(_query?: PythonLocatorQuery): PythonEnvInfo[] { + return this._envs; + } + + addEnv(native: NativeEnvInfo): void { + const info = toPythonEnvInfo(this.finder, native); + if (!info) { + return; + } + const old = this._envs.find((item) => item.executable.filename === info.executable.filename); + if (old) { + this._envs = this._envs.filter((item) => item.executable.filename !== info.executable.filename); + this._envs.push(info); + this._onChanged.fire({ type: FileChangeType.Changed, old, new: info }); + } else { + this._envs.push(info); + this._onChanged.fire({ type: FileChangeType.Created, new: info }); + } + } + + @cache(30_000, true) + async resolveEnv(envPath?: string): Promise { + if (envPath === undefined) { + return undefined; + } + const native = await this.finder.resolve(envPath); + if (native) { + const env = toPythonEnvInfo(this.finder, native); + if (env) { + const old = this._envs.find((item) => item.executable.filename === env.executable.filename); + if (old) { + this._envs = this._envs.filter((item) => item.executable.filename !== env.executable.filename); + this._envs.push(env); + this._onChanged.fire({ type: FileChangeType.Changed, old, new: env }); + } + } + + return env; + } + return undefined; + } + + dispose(): void { + this._onProgress.dispose(); + this._onChanged.dispose(); + } +} + +export function createNativeEnvironmentsApi(finder: NativePythonFinder): IDiscoveryAPI { + const native = new NativePythonEnvironments(finder); + native.triggerRefresh().ignoreErrors(); + return native; +} diff --git a/src/test/pythonEnvironments/base/locators/composite/envsCollectionService.unit.test.ts b/src/test/pythonEnvironments/base/locators/composite/envsCollectionService.unit.test.ts index 92978738373d..1f319f87d86b 100644 --- a/src/test/pythonEnvironments/base/locators/composite/envsCollectionService.unit.test.ts +++ b/src/test/pythonEnvironments/base/locators/composite/envsCollectionService.unit.test.ts @@ -7,7 +7,7 @@ import { assert, expect } from 'chai'; import { cloneDeep } from 'lodash'; import * as path from 'path'; import * as sinon from 'sinon'; -import { EventEmitter, Uri } from 'vscode'; +import { EventEmitter, LogOutputChannel, Uri } from 'vscode'; import { FileChangeType } from '../../../../../client/common/platform/fileSystemWatcher'; import { createDeferred, createDeferredFromPromise, sleep } from '../../../../../client/common/utils/async'; import { PythonEnvInfo, PythonEnvKind } from '../../../../../client/pythonEnvironments/base/info'; @@ -27,8 +27,15 @@ import { SimpleLocator } from '../../common'; import { assertEnvEqual, assertEnvsEqual, createFile, deleteFile } from '../envTestUtils'; import { OSType, getOSType } from '../../../../common'; import * as nativeFinder from '../../../../../client/pythonEnvironments/base/locators/common/nativePythonFinder'; +import { MockOutputChannel } from '../../../../mockClasses'; + +class MockNativePythonFinder implements nativeFinder.NativePythonFinder { + private output: LogOutputChannel; + + constructor() { + this.output = new MockOutputChannel('Python Locator'); + } -class MockNativePythonFinder implements nativeFinder.NativeGlobalPythonFinder { find(_searchPath: string): Promise { throw new Error('Method not implemented.'); } @@ -54,13 +61,17 @@ class MockNativePythonFinder implements nativeFinder.NativeGlobalPythonFinder { })(); } + logger(): LogOutputChannel { + return this.output; + } + dispose() { /** noop */ } } suite('Python envs locator - Environments Collection', async () => { - let createNativeGlobalPythonFinderStub: sinon.SinonStub; + let getNativePythonFinderStub: sinon.SinonStub; let collectionService: EnvsCollectionService; let storage: PythonEnvInfo[]; @@ -172,8 +183,8 @@ suite('Python envs locator - Environments Collection', async () => { } setup(async () => { - createNativeGlobalPythonFinderStub = sinon.stub(nativeFinder, 'createNativeGlobalPythonFinder'); - createNativeGlobalPythonFinderStub.returns(new MockNativePythonFinder()); + getNativePythonFinderStub = sinon.stub(nativeFinder, 'getNativePythonFinder'); + getNativePythonFinderStub.returns(new MockNativePythonFinder()); storage = []; const parentLocator = new SimpleLocator(getLocatorEnvs()); const cache = await createCollectionCache({ diff --git a/src/test/pythonEnvironments/nativeAPI.unit.test.ts b/src/test/pythonEnvironments/nativeAPI.unit.test.ts new file mode 100644 index 000000000000..a56b78b33f5b --- /dev/null +++ b/src/test/pythonEnvironments/nativeAPI.unit.test.ts @@ -0,0 +1,286 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +/* eslint-disable class-methods-use-this */ + +import { assert } from 'chai'; +import * as path from 'path'; +import * as typemoq from 'typemoq'; +import * as sinon from 'sinon'; +import * as nativeAPI from '../../client/pythonEnvironments/nativeAPI'; +import { IDiscoveryAPI } from '../../client/pythonEnvironments/base/locator'; +import { + NativeEnvInfo, + NativePythonFinder, +} from '../../client/pythonEnvironments/base/locators/common/nativePythonFinder'; +import { Architecture } from '../../client/common/utils/platform'; +import { PythonEnvInfo, PythonEnvKind, PythonEnvType } from '../../client/pythonEnvironments/base/info'; +import { isWindows } from '../../client/common/platform/platformService'; + +suite('Native Python API', () => { + let api: IDiscoveryAPI; + let mockFinder: typemoq.IMock; + + const basicEnv: NativeEnvInfo = { + displayName: 'Basic Python', + name: 'basic_python', + executable: '/usr/bin/python', + kind: 'system', + version: `3.12.0`, + prefix: '/usr/bin', + }; + + const basicEnv2: NativeEnvInfo = { + displayName: 'Basic Python', + name: 'basic_python', + executable: '/usr/bin/python', + kind: 'system', + version: undefined, // this is intentionally set to trigger resolve + prefix: '/usr/bin', + }; + + const expectedBasicEnv: PythonEnvInfo = { + arch: Architecture.Unknown, + detailedDisplayName: "Python 3.12.0 ('basic_python')", + display: "Python 3.12.0 ('basic_python')", + distro: { org: '' }, + executable: { filename: '/usr/bin/python', sysPrefix: '/usr/bin', ctime: -1, mtime: -1 }, + kind: PythonEnvKind.System, + location: '/usr/bin', + source: [], + name: 'basic_python', + type: undefined, + version: { sysVersion: '3.12.0', major: 3, minor: 12, micro: 0 }, + }; + + const conda: NativeEnvInfo = { + displayName: 'Conda Python', + name: 'conda_python', + executable: '/home/user/.conda/envs/conda_python/python', + kind: 'conda', + version: `3.12.0`, + prefix: '/home/user/.conda/envs/conda_python', + }; + + const conda1: NativeEnvInfo = { + displayName: 'Conda Python', + name: 'conda_python', + executable: '/home/user/.conda/envs/conda_python/python', + kind: 'conda', + version: undefined, // this is intentionally set to test conda without python + prefix: '/home/user/.conda/envs/conda_python', + }; + + const conda2: NativeEnvInfo = { + displayName: 'Conda Python', + name: 'conda_python', + executable: undefined, // this is intentionally set to test env with no executable + kind: 'conda', + version: undefined, // this is intentionally set to test conda without python + prefix: '/home/user/.conda/envs/conda_python', + }; + + const exePath = isWindows() + ? path.join('/home/user/.conda/envs/conda_python', 'python.exe') + : path.join('/home/user/.conda/envs/conda_python', 'python'); + + const expectedConda1: PythonEnvInfo = { + arch: Architecture.Unknown, + detailedDisplayName: "Python 3.12.0 ('conda_python')", + display: "Python 3.12.0 ('conda_python')", + distro: { org: '' }, + executable: { + filename: '/home/user/.conda/envs/conda_python/python', + sysPrefix: '/home/user/.conda/envs/conda_python', + ctime: -1, + mtime: -1, + }, + kind: PythonEnvKind.Conda, + location: '/home/user/.conda/envs/conda_python', + source: [], + name: 'conda_python', + type: PythonEnvType.Conda, + version: { sysVersion: '3.12.0', major: 3, minor: 12, micro: 0 }, + }; + + const expectedConda2: PythonEnvInfo = { + arch: Architecture.Unknown, + detailedDisplayName: 'Conda Python', + display: 'Conda Python', + distro: { org: '' }, + executable: { + filename: exePath, + sysPrefix: '/home/user/.conda/envs/conda_python', + ctime: -1, + mtime: -1, + }, + kind: PythonEnvKind.Conda, + location: '/home/user/.conda/envs/conda_python', + source: [], + name: 'conda_python', + type: PythonEnvType.Conda, + version: { sysVersion: undefined, major: -1, minor: -1, micro: -1 }, + }; + + setup(() => { + mockFinder = typemoq.Mock.ofType(); + + mockFinder + .setup((f) => f.categoryToKind(typemoq.It.isAny())) + .returns((category: string) => { + switch (category.toLowerCase()) { + case 'conda': + return PythonEnvKind.Conda; + case 'system': + case 'homebrew': + case 'macpythonorg': + case 'maccommandlinetools': + case 'macxcode': + case 'windowsregistry': + case 'linuxglobal': + return PythonEnvKind.System; + case 'globalpaths': + return PythonEnvKind.OtherGlobal; + case 'pyenv': + return PythonEnvKind.Pyenv; + case 'poetry': + return PythonEnvKind.Poetry; + case 'pipenv': + return PythonEnvKind.Pipenv; + case 'pyenvvirtualenv': + return PythonEnvKind.VirtualEnv; + case 'venv': + return PythonEnvKind.Venv; + case 'virtualenv': + return PythonEnvKind.VirtualEnv; + case 'virtualenvwrapper': + return PythonEnvKind.VirtualEnvWrapper; + case 'windowsstore': + return PythonEnvKind.MicrosoftStore; + default: { + return PythonEnvKind.Unknown; + } + } + }); + + api = nativeAPI.createNativeEnvironmentsApi(mockFinder.object); + }); + + teardown(() => { + sinon.restore(); + }); + + test('Trigger refresh without resolve', async () => { + mockFinder + .setup((f) => f.refresh()) + .returns(() => { + async function* generator() { + yield* [basicEnv]; + } + return generator(); + }) + .verifiable(typemoq.Times.once()); + + mockFinder.setup((f) => f.resolve(typemoq.It.isAny())).verifiable(typemoq.Times.never()); + + await api.triggerRefresh(); + const actual = api.getEnvs(); + assert.deepEqual(actual, [expectedBasicEnv]); + }); + + test('Trigger refresh with resolve', async () => { + mockFinder + .setup((f) => f.refresh()) + .returns(() => { + async function* generator() { + yield* [basicEnv2]; + } + return generator(); + }) + .verifiable(typemoq.Times.once()); + + mockFinder + .setup((f) => f.resolve(typemoq.It.isAny())) + .returns(() => Promise.resolve(basicEnv)) + .verifiable(typemoq.Times.once()); + + api.triggerRefresh(); + await api.getRefreshPromise(); + + const actual = api.getEnvs(); + assert.deepEqual(actual, [expectedBasicEnv]); + }); + + test('Trigger refresh and use refresh promise API', async () => { + mockFinder + .setup((f) => f.refresh()) + .returns(() => { + async function* generator() { + yield* [basicEnv]; + } + return generator(); + }) + .verifiable(typemoq.Times.once()); + + mockFinder.setup((f) => f.resolve(typemoq.It.isAny())).verifiable(typemoq.Times.never()); + + api.triggerRefresh(); + await api.getRefreshPromise(); + + const actual = api.getEnvs(); + assert.deepEqual(actual, [expectedBasicEnv]); + }); + + test('Conda environment with resolve', async () => { + mockFinder + .setup((f) => f.refresh()) + .returns(() => { + async function* generator() { + yield* [conda1]; + } + return generator(); + }) + .verifiable(typemoq.Times.once()); + mockFinder + .setup((f) => f.resolve(typemoq.It.isAny())) + .returns(() => Promise.resolve(conda)) + .verifiable(typemoq.Times.once()); + + await api.triggerRefresh(); + const actual = api.getEnvs(); + assert.deepEqual(actual, [expectedConda1]); + }); + + test('Conda environment with no python', async () => { + mockFinder + .setup((f) => f.refresh()) + .returns(() => { + async function* generator() { + yield* [conda2]; + } + return generator(); + }) + .verifiable(typemoq.Times.once()); + mockFinder.setup((f) => f.resolve(typemoq.It.isAny())).verifiable(typemoq.Times.never()); + + await api.triggerRefresh(); + const actual = api.getEnvs(); + assert.deepEqual(actual, [expectedConda2]); + }); + + test('Refresh promise undefined after refresh', async () => { + mockFinder + .setup((f) => f.refresh()) + .returns(() => { + async function* generator() { + yield* [basicEnv]; + } + return generator(); + }) + .verifiable(typemoq.Times.once()); + + mockFinder.setup((f) => f.resolve(typemoq.It.isAny())).verifiable(typemoq.Times.never()); + + await api.triggerRefresh(); + assert.isUndefined(api.getRefreshPromise()); + }); +});