Skip to content

Commit b4e1ddb

Browse files
authored
Jupyter API to get Env associated with Notebooks (#24771)
See microsoft/vscode-jupyter#15987 Should also fix microsoft/vscode-jupyter#16112 Should also avoid Pylance having to monitor notebook changes and then trying to figure out the Environment for a Notebook. previous discussion here #24358
1 parent 6b784e5 commit b4e1ddb

File tree

7 files changed

+182
-18
lines changed

7 files changed

+182
-18
lines changed

pythonExtensionApi/src/main.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,9 +227,9 @@ export type EnvironmentsChangeEvent = {
227227

228228
export type ActiveEnvironmentPathChangeEvent = EnvironmentPath & {
229229
/**
230-
* Workspace folder the environment changed for.
230+
* Resource the environment changed for.
231231
*/
232-
readonly resource: WorkspaceFolder | undefined;
232+
readonly resource: Resource | undefined;
233233
};
234234

235235
/**

src/client/api.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ import { IConfigurationService, Resource } from './common/types';
1515
import { getDebugpyLauncherArgs } from './debugger/extension/adapter/remoteLaunchers';
1616
import { IInterpreterService } from './interpreter/contracts';
1717
import { IServiceContainer, IServiceManager } from './ioc/types';
18-
import { JupyterExtensionIntegration } from './jupyter/jupyterIntegration';
18+
import {
19+
JupyterExtensionIntegration,
20+
JupyterExtensionPythonEnvironments,
21+
JupyterPythonEnvironmentApi,
22+
} from './jupyter/jupyterIntegration';
1923
import { traceError } from './logging';
2024
import { IDiscoveryAPI } from './pythonEnvironments/base/locator';
2125
import { buildEnvironmentApi } from './environmentApi';
@@ -33,11 +37,16 @@ export function buildApi(
3337
const configurationService = serviceContainer.get<IConfigurationService>(IConfigurationService);
3438
const interpreterService = serviceContainer.get<IInterpreterService>(IInterpreterService);
3539
serviceManager.addSingleton<JupyterExtensionIntegration>(JupyterExtensionIntegration, JupyterExtensionIntegration);
40+
serviceManager.addSingleton<JupyterExtensionPythonEnvironments>(
41+
JupyterExtensionPythonEnvironments,
42+
JupyterExtensionPythonEnvironments,
43+
);
3644
serviceManager.addSingleton<TensorboardExtensionIntegration>(
3745
TensorboardExtensionIntegration,
3846
TensorboardExtensionIntegration,
3947
);
4048
const jupyterIntegration = serviceContainer.get<JupyterExtensionIntegration>(JupyterExtensionIntegration);
49+
const jupyterPythonEnvApi = serviceContainer.get<JupyterPythonEnvironmentApi>(JupyterExtensionPythonEnvironments);
4150
const tensorboardIntegration = serviceContainer.get<TensorboardExtensionIntegration>(
4251
TensorboardExtensionIntegration,
4352
);
@@ -146,7 +155,7 @@ export function buildApi(
146155
stop: (client: BaseLanguageClient): Promise<void> => client.stop(),
147156
getTelemetryReporter: () => getTelemetryReporter(),
148157
},
149-
environments: buildEnvironmentApi(discoveryApi, serviceContainer),
158+
environments: buildEnvironmentApi(discoveryApi, serviceContainer, jupyterPythonEnvApi),
150159
};
151160

152161
// In test environment return the DI Container.

src/client/api/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,9 +227,9 @@ export type EnvironmentsChangeEvent = {
227227

228228
export type ActiveEnvironmentPathChangeEvent = EnvironmentPath & {
229229
/**
230-
* Workspace folder the environment changed for.
230+
* Resource the environment changed for.
231231
*/
232-
readonly resource: WorkspaceFolder | undefined;
232+
readonly resource: Resource | undefined;
233233
};
234234

235235
/**

src/client/environmentApi.ts

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ import {
3333
} from './api/types';
3434
import { buildEnvironmentCreationApi } from './pythonEnvironments/creation/createEnvApi';
3535
import { EnvironmentKnownCache } from './environmentKnownCache';
36+
import type { JupyterPythonEnvironmentApi } from './jupyter/jupyterIntegration';
37+
import { noop } from './common/utils/misc';
3638

3739
type ActiveEnvironmentChangeEvent = {
3840
resource: WorkspaceFolder | undefined;
@@ -115,6 +117,7 @@ function filterUsingVSCodeContext(e: PythonEnvInfo) {
115117
export function buildEnvironmentApi(
116118
discoveryApi: IDiscoveryAPI,
117119
serviceContainer: IServiceContainer,
120+
jupyterPythonEnvsApi: JupyterPythonEnvironmentApi,
118121
): PythonExtension['environments'] {
119122
const interpreterPathService = serviceContainer.get<IInterpreterPathService>(IInterpreterPathService);
120123
const configService = serviceContainer.get<IConfigurationService>(IConfigurationService);
@@ -146,6 +149,28 @@ export function buildEnvironmentApi(
146149
})
147150
.ignoreErrors();
148151
}
152+
153+
function getActiveEnvironmentPath(resource?: Resource) {
154+
resource = resource && 'uri' in resource ? resource.uri : resource;
155+
const jupyterEnv =
156+
resource && jupyterPythonEnvsApi.getPythonEnvironment
157+
? jupyterPythonEnvsApi.getPythonEnvironment(resource)
158+
: undefined;
159+
if (jupyterEnv) {
160+
traceVerbose('Python Environment returned from Jupyter', resource?.fsPath, jupyterEnv.id);
161+
return {
162+
id: jupyterEnv.id,
163+
path: jupyterEnv.path,
164+
};
165+
}
166+
const path = configService.getSettings(resource).pythonPath;
167+
const id = path === 'python' ? 'DEFAULT_PYTHON' : getEnvID(path);
168+
return {
169+
id,
170+
path,
171+
};
172+
}
173+
149174
disposables.push(
150175
discoveryApi.onProgress((e) => {
151176
if (e.stage === ProgressReportStage.discoveryFinished) {
@@ -206,6 +231,16 @@ export function buildEnvironmentApi(
206231
}),
207232
onEnvironmentsChanged,
208233
onEnvironmentVariablesChanged,
234+
jupyterPythonEnvsApi.onDidChangePythonEnvironment
235+
? jupyterPythonEnvsApi.onDidChangePythonEnvironment((e) => {
236+
const jupyterEnv = getActiveEnvironmentPath(e);
237+
onDidActiveInterpreterChangedEvent.fire({
238+
id: jupyterEnv.id,
239+
path: jupyterEnv.path,
240+
resource: e,
241+
});
242+
}, undefined)
243+
: { dispose: noop },
209244
);
210245
if (!knownCache!) {
211246
knownCache = initKnownCache();
@@ -223,13 +258,7 @@ export function buildEnvironmentApi(
223258
},
224259
getActiveEnvironmentPath(resource?: Resource) {
225260
sendApiTelemetry('getActiveEnvironmentPath');
226-
resource = resource && 'uri' in resource ? resource.uri : resource;
227-
const path = configService.getSettings(resource).pythonPath;
228-
const id = path === 'python' ? 'DEFAULT_PYTHON' : getEnvID(path);
229-
return {
230-
id,
231-
path,
232-
};
261+
return getActiveEnvironmentPath(resource);
233262
},
234263
updateActiveEnvironmentPath(env: Environment | EnvironmentPath | string, resource?: Resource): Promise<void> {
235264
sendApiTelemetry('updateActiveEnvironmentPath');

src/client/jupyter/jupyterIntegration.ts

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
/* eslint-disable comma-dangle */
22

3-
/* eslint-disable implicit-arrow-linebreak */
3+
/* eslint-disable implicit-arrow-linebreak, max-classes-per-file */
44
// Copyright (c) Microsoft Corporation. All rights reserved.
55
// Licensed under the MIT License.
66

77
import { inject, injectable, named } from 'inversify';
88
import { dirname } from 'path';
9-
import { Extension, Memento, Uri } from 'vscode';
9+
import { EventEmitter, Extension, Memento, Uri, workspace, Event } from 'vscode';
1010
import type { SemVer } from 'semver';
1111
import { IContextKeyManager, IWorkspaceService } from '../common/application/types';
1212
import { JUPYTER_EXTENSION_ID, PYLANCE_EXTENSION_ID } from '../common/constants';
@@ -23,6 +23,7 @@ import { PylanceApi } from '../activation/node/pylanceApi';
2323
import { ExtensionContextKey } from '../common/application/contextKeys';
2424
import { getDebugpyPath } from '../debugger/pythonDebugger';
2525
import type { Environment } from '../api/types';
26+
import { DisposableBase } from '../common/utils/resourceLifecycle';
2627

2728
type PythonApiForJupyterExtension = {
2829
/**
@@ -170,3 +171,108 @@ export class JupyterExtensionIntegration {
170171
}
171172
}
172173
}
174+
175+
export interface JupyterPythonEnvironmentApi {
176+
/**
177+
* This event is triggered when the environment associated with a Jupyter Notebook or Interactive Window changes.
178+
* The Uri in the event is the Uri of the Notebook/IW.
179+
*/
180+
onDidChangePythonEnvironment?: Event<Uri>;
181+
/**
182+
* Returns the EnvironmentPath to the Python environment associated with a Jupyter Notebook or Interactive Window.
183+
* If the Uri is not associated with a Jupyter Notebook or Interactive Window, then this method returns undefined.
184+
* @param uri
185+
*/
186+
getPythonEnvironment?(
187+
uri: Uri,
188+
):
189+
| undefined
190+
| {
191+
/**
192+
* The ID of the environment.
193+
*/
194+
readonly id: string;
195+
/**
196+
* Path to environment folder or path to python executable that uniquely identifies an environment. Environments
197+
* lacking a python executable are identified by environment folder paths, whereas other envs can be identified
198+
* using python executable path.
199+
*/
200+
readonly path: string;
201+
};
202+
}
203+
204+
@injectable()
205+
export class JupyterExtensionPythonEnvironments extends DisposableBase implements JupyterPythonEnvironmentApi {
206+
private jupyterExtension?: JupyterPythonEnvironmentApi;
207+
208+
private readonly _onDidChangePythonEnvironment = this._register(new EventEmitter<Uri>());
209+
210+
public readonly onDidChangePythonEnvironment = this._onDidChangePythonEnvironment.event;
211+
212+
constructor(@inject(IExtensions) private readonly extensions: IExtensions) {
213+
super();
214+
}
215+
216+
public getPythonEnvironment(
217+
uri: Uri,
218+
):
219+
| undefined
220+
| {
221+
/**
222+
* The ID of the environment.
223+
*/
224+
readonly id: string;
225+
/**
226+
* Path to environment folder or path to python executable that uniquely identifies an environment. Environments
227+
* lacking a python executable are identified by environment folder paths, whereas other envs can be identified
228+
* using python executable path.
229+
*/
230+
readonly path: string;
231+
} {
232+
if (!isJupyterResource(uri)) {
233+
return undefined;
234+
}
235+
const api = this.getJupyterApi();
236+
if (api?.getPythonEnvironment) {
237+
return api.getPythonEnvironment(uri);
238+
}
239+
return undefined;
240+
}
241+
242+
private getJupyterApi() {
243+
if (!this.jupyterExtension) {
244+
const ext = this.extensions.getExtension<JupyterPythonEnvironmentApi>(JUPYTER_EXTENSION_ID);
245+
if (!ext) {
246+
return undefined;
247+
}
248+
if (!ext.isActive) {
249+
ext.activate().then(() => {
250+
this.hookupOnDidChangePythonEnvironment(ext.exports);
251+
});
252+
return undefined;
253+
}
254+
this.hookupOnDidChangePythonEnvironment(ext.exports);
255+
}
256+
return this.jupyterExtension;
257+
}
258+
259+
private hookupOnDidChangePythonEnvironment(api: JupyterPythonEnvironmentApi) {
260+
this.jupyterExtension = api;
261+
if (api.onDidChangePythonEnvironment) {
262+
this._register(
263+
api.onDidChangePythonEnvironment(
264+
this._onDidChangePythonEnvironment.fire,
265+
this._onDidChangePythonEnvironment,
266+
),
267+
);
268+
}
269+
}
270+
}
271+
272+
function isJupyterResource(resource: Uri): boolean {
273+
// Jupyter extension only deals with Notebooks and Interactive Windows.
274+
return (
275+
resource.fsPath.endsWith('.ipynb') ||
276+
workspace.notebookDocuments.some((item) => item.uri.toString() === resource.toString())
277+
);
278+
}

src/test/api.functional.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import { ServiceManager } from '../client/ioc/serviceManager';
1919
import { IServiceContainer, IServiceManager } from '../client/ioc/types';
2020
import { IDiscoveryAPI } from '../client/pythonEnvironments/base/locator';
2121
import * as pythonDebugger from '../client/debugger/pythonDebugger';
22+
import { JupyterExtensionPythonEnvironments, JupyterPythonEnvironmentApi } from '../client/jupyter/jupyterIntegration';
23+
import { EventEmitter, Uri } from 'vscode';
2224

2325
suite('Extension API', () => {
2426
const debuggerPath = path.join(EXTENSION_ROOT_DIR, 'python_files', 'lib', 'python', 'debugpy');
@@ -49,6 +51,14 @@ suite('Extension API', () => {
4951
instance(environmentVariablesProvider),
5052
);
5153
when(serviceContainer.get<IInterpreterService>(IInterpreterService)).thenReturn(instance(interpreterService));
54+
const onDidChangePythonEnvironment = new EventEmitter<Uri>();
55+
const jupyterApi: JupyterPythonEnvironmentApi = {
56+
onDidChangePythonEnvironment: onDidChangePythonEnvironment.event,
57+
getPythonEnvironment: (_uri: Uri) => undefined,
58+
};
59+
when(serviceContainer.get<JupyterPythonEnvironmentApi>(JupyterExtensionPythonEnvironments)).thenReturn(
60+
jupyterApi,
61+
);
5262
when(serviceContainer.get<IDisposableRegistry>(IDisposableRegistry)).thenReturn([]);
5363
getDebugpyPathStub = sinon.stub(pythonDebugger, 'getDebugpyPath');
5464
getDebugpyPathStub.resolves(debuggerPath);

src/test/environmentApi.unit.test.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
EnvironmentsChangeEvent,
3939
PythonExtension,
4040
} from '../client/api/types';
41+
import { JupyterPythonEnvironmentApi } from '../client/jupyter/jupyterIntegration';
4142

4243
suite('Python Environment API', () => {
4344
const workspacePath = 'path/to/workspace';
@@ -80,7 +81,6 @@ suite('Python Environment API', () => {
8081
onDidChangeRefreshState = new EventEmitter();
8182
onDidChangeEnvironments = new EventEmitter();
8283
onDidChangeEnvironmentVariables = new EventEmitter();
83-
8484
serviceContainer.setup((s) => s.get(IExtensions)).returns(() => extensions.object);
8585
serviceContainer.setup((s) => s.get(IInterpreterPathService)).returns(() => interpreterPathService.object);
8686
serviceContainer.setup((s) => s.get(IConfigurationService)).returns(() => configService.object);
@@ -94,8 +94,13 @@ suite('Python Environment API', () => {
9494
discoverAPI.setup((d) => d.onProgress).returns(() => onDidChangeRefreshState.event);
9595
discoverAPI.setup((d) => d.onChanged).returns(() => onDidChangeEnvironments.event);
9696
discoverAPI.setup((d) => d.getEnvs()).returns(() => []);
97+
const onDidChangePythonEnvironment = new EventEmitter<Uri>();
98+
const jupyterApi: JupyterPythonEnvironmentApi = {
99+
onDidChangePythonEnvironment: onDidChangePythonEnvironment.event,
100+
getPythonEnvironment: (_uri: Uri) => undefined,
101+
};
97102

98-
environmentApi = buildEnvironmentApi(discoverAPI.object, serviceContainer.object);
103+
environmentApi = buildEnvironmentApi(discoverAPI.object, serviceContainer.object, jupyterApi);
99104
});
100105

101106
teardown(() => {
@@ -323,7 +328,12 @@ suite('Python Environment API', () => {
323328
},
324329
];
325330
discoverAPI.setup((d) => d.getEnvs()).returns(() => envs);
326-
environmentApi = buildEnvironmentApi(discoverAPI.object, serviceContainer.object);
331+
const onDidChangePythonEnvironment = new EventEmitter<Uri>();
332+
const jupyterApi: JupyterPythonEnvironmentApi = {
333+
onDidChangePythonEnvironment: onDidChangePythonEnvironment.event,
334+
getPythonEnvironment: (_uri: Uri) => undefined,
335+
};
336+
environmentApi = buildEnvironmentApi(discoverAPI.object, serviceContainer.object, jupyterApi);
327337
const actual = environmentApi.known;
328338
const actualEnvs = actual?.map((a) => (a as EnvironmentReference).internal);
329339
assert.deepEqual(

0 commit comments

Comments
 (0)