Skip to content

Commit b8bf64e

Browse files
Fix environment variable handling in jupyter paths unit tests (#16883)
* Checkpoint from VS Code for coding agent session * Initial plan * Update unit tests for enhanced kernel spec root paths Co-authored-by: DonJayamanne <[email protected]> * Add comprehensive unit tests for enhanced kernel spec root paths discovery Co-authored-by: DonJayamanne <[email protected]> * UpdatesÏ * restore * Update formatting * Update cache key * Updates --------- Co-authored-by: Don Jayamanne <[email protected]> Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: DonJayamanne <[email protected]>
1 parent 9997d59 commit b8bf64e

File tree

3 files changed

+260
-35
lines changed

3 files changed

+260
-35
lines changed

src/kernels/raw/finder/jupyterPaths.node.ts

Lines changed: 33 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import * as uriPath from '../../../platform/vscode-path/resources';
99
import { CancellationToken, Memento, Uri } from 'vscode';
1010
import { IFileSystem, IPlatformService } from '../../../platform/common/platform/types';
1111
import { IFileSystemNode } from '../../../platform/common/platform/types.node';
12-
import { ignoreLogging, logValue, logger } from '../../../platform/logging';
12+
import { logValue, logger } from '../../../platform/logging';
1313
import {
1414
IDisposableRegistry,
1515
IMemento,
@@ -39,15 +39,14 @@ const macJupyterRuntimePath = path.join('Library', 'Jupyter', 'runtime');
3939

4040
export const baseKernelPath = path.join('share', 'jupyter', 'kernels');
4141
const CACHE_KEY_FOR_JUPYTER_KERNELSPEC_ROOT_PATH = 'CACHE_KEY_FOR_JUPYTER_KERNELSPEC_ROOT_PATH.';
42-
export const CACHE_KEY_FOR_JUPYTER_KERNEL_PATHS = 'CACHE_KEY_FOR_JUPYTER_KERNEL_PATHS_.';
42+
export const CACHE_KEY_FOR_JUPYTER_KERNEL_PATHS = 'CACHE_KEY_FOR_JUPYTER_KERNEL_PATHS_V2';
4343

4444
/**
4545
* Finds locations to search for jupyter kernels.
4646
*/
4747
@injectable()
4848
export class JupyterPaths {
4949
private cachedKernelSpecRootPath?: Promise<Uri | undefined>;
50-
private cachedJupyterKernelPaths?: Promise<Uri[]>;
5150
private cachedJupyterPaths?: Promise<Uri[]>;
5251
private cachedDataDirs = new Map<string, Promise<Uri[]>>();
5352
constructor(
@@ -62,7 +61,6 @@ export class JupyterPaths {
6261
) {
6362
this.envVarsProvider.onDidEnvironmentVariablesChange(
6463
() => {
65-
this.cachedJupyterKernelPaths = undefined;
6664
this.cachedJupyterPaths = undefined;
6765
},
6866
this,
@@ -330,24 +328,43 @@ export class JupyterPaths {
330328
this.cachedKernelSpecRootPaths = undefined;
331329
}
332330
}, this);
331+
promise
332+
.then((paths) => {
333+
if (this.cachedKernelSpecRootPaths?.promise === promise && paths.length) {
334+
this.updateCachedKernelSpecPaths(paths).catch(noop);
335+
this.cachedKernelSpecRootPaths.promise = Promise.resolve(paths);
336+
}
337+
})
338+
.catch(noop);
333339
promise.finally(() => disposable.dispose()).catch(noop);
334-
return promise;
340+
341+
// If we have something in cache use that.
342+
const cached = this.getCachedKernelSpecPaths();
343+
return cached.length ? cached : promise;
335344
}
336345
private async getKernelSpecRootPathsImpl(cancelToken: CancellationToken): Promise<Uri[]> {
346+
const paths = new ResourceSet();
337347
// Paths specified in JUPYTER_PATH are supposed to come first in searching
338-
const paths = new ResourceSet(await this.getJupyterPathKernelPaths(cancelToken));
348+
// Use the complete data directory paths (equivalent to Python's jupyter_path("kernels"))
349+
// This includes JUPYTER_PATH, user directories, environment directories, and system paths
350+
const [dataDirs, kernelSpecRootpath] = await Promise.all([
351+
this.getDataDirs({ resource: undefined }),
352+
this.getKernelSpecRootPath()
353+
]);
339354
if (cancelToken.isCancellationRequested) {
340355
return [];
341356
}
342-
if (this.platformService.isWindows) {
343-
const winPath = await this.getKernelSpecRootPath();
344-
if (cancelToken.isCancellationRequested) {
345-
return [];
346-
}
347-
if (winPath) {
348-
paths.add(winPath);
349-
}
350357

358+
// Convert data directories to kernel spec directories by appending 'kernels' subdirectory
359+
for (const dataDir of dataDirs) {
360+
const kernelSpecDir = uriPath.joinPath(dataDir, 'kernels');
361+
paths.add(kernelSpecDir);
362+
}
363+
if (kernelSpecRootpath) {
364+
paths.add(kernelSpecRootpath);
365+
}
366+
// Add platform-specific additional paths that might not be covered by getDataDirs
367+
if (this.platformService.isWindows) {
351368
if (process.env.PROGRAMDATA) {
352369
paths.add(Uri.file(path.join(process.env.PROGRAMDATA, 'jupyter', 'kernels')));
353370
}
@@ -370,17 +387,6 @@ export class JupyterPaths {
370387
return Array.from(paths);
371388
}
372389

373-
private async getJupyterPathKernelPaths(@ignoreLogging() cancelToken?: CancellationToken): Promise<Uri[]> {
374-
this.cachedJupyterKernelPaths =
375-
this.cachedJupyterKernelPaths || this.getJupyterPathSubPaths(cancelToken, 'kernels');
376-
this.cachedJupyterKernelPaths.then((value) => {
377-
if (value.length > 0) {
378-
this.updateCachedPaths(value).then(noop, noop);
379-
}
380-
}, noop);
381-
return this.getCachedPaths().length > 0 ? this.getCachedPaths() : this.cachedJupyterKernelPaths;
382-
}
383-
384390
private async getJupyterPaths(cancelToken?: CancellationToken): Promise<Uri[]> {
385391
this.cachedJupyterPaths = this.cachedJupyterPaths || this.getJupyterPathSubPaths(cancelToken);
386392
return this.cachedJupyterPaths;
@@ -419,11 +425,11 @@ export class JupyterPaths {
419425
return Array.from(paths);
420426
}
421427

422-
private getCachedPaths(): Uri[] {
428+
private getCachedKernelSpecPaths(): Uri[] {
423429
return this.globalState.get<string[]>(CACHE_KEY_FOR_JUPYTER_KERNEL_PATHS, []).map((s) => Uri.parse(s));
424430
}
425431

426-
private async updateCachedPaths(paths: Uri[]) {
432+
private async updateCachedKernelSpecPaths(paths: Uri[]) {
427433
const currentValue = this.globalState.get<string[]>(CACHE_KEY_FOR_JUPYTER_KERNEL_PATHS, []);
428434
const newValue = paths.map(Uri.toString);
429435
if (currentValue.join(',') !== newValue.join(',')) {

src/kernels/raw/finder/jupyterPaths.node.unit.test.ts

Lines changed: 225 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import * as sinon from 'sinon';
88
import { assert } from 'chai';
9-
import { anything, deepEqual, instance, mock, when } from 'ts-mockito';
9+
import { anything, deepEqual, instance, mock, when, verify } from 'ts-mockito';
1010
import { CancellationTokenSource, ExtensionContext, Memento, Uri } from 'vscode';
1111
import { CACHE_KEY_FOR_JUPYTER_KERNEL_PATHS, JupyterPaths } from './jupyterPaths.node';
1212
import { dispose } from '../../../platform/common/utils/lifecycle';
@@ -48,6 +48,7 @@ suite('Jupyter Paths', () => {
4848
};
4949
const unixHomeDir = Uri.file('/users/username');
5050
const macHomeDir = Uri.file('/users/username');
51+
const linuxJupyterPath = path.join('.local', 'share', 'jupyter', 'kernels');
5152
let cancelToken: CancellationTokenSource;
5253
suiteSetup(function () {
5354
if (isWeb()) {
@@ -322,8 +323,14 @@ suite('Jupyter Paths', () => {
322323
const paths = await jupyterPaths.getKernelSpecRootPaths(cancelToken.token);
323324
const winJupyterPath = path.join('AppData', 'Roaming', 'jupyter', 'kernels');
324325

325-
assert.strictEqual(paths.length, 1, `Expected 1 path, got ${paths.length}, ${JSON.stringify(paths)}`);
326-
assert.strictEqual(paths[0].toString(), Uri.joinPath(windowsHomeDir, winJupyterPath).toString());
326+
// New implementation returns data dirs + kernel spec root path
327+
assert.strictEqual(paths.length, 2, `Expected 2 paths, got ${paths.length}, ${JSON.stringify(paths)}`);
328+
329+
// First path should be from data directory (.jupyter/data/kernels)
330+
assert.strictEqual(paths[0].toString(), Uri.joinPath(windowsHomeDir, '.jupyter', 'data', 'kernels').toString());
331+
332+
// Second path should be the kernel spec root path
333+
assert.strictEqual(paths[1].toString(), Uri.joinPath(windowsHomeDir, winJupyterPath).toString());
327334
});
328335

329336
test('Get kernelspec root paths on Windows with JUPYTER_PATH env variable', async () => {
@@ -336,9 +343,17 @@ suite('Jupyter Paths', () => {
336343
const paths = await jupyterPaths.getKernelSpecRootPaths(cancelToken.token);
337344
const winJupyterPath = path.join('AppData', 'Roaming', 'jupyter', 'kernels');
338345

339-
assert.strictEqual(paths.length, 2, `Expected 2 path, got ${paths.length}, ${JSON.stringify(paths)}`);
346+
// New implementation returns JUPYTER_PATH kernels + data dirs + kernel spec root path
347+
assert.strictEqual(paths.length, 3, `Expected 3 paths, got ${paths.length}, ${JSON.stringify(paths)}`);
348+
349+
// First path should be from JUPYTER_PATH
340350
assert.strictEqual(paths[0].toString(), Uri.joinPath(Uri.file(__filename), 'kernels').toString());
341-
assert.strictEqual(paths[1].toString(), Uri.joinPath(windowsHomeDir, winJupyterPath).toString());
351+
352+
// Second path should be from data directory (.jupyter/data/kernels)
353+
assert.strictEqual(paths[1].toString(), Uri.joinPath(windowsHomeDir, '.jupyter', 'data', 'kernels').toString());
354+
355+
// Third path should be the kernel spec root path
356+
assert.strictEqual(paths[2].toString(), Uri.joinPath(windowsHomeDir, winJupyterPath).toString());
342357
});
343358
test('Get kernelspec root paths on Windows with JUPYTER_PATH & ALLUSERSPROFILE env variable', async function () {
344359
when(platformService.osType).thenReturn(OSType.Windows);
@@ -351,12 +366,215 @@ suite('Jupyter Paths', () => {
351366
const paths = await jupyterPaths.getKernelSpecRootPaths(cancelToken.token);
352367
const winJupyterPath = path.join('AppData', 'Roaming', 'jupyter', 'kernels');
353368

354-
assert.strictEqual(paths.length, 3, `Expected 3 path, got ${paths.length}, ${JSON.stringify(paths)}`);
369+
// New implementation returns JUPYTER_PATH kernels + data dirs + PROGRAMDATA + kernel spec root path
370+
assert.strictEqual(paths.length, 4, `Expected 4 paths, got ${paths.length}, ${JSON.stringify(paths)}`);
371+
372+
// First path should be from JUPYTER_PATH
355373
assert.strictEqual(paths[0].toString(), Uri.joinPath(Uri.file(__filename), 'kernels').toString());
356-
assert.strictEqual(paths[1].toString(), Uri.joinPath(windowsHomeDir, winJupyterPath).toString());
374+
375+
// Second path should be from data directory (.jupyter/data/kernels)
376+
assert.strictEqual(paths[1].toString(), Uri.joinPath(windowsHomeDir, '.jupyter', 'data', 'kernels').toString());
377+
378+
// Third path should be from PROGRAMDATA
357379
assert.strictEqual(
358380
paths[2].toString(),
359381
Uri.file(path.join(allUserProfilePath, 'jupyter', 'kernels')).toString()
360382
);
383+
384+
// Fourth path should be the kernel spec root path
385+
assert.strictEqual(paths[3].toString(), Uri.joinPath(windowsHomeDir, winJupyterPath).toString());
386+
});
387+
388+
test('Get kernelspec root paths on Linux', async () => {
389+
when(platformService.osType).thenReturn(OSType.Linux);
390+
when(platformService.isWindows).thenReturn(false);
391+
when(platformService.isMac).thenReturn(false);
392+
when(platformService.homeDir).thenReturn(unixHomeDir);
393+
when(memento.get(CACHE_KEY_FOR_JUPYTER_KERNEL_PATHS, anything())).thenReturn([]);
394+
395+
// Clear environment variables that might affect the test
396+
delete process.env['XDG_DATA_HOME'];
397+
delete process.env['JUPYTER_DATA_DIR'];
398+
399+
const paths = await jupyterPaths.getKernelSpecRootPaths(cancelToken.token);
400+
401+
// Should include data dirs + system paths, and possibly kernel spec root path
402+
assert.isAtLeast(paths.length, 3, `Expected at least 3 paths, got ${paths.length}, ${JSON.stringify(paths)}`);
403+
404+
// First path should be from data directory (without XDG_DATA_HOME, defaults to ~/.local/share/jupyter)
405+
assert.strictEqual(
406+
paths[0].toString(),
407+
Uri.joinPath(unixHomeDir, '.local', 'share', 'jupyter', 'kernels').toString()
408+
);
409+
410+
// Should include system paths
411+
const pathStrings = paths.map((p) => p.toString());
412+
assert.include(pathStrings, Uri.file('/usr/share/jupyter/kernels').toString());
413+
assert.include(pathStrings, Uri.file('/usr/local/share/jupyter/kernels').toString());
414+
415+
// May include kernel spec root path if available
416+
const hasKernelSpecRootPath = pathStrings.some((p) => p.includes(linuxJupyterPath));
417+
if (hasKernelSpecRootPath) {
418+
assert.include(pathStrings, Uri.joinPath(unixHomeDir, linuxJupyterPath).toString());
419+
}
420+
});
421+
422+
test('Get kernelspec root paths on macOS', async () => {
423+
when(platformService.osType).thenReturn(OSType.OSX);
424+
when(platformService.isWindows).thenReturn(false);
425+
when(platformService.isMac).thenReturn(true);
426+
when(platformService.homeDir).thenReturn(macHomeDir);
427+
when(memento.get(CACHE_KEY_FOR_JUPYTER_KERNEL_PATHS, anything())).thenReturn([]);
428+
429+
// Clear environment variables that might affect the test
430+
delete process.env['XDG_DATA_HOME'];
431+
delete process.env['JUPYTER_DATA_DIR'];
432+
433+
const paths = await jupyterPaths.getKernelSpecRootPaths(cancelToken.token);
434+
435+
// Should include at least 3 paths: data dir + system paths
436+
assert.isAtLeast(paths.length, 3, `Expected at least 3 paths, got ${paths.length}, ${JSON.stringify(paths)}`);
437+
438+
// First path should be from data directory (macOS uses ~/Library/Jupyter)
439+
assert.strictEqual(paths[0].toString(), Uri.joinPath(macHomeDir, 'Library', 'Jupyter', 'kernels').toString());
440+
441+
// Should include system paths
442+
const pathStrings = paths.map((p) => p.toString());
443+
assert.include(pathStrings, Uri.file('/usr/share/jupyter/kernels').toString());
444+
assert.include(pathStrings, Uri.file('/usr/local/share/jupyter/kernels').toString());
445+
});
446+
447+
test('Get kernelspec root paths on Linux with Python interpreter', async () => {
448+
when(platformService.osType).thenReturn(OSType.Linux);
449+
when(platformService.isWindows).thenReturn(false);
450+
when(platformService.isMac).thenReturn(false);
451+
when(platformService.homeDir).thenReturn(unixHomeDir);
452+
when(memento.get(CACHE_KEY_FOR_JUPYTER_KERNEL_PATHS, anything())).thenReturn([]);
453+
454+
// Mock getDataDirs to return additional interpreter-specific paths
455+
const mockDataDirs = [
456+
Uri.joinPath(unixHomeDir, '.local', 'share', 'jupyter'),
457+
Uri.joinPath(Uri.file(sysPrefix), 'share', 'jupyter')
458+
];
459+
sinon.stub(jupyterPaths, 'getDataDirs').resolves(mockDataDirs);
460+
461+
const paths = await jupyterPaths.getKernelSpecRootPaths(cancelToken.token);
462+
463+
// Should include interpreter-specific data dirs converted to kernel paths
464+
const pathStrings = paths.map((p) => p.toString());
465+
assert.include(pathStrings, Uri.joinPath(unixHomeDir, '.local', 'share', 'jupyter', 'kernels').toString());
466+
assert.include(pathStrings, Uri.joinPath(Uri.file(sysPrefix), 'share', 'jupyter', 'kernels').toString());
467+
468+
sinon.restore();
469+
});
470+
471+
test('Get kernelspec root paths handles cancellation token', async () => {
472+
when(platformService.osType).thenReturn(OSType.Windows);
473+
when(platformService.homeDir).thenReturn(windowsHomeDir);
474+
when(memento.get(CACHE_KEY_FOR_JUPYTER_KERNEL_PATHS, anything())).thenReturn([]);
475+
476+
// Cancel the token immediately
477+
cancelToken.cancel();
478+
479+
const paths = await jupyterPaths.getKernelSpecRootPaths(cancelToken.token);
480+
481+
// Should return empty array when cancelled
482+
assert.strictEqual(paths.length, 0, `Expected empty array when cancelled, got ${paths.length} paths`);
483+
});
484+
485+
test('Get kernelspec root paths handles missing home directory gracefully', async () => {
486+
when(platformService.osType).thenReturn(OSType.Linux);
487+
when(platformService.isWindows).thenReturn(false);
488+
when(platformService.isMac).thenReturn(false);
489+
when(platformService.homeDir).thenReturn(undefined); // No home directory
490+
when(memento.get(CACHE_KEY_FOR_JUPYTER_KERNEL_PATHS, anything())).thenReturn([]);
491+
492+
const paths = await jupyterPaths.getKernelSpecRootPaths(cancelToken.token);
493+
494+
// Should still return system paths even without home directory
495+
const pathStrings = paths.map((p) => p.toString());
496+
assert.include(pathStrings, Uri.file('/usr/share/jupyter/kernels').toString());
497+
assert.include(pathStrings, Uri.file('/usr/local/share/jupyter/kernels').toString());
498+
});
499+
500+
test('Get kernelspec root paths deduplicates paths', async () => {
501+
when(platformService.osType).thenReturn(OSType.Windows);
502+
when(platformService.homeDir).thenReturn(windowsHomeDir);
503+
when(memento.get(CACHE_KEY_FOR_JUPYTER_KERNEL_PATHS, anything())).thenReturn([]);
504+
505+
// Create a scenario where paths might be duplicated
506+
const duplicatePath = Uri.joinPath(windowsHomeDir, '.jupyter', 'data');
507+
const mockDataDirs = [
508+
duplicatePath,
509+
duplicatePath, // Duplicate
510+
Uri.joinPath(windowsHomeDir, 'AppData', 'Roaming', 'jupyter')
511+
];
512+
sinon.stub(jupyterPaths, 'getDataDirs').resolves(mockDataDirs);
513+
514+
const paths = await jupyterPaths.getKernelSpecRootPaths(cancelToken.token);
515+
516+
// Should not contain duplicate paths
517+
const pathStrings = paths.map((p) => p.toString());
518+
const uniquePaths = [...new Set(pathStrings)];
519+
assert.strictEqual(pathStrings.length, uniquePaths.length, 'Paths should be deduplicated');
520+
521+
sinon.restore();
522+
});
523+
524+
test('Get kernelspec root paths with cached data', async () => {
525+
const cachedPaths = [
526+
Uri.joinPath(windowsHomeDir, 'cached1', 'kernels').toString(),
527+
Uri.joinPath(windowsHomeDir, 'cached2', 'kernels').toString()
528+
];
529+
when(platformService.osType).thenReturn(OSType.Windows);
530+
when(platformService.homeDir).thenReturn(windowsHomeDir);
531+
when(memento.get(CACHE_KEY_FOR_JUPYTER_KERNEL_PATHS, anything())).thenReturn(cachedPaths);
532+
533+
const paths = await jupyterPaths.getKernelSpecRootPaths(cancelToken.token);
534+
535+
// Should return cached data if available
536+
assert.strictEqual(paths.length, 2);
537+
assert.strictEqual(paths[0].toString(), cachedPaths[0]);
538+
assert.strictEqual(paths[1].toString(), cachedPaths[1]);
539+
});
540+
541+
test('Get kernelspec root paths with JUPYTER_PATH on Linux', async () => {
542+
when(platformService.osType).thenReturn(OSType.Linux);
543+
when(platformService.isWindows).thenReturn(false);
544+
when(platformService.isMac).thenReturn(false);
545+
when(platformService.homeDir).thenReturn(unixHomeDir);
546+
when(memento.get(CACHE_KEY_FOR_JUPYTER_KERNEL_PATHS, anything())).thenReturn([]);
547+
548+
const jupyter_Paths = ['/custom/jupyter/path1', '/custom/jupyter/path2'];
549+
process.env['JUPYTER_PATH'] = jupyter_Paths.join(path.delimiter);
550+
551+
const paths = await jupyterPaths.getKernelSpecRootPaths(cancelToken.token);
552+
553+
// First paths should be from JUPYTER_PATH with 'kernels' appended
554+
assert.isAtLeast(paths.length, 2, `Expected at least 2 paths, got ${paths.length}`);
555+
assert.strictEqual(paths[0].toString(), Uri.joinPath(Uri.file(jupyter_Paths[0]), 'kernels').toString());
556+
assert.strictEqual(paths[1].toString(), Uri.joinPath(Uri.file(jupyter_Paths[1]), 'kernels').toString());
557+
});
558+
559+
test('Enhanced caching behavior works correctly', async () => {
560+
when(platformService.osType).thenReturn(OSType.Windows);
561+
when(platformService.homeDir).thenReturn(windowsHomeDir);
562+
when(memento.get(CACHE_KEY_FOR_JUPYTER_KERNEL_PATHS, anything())).thenReturn([]);
563+
564+
// First call
565+
const paths1 = await jupyterPaths.getKernelSpecRootPaths(cancelToken.token);
566+
567+
// Verify caching method is called
568+
verify(memento.get(CACHE_KEY_FOR_JUPYTER_KERNEL_PATHS, anything())).atLeast(1);
569+
570+
// Second call should use cached result (simulate by using same token)
571+
const newCancelToken = new CancellationTokenSource();
572+
disposables.push(newCancelToken);
573+
const paths2 = await jupyterPaths.getKernelSpecRootPaths(newCancelToken.token);
574+
575+
assert.strictEqual(paths1.length, paths2.length);
576+
paths1.forEach((path, index) => {
577+
assert.strictEqual(path.toString(), paths2[index].toString());
578+
});
361579
});
362580
});

0 commit comments

Comments
 (0)