Skip to content

Commit bea873e

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

File tree

2 files changed

+191
-10
lines changed

2 files changed

+191
-10
lines changed

src/managers/poetry/poetryUtils.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
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';
@@ -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,19 +295,21 @@ 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) {
@@ -321,11 +319,12 @@ async function resolveVirtualenvsPath(poetry: string, virtualenvsPath: string):
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> {

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)