jupyterlab_nb_venv_kernels_ui_extension 1.2.21 → 1.2.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -131,16 +131,41 @@ async function fetchVenvEnvironments() {
131
131
  return null;
132
132
  }
133
133
  }
134
+ /**
135
+ * Resolve an nb_venv_kernels environment path to an absolute path.
136
+ *
137
+ * nb_venv_kernels reports `env.path` relative to its `workspace_root` (e.g.
138
+ * `delaval/.../datascience/.venv`); the kernelspec `argv[0]` we compare it
139
+ * against is absolute. Join them so the prefix check works.
140
+ *
141
+ * @param envPath - The `path` field from an nb_venv_kernels environment
142
+ * @param workspaceRoot - The `workspace_root` field from the environments response
143
+ * @returns The absolute path, or null if it cannot be resolved
144
+ */
145
+ function absoluteEnvPath(envPath, workspaceRoot) {
146
+ if (!envPath) {
147
+ return null;
148
+ }
149
+ if (envPath.startsWith('/')) {
150
+ return envPath.replace(/\/+$/, '');
151
+ }
152
+ if (!workspaceRoot || !workspaceRoot.startsWith('/')) {
153
+ return null;
154
+ }
155
+ return (workspaceRoot.replace(/\/+$/, '') + '/' + envPath.replace(/^\/+|\/+$/g, ''));
156
+ }
134
157
  /**
135
158
  * Find a venv environment matching the given display name.
136
159
  *
137
- * When `executablePath` is provided, performs deterministic path-based
138
- * matching: the environment whose `path` is a prefix of the kernel's
139
- * Python executable wins. This avoids the substring-collision class of
140
- * bug where two envs share a name prefix (e.g. `demo` vs `demo-prod`).
160
+ * When `executablePath` is provided as an absolute path, performs
161
+ * deterministic path-based matching: the environment whose (resolved,
162
+ * absolute) `path` is a prefix of the kernel's Python executable wins. This
163
+ * avoids the substring-collision class of bug where two envs share a name
164
+ * prefix (e.g. `demo` vs `demo-prod`).
141
165
  *
142
- * Falls back to substring matching on env names only when no executable
143
- * path is available (e.g. KernelPathHandler returned null).
166
+ * Falls back to substring matching on env names when no executable path is
167
+ * available, or when none of the env paths could be resolved to absolute
168
+ * (old nb_venv_kernels without `workspace_root`).
144
169
  *
145
170
  * @param displayName - The kernel display name (used for fallback match)
146
171
  * @param executablePath - The kernel's `argv[0]` python path, if known
@@ -151,26 +176,31 @@ async function findVenvEnvironment(displayName, executablePath) {
151
176
  if (!envData) {
152
177
  return null;
153
178
  }
154
- // Path-based match (preferred) - exact prefix on env.path eliminates
155
- // ambiguity between envs with overlapping names. If we have an absolute
156
- // executable path, we trust it: a non-match here is definitive (kernel
157
- // not registered with nb_venv_kernels), so we do NOT fall back to
158
- // substring matching - that would re-introduce the very collision bug
159
- // this function is designed to prevent.
179
+ // Path-based match (preferred) - exact prefix on the env's absolute path
180
+ // eliminates ambiguity between envs with overlapping names. If we have an
181
+ // absolute executable path AND at least one env path resolved, we trust
182
+ // the result: a non-match is then definitive (kernel not registered with
183
+ // nb_venv_kernels), so we do NOT fall back to substring matching - that
184
+ // would re-introduce the very collision bug this function prevents.
160
185
  if (executablePath && executablePath.startsWith('/')) {
186
+ let attemptedPathMatch = false;
161
187
  for (const env of envData.environments) {
162
188
  if (env.type === 'conda') {
163
189
  continue;
164
190
  }
165
- if (!env.path) {
191
+ const envAbs = absoluteEnvPath(env.path, envData.workspace_root);
192
+ if (!envAbs) {
166
193
  continue;
167
194
  }
168
- const prefix = env.path.endsWith('/') ? env.path : env.path + '/';
169
- if (executablePath.startsWith(prefix)) {
195
+ attemptedPathMatch = true;
196
+ if (executablePath.startsWith(envAbs + '/')) {
170
197
  return env;
171
198
  }
172
199
  }
173
- return null;
200
+ if (attemptedPathMatch) {
201
+ return null;
202
+ }
203
+ // No env path could be resolved to absolute - fall through to substring.
174
204
  }
175
205
  // Fallback: substring match on env names, used when executablePath is
176
206
  // missing or relative (e.g. nb_venv_kernels did not rewrite argv[0] for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_nb_venv_kernels_ui_extension",
3
- "version": "1.2.21",
3
+ "version": "1.2.22",
4
4
  "description": "Jupyterlab extension to allow user to right-click on the kernel launcher button and select 'Show in File Browser' or 'Open Terminal at location' menus and get them to navigate to location or open location in terminal respectively",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -172,25 +172,48 @@ interface IVenvEnvironment {
172
172
  path: string;
173
173
  }
174
174
 
175
+ function absoluteEnvPath(
176
+ envPath: string,
177
+ workspaceRoot: string | undefined
178
+ ): string | null {
179
+ if (!envPath) {
180
+ return null;
181
+ }
182
+ if (envPath.startsWith('/')) {
183
+ return envPath.replace(/\/+$/, '');
184
+ }
185
+ if (!workspaceRoot || !workspaceRoot.startsWith('/')) {
186
+ return null;
187
+ }
188
+ return (
189
+ workspaceRoot.replace(/\/+$/, '') + '/' + envPath.replace(/^\/+|\/+$/g, '')
190
+ );
191
+ }
192
+
175
193
  function findVenvEnvironmentMatch(
176
194
  environments: IVenvEnvironment[],
177
195
  displayName: string,
178
- executablePath?: string | null
196
+ executablePath?: string | null,
197
+ workspaceRoot?: string
179
198
  ): IVenvEnvironment | null {
180
199
  if (executablePath && executablePath.startsWith('/')) {
200
+ let attemptedPathMatch = false;
181
201
  for (const env of environments) {
182
202
  if (env.type === 'conda') {
183
203
  continue;
184
204
  }
185
- if (!env.path) {
205
+ const envAbs = absoluteEnvPath(env.path, workspaceRoot);
206
+ if (!envAbs) {
186
207
  continue;
187
208
  }
188
- const prefix = env.path.endsWith('/') ? env.path : env.path + '/';
189
- if (executablePath.startsWith(prefix)) {
209
+ attemptedPathMatch = true;
210
+ if (executablePath.startsWith(envAbs + '/')) {
190
211
  return env;
191
212
  }
192
213
  }
193
- return null;
214
+ if (attemptedPathMatch) {
215
+ return null;
216
+ }
194
217
  }
195
218
 
196
219
  const candidates = environments
@@ -338,6 +361,79 @@ describe('findVenvEnvironment matching', () => {
338
361
  );
339
362
  expect(match).toBeNull();
340
363
  });
364
+
365
+ // Regression: nb_venv_kernels reports env.path relative to workspace_root,
366
+ // but the kernelspec argv[0] (executablePath) is absolute. Earlier the
367
+ // prefix check compared an absolute path against a relative one and never
368
+ // matched - every uv kernel showed "is not managed by nb_venv_kernels".
369
+ it('matches when env.path is relative to workspace_root', () => {
370
+ const envs: IVenvEnvironment[] = [
371
+ {
372
+ name: 'cp-kpi',
373
+ custom_name: 'cp-kpi',
374
+ type: 'uv',
375
+ exists: true,
376
+ has_kernel: true,
377
+ path: 'delaval/cp/graph-engine/datascience/.venv'
378
+ }
379
+ ];
380
+ const match = findVenvEnvironmentMatch(
381
+ envs,
382
+ 'Python [uv env:cp-kpi]',
383
+ '/home/lab/workspace/delaval/cp/graph-engine/datascience/.venv/bin/python',
384
+ '/home/lab/workspace'
385
+ );
386
+ expect(match?.name).toBe('cp-kpi');
387
+ });
388
+
389
+ it('disambiguates relative env paths sharing a prefix', () => {
390
+ const envs: IVenvEnvironment[] = [
391
+ {
392
+ name: 'demo',
393
+ custom_name: 'demo',
394
+ type: 'uv',
395
+ exists: true,
396
+ has_kernel: true,
397
+ path: 'projects/demo/.venv'
398
+ },
399
+ {
400
+ name: 'demo-prod',
401
+ custom_name: 'demo-prod',
402
+ type: 'uv',
403
+ exists: true,
404
+ has_kernel: true,
405
+ path: 'projects/demo-prod/.venv'
406
+ }
407
+ ];
408
+ const match = findVenvEnvironmentMatch(
409
+ envs,
410
+ 'Python [uv env:demo-prod]',
411
+ '/home/lab/workspace/projects/demo-prod/.venv/bin/python',
412
+ '/home/lab/workspace'
413
+ );
414
+ expect(match?.name).toBe('demo-prod');
415
+ });
416
+
417
+ it('falls back to substring when env paths are relative and workspace_root is missing', () => {
418
+ const envs: IVenvEnvironment[] = [
419
+ {
420
+ name: 'cp-kpi',
421
+ custom_name: 'cp-kpi',
422
+ type: 'uv',
423
+ exists: true,
424
+ has_kernel: true,
425
+ path: 'delaval/cp/datascience/.venv'
426
+ }
427
+ ];
428
+ // old nb_venv_kernels: no workspace_root -> path-match can't be attempted
429
+ const match = findVenvEnvironmentMatch(
430
+ envs,
431
+ 'Python [uv env:cp-kpi]',
432
+ '/home/lab/workspace/delaval/cp/datascience/.venv/bin/python',
433
+ undefined
434
+ );
435
+ expect(match?.name).toBe('cp-kpi');
436
+ });
341
437
  });
342
438
 
343
439
  describe('context menu configuration', () => {
package/src/index.ts CHANGED
@@ -200,16 +200,47 @@ async function fetchVenvEnvironments(): Promise<IVenvEnvironmentsResponse | null
200
200
  }
201
201
  }
202
202
 
203
+ /**
204
+ * Resolve an nb_venv_kernels environment path to an absolute path.
205
+ *
206
+ * nb_venv_kernels reports `env.path` relative to its `workspace_root` (e.g.
207
+ * `delaval/.../datascience/.venv`); the kernelspec `argv[0]` we compare it
208
+ * against is absolute. Join them so the prefix check works.
209
+ *
210
+ * @param envPath - The `path` field from an nb_venv_kernels environment
211
+ * @param workspaceRoot - The `workspace_root` field from the environments response
212
+ * @returns The absolute path, or null if it cannot be resolved
213
+ */
214
+ function absoluteEnvPath(
215
+ envPath: string,
216
+ workspaceRoot: string | undefined
217
+ ): string | null {
218
+ if (!envPath) {
219
+ return null;
220
+ }
221
+ if (envPath.startsWith('/')) {
222
+ return envPath.replace(/\/+$/, '');
223
+ }
224
+ if (!workspaceRoot || !workspaceRoot.startsWith('/')) {
225
+ return null;
226
+ }
227
+ return (
228
+ workspaceRoot.replace(/\/+$/, '') + '/' + envPath.replace(/^\/+|\/+$/g, '')
229
+ );
230
+ }
231
+
203
232
  /**
204
233
  * Find a venv environment matching the given display name.
205
234
  *
206
- * When `executablePath` is provided, performs deterministic path-based
207
- * matching: the environment whose `path` is a prefix of the kernel's
208
- * Python executable wins. This avoids the substring-collision class of
209
- * bug where two envs share a name prefix (e.g. `demo` vs `demo-prod`).
235
+ * When `executablePath` is provided as an absolute path, performs
236
+ * deterministic path-based matching: the environment whose (resolved,
237
+ * absolute) `path` is a prefix of the kernel's Python executable wins. This
238
+ * avoids the substring-collision class of bug where two envs share a name
239
+ * prefix (e.g. `demo` vs `demo-prod`).
210
240
  *
211
- * Falls back to substring matching on env names only when no executable
212
- * path is available (e.g. KernelPathHandler returned null).
241
+ * Falls back to substring matching on env names when no executable path is
242
+ * available, or when none of the env paths could be resolved to absolute
243
+ * (old nb_venv_kernels without `workspace_root`).
213
244
  *
214
245
  * @param displayName - The kernel display name (used for fallback match)
215
246
  * @param executablePath - The kernel's `argv[0]` python path, if known
@@ -224,26 +255,31 @@ async function findVenvEnvironment(
224
255
  return null;
225
256
  }
226
257
 
227
- // Path-based match (preferred) - exact prefix on env.path eliminates
228
- // ambiguity between envs with overlapping names. If we have an absolute
229
- // executable path, we trust it: a non-match here is definitive (kernel
230
- // not registered with nb_venv_kernels), so we do NOT fall back to
231
- // substring matching - that would re-introduce the very collision bug
232
- // this function is designed to prevent.
258
+ // Path-based match (preferred) - exact prefix on the env's absolute path
259
+ // eliminates ambiguity between envs with overlapping names. If we have an
260
+ // absolute executable path AND at least one env path resolved, we trust
261
+ // the result: a non-match is then definitive (kernel not registered with
262
+ // nb_venv_kernels), so we do NOT fall back to substring matching - that
263
+ // would re-introduce the very collision bug this function prevents.
233
264
  if (executablePath && executablePath.startsWith('/')) {
265
+ let attemptedPathMatch = false;
234
266
  for (const env of envData.environments) {
235
267
  if (env.type === 'conda') {
236
268
  continue;
237
269
  }
238
- if (!env.path) {
270
+ const envAbs = absoluteEnvPath(env.path, envData.workspace_root);
271
+ if (!envAbs) {
239
272
  continue;
240
273
  }
241
- const prefix = env.path.endsWith('/') ? env.path : env.path + '/';
242
- if (executablePath.startsWith(prefix)) {
274
+ attemptedPathMatch = true;
275
+ if (executablePath.startsWith(envAbs + '/')) {
243
276
  return env;
244
277
  }
245
278
  }
246
- return null;
279
+ if (attemptedPathMatch) {
280
+ return null;
281
+ }
282
+ // No env path could be resolved to absolute - fall through to substring.
247
283
  }
248
284
 
249
285
  // Fallback: substring match on env names, used when executablePath is