Skip to content
Draft
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
47 changes: 36 additions & 11 deletions typescript/src/python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UsableEnvironmentsApi>;
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',

Expand Down Expand Up @@ -124,6 +143,9 @@ function wrapEnvironmentsApi(api: PythonEnvironmentApi): IPythonApi {
},

onDidChangePackages(handler: () => void) {
if (typeof api.onDidChangePackages !== 'function') {
return { dispose: () => undefined };
}
return api.onDidChangePackages(handler);
},

Expand Down Expand Up @@ -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.');
}
Expand Down Expand Up @@ -318,14 +343,14 @@ export class PythonEnvironmentsProvider {
* wired regardless of how the interpreter was selected (resolved by the
* Python extension *or* pinned via the `<serverId>.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<Disposable | undefined> {
try {
Expand Down
71 changes: 71 additions & 0 deletions typescript/tests/python.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading