Skip to content

Commit 6c28cab

Browse files
committed
refactor: update resolveVirtualenvsPath to handle {cache-dir} placeholder and improve path resolution
1 parent 3c8ba72 commit 6c28cab

4 files changed

Lines changed: 203 additions & 24 deletions

File tree

src/managers/poetry/poetryUtils.ts

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
1-
import * as cp from 'child_process';
21
import * as fs from 'fs-extra';
32
import * as path from 'path';
4-
import { promisify } from 'util';
53
import { Uri } from 'vscode';
64
import which from 'which';
75
import { EnvironmentManager, PythonEnvironment, PythonEnvironmentApi, PythonEnvironmentInfo } from '../../api';
86
import { execProcess } from '../../common/childProcess.apis';
97
import { ENVS_EXTENSION_ID } from '../../common/constants';
108
import { traceError, traceInfo } from '../../common/logging';
119
import { getWorkspacePersistentState } from '../../common/persistentState';
12-
import { getUserHomeDir, untildify } from '../../common/utils/pathUtils';
10+
import { getUserHomeDir, normalizePath, untildify } from '../../common/utils/pathUtils';
1311
import { isMac, isWindows } from '../../common/utils/platformUtils';
1412
import { getSettingWorkspaceScope } from '../../features/settings/settingHelpers';
1513
import {
@@ -21,8 +19,6 @@ import {
2119
} from '../common/nativePythonFinder';
2220
import { getShellActivationCommands, shortVersion, sortEnvironments } from '../common/utils';
2321

24-
const exec = promisify(cp.exec);
25-
2622
/**
2723
* Checks if the POETRY_VIRTUALENVS_IN_PROJECT environment variable is set to a truthy value.
2824
* When true, Poetry creates virtualenvs in the project's `.venv` directory.
@@ -299,33 +295,36 @@ export function getDefaultPoetryVirtualenvsPath(): string | undefined {
299295
* First tries to query Poetry's cache-dir config, then falls back to platform-specific default.
300296
* @param poetry Path to the poetry executable
301297
* @param virtualenvsPath The path possibly containing {cache-dir} placeholder
298+
* @returns The resolved path, or undefined if the placeholder cannot be resolved
302299
*/
303-
async function resolveVirtualenvsPath(poetry: string, virtualenvsPath: string): Promise<string> {
300+
async function resolveVirtualenvsPath(poetry: string, virtualenvsPath: string): Promise<string | undefined> {
304301
if (!virtualenvsPath.includes('{cache-dir}')) {
305302
return virtualenvsPath;
306303
}
307304

308305
// Try to get the actual cache-dir from Poetry
309306
try {
310-
const { stdout } = await exec(`"${poetry}" config cache-dir`);
307+
const { stdout } = await execProcess(`"${poetry}" config cache-dir`);
311308
if (stdout) {
312309
const cacheDir = stdout.trim();
313310
if (cacheDir && path.isAbsolute(cacheDir)) {
314-
return virtualenvsPath.replace('{cache-dir}', cacheDir);
311+
const resolved = virtualenvsPath.replace('{cache-dir}', cacheDir);
312+
return path.normalize(resolved);
315313
}
316314
}
317315
} catch (e) {
318-
traceError(`Error getting Poetry cache-dir config: ${e}`);
316+
traceError('Error getting Poetry cache-dir config', e);
319317
}
320318

321319
// Fall back to platform-specific default cache dir
322320
const defaultCacheDir = getDefaultPoetryCacheDir();
323321
if (defaultCacheDir) {
324-
return virtualenvsPath.replace('{cache-dir}', defaultCacheDir);
322+
const resolved = virtualenvsPath.replace('{cache-dir}', defaultCacheDir);
323+
return path.normalize(resolved);
325324
}
326325

327-
// Last resort: return the original path (will likely not be valid)
328-
return virtualenvsPath;
326+
// Cannot resolve the placeholder - return undefined instead of unresolved path
327+
return undefined;
329328
}
330329

331330
export async function getPoetryVersion(poetry: string): Promise<string | undefined> {
@@ -357,8 +356,8 @@ export async function nativeToPythonEnv(
357356
const displayName = info.displayName || `poetry (${sv})`;
358357

359358
// Check if this is a global Poetry virtualenv by checking if it's in Poetry's virtualenvs directory
360-
// We need to use path.normalize() to ensure consistent path format comparison
361-
const normalizedPrefix = path.normalize(info.prefix);
359+
// We use normalizePath() for case-insensitive path comparison on Windows
360+
const normalizedPrefix = normalizePath(info.prefix);
362361

363362
// Determine if the environment is in Poetry's global virtualenvs directory
364363
let isGlobalPoetryEnv = false;
@@ -367,19 +366,17 @@ export async function nativeToPythonEnv(
367366
if (!isPoetryVirtualenvsInProject() || !info.project) {
368367
const virtualenvsPath = poetryVirtualenvsPath; // Use the cached value if available
369368
if (virtualenvsPath) {
370-
const normalizedVirtualenvsPath = path.normalize(virtualenvsPath);
369+
const normalizedVirtualenvsPath = normalizePath(virtualenvsPath);
371370
isGlobalPoetryEnv = normalizedPrefix.startsWith(normalizedVirtualenvsPath);
372371
} else {
373372
// Fall back to checking the platform-specific default location if we haven't cached the path yet
374373
const defaultPath = getDefaultPoetryVirtualenvsPath();
375374
if (defaultPath) {
376-
const normalizedDefaultPath = path.normalize(defaultPath);
375+
const normalizedDefaultPath = normalizePath(defaultPath);
377376
isGlobalPoetryEnv = normalizedPrefix.startsWith(normalizedDefaultPath);
378377

379378
// Try to get the actual path asynchronously for next time
380-
getPoetryVirtualenvsPath(_poetry).catch((e) =>
381-
traceError(`Error getting Poetry virtualenvs path: ${e}`),
382-
);
379+
getPoetryVirtualenvsPath(_poetry).catch((e) => traceError('Error getting Poetry virtualenvs path', e));
383380
}
384381
}
385382
}

src/test/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export function isMultiRootTest(): boolean {
2626
return false;
2727
}
2828
try {
29-
// eslint-disable-next-line @typescript-eslint/no-require-imports
29+
3030
const vscode = require('vscode');
3131
return Array.isArray(vscode.workspace.workspaceFolders) && vscode.workspace.workspaceFolders.length > 1;
3232
} catch {

src/test/features/projectManager.initialize.unit.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -310,8 +310,8 @@ suite('Project Manager Initialization - Settings Preservation', () => {
310310
test('adding a workspace folder should NOT write project settings', async () => {
311311
const mockConfig = new MockWorkspaceConfiguration();
312312
(mockConfig as any).get = <T>(key: string, defaultValue?: T): T | undefined => {
313-
if (key === 'pythonProjects') return [] as unknown as T;
314-
if (key === 'defaultEnvManager') return 'ms-python.python:venv' as T;
313+
if (key === 'pythonProjects') {return [] as unknown as T;}
314+
if (key === 'defaultEnvManager') {return 'ms-python.python:venv' as T;}
315315
return defaultValue;
316316
};
317317
mockConfig.update = () => Promise.resolve();
@@ -347,8 +347,8 @@ suite('Project Manager Initialization - Settings Preservation', () => {
347347
test('removing a workspace folder should NOT write additional settings', async () => {
348348
const mockConfig = new MockWorkspaceConfiguration();
349349
(mockConfig as any).get = <T>(key: string, defaultValue?: T): T | undefined => {
350-
if (key === 'pythonProjects') return [] as unknown as T;
351-
if (key === 'defaultEnvManager') return 'ms-python.python:venv' as T;
350+
if (key === 'pythonProjects') {return [] as unknown as T;}
351+
if (key === 'defaultEnvManager') {return 'ms-python.python:venv' as T;}
352352
return defaultValue;
353353
};
354354
mockConfig.update = () => Promise.resolve();

src/test/managers/poetry/poetryUtils.unit.test.ts

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,20 @@ import path from 'node:path';
33
import * as sinon from 'sinon';
44
import { EnvironmentManager, PythonEnvironment, PythonEnvironmentApi, PythonEnvironmentInfo } from '../../../api';
55
import * as childProcessApis from '../../../common/childProcess.apis';
6+
import * as persistentState from '../../../common/persistentState';
67
import * as pathUtils from '../../../common/utils/pathUtils';
78
import * as platformUtils from '../../../common/utils/platformUtils';
89
import { NativeEnvInfo } from '../../../managers/common/nativePythonFinder';
910
import * as utils from '../../../managers/common/utils';
1011
import {
12+
clearPoetryCache,
1113
getDefaultPoetryCacheDir,
1214
getDefaultPoetryVirtualenvsPath,
1315
getPoetryVersion,
16+
getPoetryVirtualenvsPath,
1417
isPoetryVirtualenvsInProject,
1518
nativeToPythonEnv,
19+
POETRY_VIRTUALENVS_PATH_KEY,
1620
} from '../../../managers/poetry/poetryUtils';
1721

1822
suite('isPoetryVirtualenvsInProject', () => {
@@ -365,3 +369,181 @@ suite('getDefaultPoetryVirtualenvsPath', () => {
365369
assert.strictEqual(result, undefined);
366370
});
367371
});
372+
373+
suite('getPoetryVirtualenvsPath - {cache-dir} placeholder resolution', () => {
374+
let execProcessStub: sinon.SinonStub;
375+
let isWindowsStub: sinon.SinonStub;
376+
let isMacStub: sinon.SinonStub;
377+
let getUserHomeDirStub: sinon.SinonStub;
378+
let getWorkspacePersistentStateStub: sinon.SinonStub;
379+
let mockState: { get: sinon.SinonStub; set: sinon.SinonStub };
380+
381+
setup(async () => {
382+
execProcessStub = sinon.stub(childProcessApis, 'execProcess');
383+
isWindowsStub = sinon.stub(platformUtils, 'isWindows');
384+
isMacStub = sinon.stub(platformUtils, 'isMac');
385+
getUserHomeDirStub = sinon.stub(pathUtils, 'getUserHomeDir');
386+
387+
// Create a mock state object to track persistence
388+
mockState = {
389+
get: sinon.stub(),
390+
set: sinon.stub().resolves(),
391+
};
392+
getWorkspacePersistentStateStub = sinon.stub(persistentState, 'getWorkspacePersistentState');
393+
getWorkspacePersistentStateStub.resolves(mockState);
394+
395+
// Clear Poetry cache before each test
396+
await clearPoetryCache();
397+
});
398+
399+
teardown(() => {
400+
sinon.restore();
401+
});
402+
403+
test('resolves {cache-dir} placeholder when poetry config cache-dir succeeds', async () => {
404+
// First call: virtualenvs.path returns a path with {cache-dir}
405+
execProcessStub.onFirstCall().resolves({ stdout: '{cache-dir}/virtualenvs\n', stderr: '' });
406+
// Second call: cache-dir config returns the actual path
407+
execProcessStub.onSecondCall().resolves({ stdout: '/home/test/.cache/pypoetry\n', stderr: '' });
408+
409+
const result = await getPoetryVirtualenvsPath('/usr/bin/poetry');
410+
411+
assert.strictEqual(result, path.join('/home/test/.cache/pypoetry', 'virtualenvs'));
412+
// Verify the resolved path was persisted
413+
assert.ok(
414+
mockState.set.calledWith(
415+
POETRY_VIRTUALENVS_PATH_KEY,
416+
path.join('/home/test/.cache/pypoetry', 'virtualenvs'),
417+
),
418+
);
419+
});
420+
421+
test('falls back to platform default when poetry config cache-dir fails', async () => {
422+
isWindowsStub.returns(false);
423+
isMacStub.returns(false);
424+
getUserHomeDirStub.returns('/home/test');
425+
426+
// First call: virtualenvs.path returns a path with {cache-dir}
427+
execProcessStub.onFirstCall().resolves({ stdout: '{cache-dir}/virtualenvs\n', stderr: '' });
428+
// Second call: cache-dir config fails
429+
execProcessStub.onSecondCall().rejects(new Error('Command failed'));
430+
431+
const result = await getPoetryVirtualenvsPath('/usr/bin/poetry');
432+
433+
// Should fall back to platform default cache dir
434+
const expectedPath = path.join('/home/test', '.cache', 'pypoetry', 'virtualenvs');
435+
assert.strictEqual(result, expectedPath);
436+
// The resolved path should still be persisted
437+
assert.ok(mockState.set.calledWith(POETRY_VIRTUALENVS_PATH_KEY, expectedPath));
438+
});
439+
440+
test('falls back to platform default when poetry config cache-dir returns non-absolute path', async () => {
441+
isWindowsStub.returns(false);
442+
isMacStub.returns(false);
443+
getUserHomeDirStub.returns('/home/test');
444+
445+
// First call: virtualenvs.path returns a path with {cache-dir}
446+
execProcessStub.onFirstCall().resolves({ stdout: '{cache-dir}/virtualenvs\n', stderr: '' });
447+
// Second call: cache-dir returns a relative path (invalid)
448+
execProcessStub.onSecondCall().resolves({ stdout: 'relative/path\n', stderr: '' });
449+
450+
const result = await getPoetryVirtualenvsPath('/usr/bin/poetry');
451+
452+
// Should fall back to platform default cache dir
453+
const expectedPath = path.join('/home/test', '.cache', 'pypoetry', 'virtualenvs');
454+
assert.strictEqual(result, expectedPath);
455+
});
456+
457+
test('does not persist path when placeholder cannot be resolved and no platform default', async () => {
458+
isWindowsStub.returns(false);
459+
isMacStub.returns(false);
460+
getUserHomeDirStub.returns(undefined); // No home dir available
461+
462+
// First call: virtualenvs.path returns a path with {cache-dir}
463+
execProcessStub.onFirstCall().resolves({ stdout: '{cache-dir}/virtualenvs\n', stderr: '' });
464+
// Second call: cache-dir config fails
465+
execProcessStub.onSecondCall().rejects(new Error('Command failed'));
466+
467+
const result = await getPoetryVirtualenvsPath('/usr/bin/poetry');
468+
469+
// Should fall back to platform default (which returns undefined when home is not available)
470+
assert.strictEqual(result, undefined);
471+
// Path should NOT be persisted when unresolved
472+
assert.ok(!mockState.set.calledWith(POETRY_VIRTUALENVS_PATH_KEY, sinon.match.any));
473+
});
474+
475+
test('handles virtualenvs.path without {cache-dir} placeholder (absolute path)', async () => {
476+
// virtualenvs.path returns an absolute path directly
477+
execProcessStub.onFirstCall().resolves({ stdout: '/custom/virtualenvs/path\n', stderr: '' });
478+
479+
const result = await getPoetryVirtualenvsPath('/usr/bin/poetry');
480+
481+
assert.strictEqual(result, '/custom/virtualenvs/path');
482+
// Should be persisted
483+
assert.ok(mockState.set.calledWith(POETRY_VIRTUALENVS_PATH_KEY, '/custom/virtualenvs/path'));
484+
});
485+
486+
test('falls back to platform default when virtualenvs.path returns non-absolute path without placeholder', async () => {
487+
isWindowsStub.returns(false);
488+
isMacStub.returns(false);
489+
getUserHomeDirStub.returns('/home/test');
490+
491+
// virtualenvs.path returns a relative path (not valid)
492+
execProcessStub.onFirstCall().resolves({ stdout: 'relative/path\n', stderr: '' });
493+
494+
const result = await getPoetryVirtualenvsPath('/usr/bin/poetry');
495+
496+
// Should fall back to platform default
497+
const expectedPath = path.join('/home/test', '.cache', 'pypoetry', 'virtualenvs');
498+
assert.strictEqual(result, expectedPath);
499+
});
500+
501+
test('uses cached value from persistent state', async () => {
502+
mockState.get.resolves('/cached/virtualenvs/path');
503+
504+
const result = await getPoetryVirtualenvsPath('/usr/bin/poetry');
505+
506+
assert.strictEqual(result, '/cached/virtualenvs/path');
507+
// Should not call exec since we have a cached value
508+
assert.ok(!execProcessStub.called);
509+
});
510+
511+
test('handles virtualenvs.path config command failure', async () => {
512+
isWindowsStub.returns(false);
513+
isMacStub.returns(false);
514+
getUserHomeDirStub.returns('/home/test');
515+
516+
// virtualenvs.path config fails
517+
execProcessStub.onFirstCall().rejects(new Error('Config command failed'));
518+
519+
const result = await getPoetryVirtualenvsPath('/usr/bin/poetry');
520+
521+
// Should fall back to platform default
522+
const expectedPath = path.join('/home/test', '.cache', 'pypoetry', 'virtualenvs');
523+
assert.strictEqual(result, expectedPath);
524+
});
525+
526+
test('Windows: resolves {cache-dir} with platform default when cache-dir query fails', async () => {
527+
const originalLocalAppData = process.env.LOCALAPPDATA;
528+
try {
529+
isWindowsStub.returns(true);
530+
process.env.LOCALAPPDATA = 'C:\\Users\\test\\AppData\\Local';
531+
532+
// First call: virtualenvs.path returns a path with {cache-dir}
533+
execProcessStub.onFirstCall().resolves({ stdout: '{cache-dir}/virtualenvs\n', stderr: '' });
534+
// Second call: cache-dir config fails
535+
execProcessStub.onSecondCall().rejects(new Error('Command failed'));
536+
537+
const result = await getPoetryVirtualenvsPath('C:\\poetry\\poetry.exe');
538+
539+
const expectedPath = path.join('C:\\Users\\test\\AppData\\Local', 'pypoetry', 'Cache', 'virtualenvs');
540+
assert.strictEqual(result, expectedPath);
541+
} finally {
542+
if (originalLocalAppData === undefined) {
543+
delete process.env.LOCALAPPDATA;
544+
} else {
545+
process.env.LOCALAPPDATA = originalLocalAppData;
546+
}
547+
}
548+
});
549+
});

0 commit comments

Comments
 (0)