From cfeb53eee960ea2fdceb660d9bdab88be4419ec5 Mon Sep 17 00:00:00 2001 From: sama Pyb Date: Fri, 31 Jul 2026 02:47:38 +0800 Subject: [PATCH] Fall back when Python environments API is incomplete --- typescript/src/python.ts | 47 +++++++++++++++++----- typescript/tests/python.test.ts | 71 +++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 11 deletions(-) diff --git a/typescript/src/python.ts b/typescript/src/python.ts index 8d9ee97..6313b76 100644 --- a/typescript/src/python.ts +++ b/typescript/src/python.ts @@ -73,8 +73,27 @@ export interface IPythonApi { // API adapters // --------------------------------------------------------------------------- +type UsableEnvironmentsApi = Pick< + PythonEnvironmentApi, + 'getEnvironment' | 'resolveEnvironment' | 'onDidChangeEnvironment' +> & { + onDidChangePackages?: unknown; +}; + +function isUsableEnvironmentsApi(api: unknown): api is UsableEnvironmentsApi { + if (typeof api !== 'object' || api === null) { + return false; + } + const candidate = api as Partial; + return ( + typeof candidate.getEnvironment === 'function' && + typeof candidate.resolveEnvironment === 'function' && + typeof candidate.onDidChangeEnvironment === 'function' + ); +} + /** Wrap the newer `@vscode/python-environments` API. */ -function wrapEnvironmentsApi(api: PythonEnvironmentApi): IPythonApi { +function wrapEnvironmentsApi(api: UsableEnvironmentsApi): IPythonApi { return { extension: 'ms-python.python-environments', @@ -124,6 +143,9 @@ function wrapEnvironmentsApi(api: PythonEnvironmentApi): IPythonApi { }, onDidChangePackages(handler: () => void) { + if (typeof api.onDidChangePackages !== 'function') { + return { dispose: () => undefined }; + } return api.onDidChangePackages(handler); }, @@ -233,9 +255,12 @@ export class PythonEnvironmentsProvider { } try { const envsApi = await PythonEnvironments.api(); - this._api = wrapEnvironmentsApi(envsApi); - this._apiResolved = true; - return this._api; + if (isUsableEnvironmentsApi(envsApi)) { + this._api = wrapEnvironmentsApi(envsApi); + this._apiResolved = true; + return this._api; + } + traceLog('Python environments API is incomplete — trying legacy.'); } catch { traceLog('Python environments extension not available — trying legacy.'); } @@ -318,14 +343,14 @@ export class PythonEnvironmentsProvider { * wired regardless of how the interpreter was selected (resolved by the * Python extension *or* pinned via the `.interpreter` setting). * - * Subscription failures are non-fatal: if no API is available, the runtime - * does not expose `onDidChangePackages` (e.g. the legacy `ms-python.python` - * extension or a version-skewed runtime), or subscribing throws, this - * resolves to `undefined` and logs rather than propagating — a refresh - * feature must never block or break activation. + * Subscription failures are non-fatal: adapters without package events + * (e.g. the legacy `ms-python.python` extension or a version-skewed runtime) + * return a no-op disposable. If no API is available, or subscribing throws, + * this resolves to `undefined` rather than propagating — a refresh feature + * must never block or break activation. * - * @returns A {@link Disposable} for the subscription, or `undefined` when no - * package-change event is available. + * @returns A real or no-op {@link Disposable}, or `undefined` when no API is + * available or subscription fails. */ async subscribeToPackageChanges(handler: () => void): Promise { try { diff --git a/typescript/tests/python.test.ts b/typescript/tests/python.test.ts index ea28c25..520bda0 100644 --- a/typescript/tests/python.test.ts +++ b/typescript/tests/python.test.ts @@ -3,6 +3,8 @@ import { assert } from 'chai'; import * as sinon from 'sinon'; +import { PythonEnvironments } from '@vscode/python-environments'; +import { PythonExtension } from '@vscode/python-extension'; import { PythonEnvironmentsProvider, IInterpreterDetails } from '../src/python'; import { IResolvedPythonEnvironment, ToolConfig } from '../src/types'; import * as utilities from '../src/utilities'; @@ -137,6 +139,75 @@ suite('PythonEnvironmentsProvider', () => { await provider.initializePython(disposables); assert.isArray(disposables); }); + + for (const [description, incompleteApi] of [ + ['undefined', undefined], + ['an empty object', {}], + ] as const) { + test(`falls back to the legacy API when the environments API returns ${description}`, async () => { + const environmentsApi = sinon.stub(PythonEnvironments, 'api').resolves(incompleteApi as never); + + const onDidChangeActiveEnvironmentPath = sinon.stub().returns({ dispose: sinon.stub() }); + const resolveEnvironment = sinon.stub().resolves({ + executable: { uri: { fsPath: '/usr/bin/python3' } }, + version: { major: 3, minor: 12, micro: 1 }, + }); + const legacyApi = sinon.stub(PythonExtension, 'api').resolves({ + environments: { + getActiveEnvironmentPath: sinon.stub().returns('/usr/bin/python3'), + onDidChangeActiveEnvironmentPath, + resolveEnvironment, + }, + debug: { + getDebuggerPackagePath: sinon.stub().resolves(undefined), + }, + } as never); + + const provider = new PythonEnvironmentsProvider(makeToolConfig()); + const disposables: { dispose: () => void }[] = []; + + await provider.initializePython(disposables); + + assert.isTrue(environmentsApi.calledOnce, 'should try the environments API first'); + assert.isTrue(legacyApi.calledOnce, 'should try the legacy API'); + assert.isTrue( + onDidChangeActiveEnvironmentPath.calledOnce, + 'should subscribe through the legacy API', + ); + assert.lengthOf(disposables, 1); + + const interpreter = await provider.getInterpreterDetails(); + assert.deepEqual(interpreter.path, ['/usr/bin/python3']); + assert.isTrue(resolveEnvironment.calledTwice, 'should cache and reuse the legacy API'); + assert.isTrue(environmentsApi.calledOnce, 'should not retry the environments API'); + assert.isTrue(legacyApi.calledOnce, 'should reuse the cached legacy API'); + }); + } + + test('uses the environments API when only the optional package event is unavailable', async () => { + const onDidChangeEnvironment = sinon.stub().returns({ dispose: sinon.stub() }); + const environmentsApi = sinon.stub(PythonEnvironments, 'api').resolves({ + getEnvironment: sinon.stub().resolves(undefined), + resolveEnvironment: sinon.stub().resolves(undefined), + onDidChangeEnvironment, + } as never); + const legacyApi = sinon.stub(PythonExtension, 'api'); + + const provider = new PythonEnvironmentsProvider(makeToolConfig()); + const disposables: { dispose: () => void }[] = []; + + await provider.initializePython(disposables); + + assert.isTrue(environmentsApi.calledOnce); + assert.isFalse(legacyApi.called); + assert.isTrue(onDidChangeEnvironment.calledOnce); + assert.lengthOf(disposables, 1); + + const packageDisposable = await provider.subscribeToPackageChanges(sinon.stub()); + assert.isDefined(packageDisposable); + assert.isTrue(environmentsApi.calledOnce, 'should reuse the cached environments API'); + packageDisposable?.dispose(); + }); }); suite('subscribeToPackageChanges', () => {