From 3c8ba729753b3fc6a604921d8370bddd41642588 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Tue, 10 Feb 2026 15:15:23 -0800 Subject: [PATCH 1/2] Add platform-specific default paths for Poetry cache and virtualenvs --- src/common/utils/platformUtils.ts | 4 + src/managers/poetry/poetryUtils.ts | 117 +++++++++++-- .../managers/poetry/poetryUtils.unit.test.ts | 157 ++++++++++++++++++ 3 files changed, 261 insertions(+), 17 deletions(-) diff --git a/src/common/utils/platformUtils.ts b/src/common/utils/platformUtils.ts index bc11fd8a..53245b0b 100644 --- a/src/common/utils/platformUtils.ts +++ b/src/common/utils/platformUtils.ts @@ -1,3 +1,7 @@ export function isWindows(): boolean { return process.platform === 'win32'; } + +export function isMac(): boolean { + return process.platform === 'darwin'; +} diff --git a/src/managers/poetry/poetryUtils.ts b/src/managers/poetry/poetryUtils.ts index 038028ce..889dee42 100644 --- a/src/managers/poetry/poetryUtils.ts +++ b/src/managers/poetry/poetryUtils.ts @@ -1,5 +1,7 @@ +import * as cp from 'child_process'; import * as fs from 'fs-extra'; import * as path from 'path'; +import { promisify } from 'util'; import { Uri } from 'vscode'; import which from 'which'; import { EnvironmentManager, PythonEnvironment, PythonEnvironmentApi, PythonEnvironmentInfo } from '../../api'; @@ -8,7 +10,7 @@ import { ENVS_EXTENSION_ID } from '../../common/constants'; import { traceError, traceInfo } from '../../common/logging'; import { getWorkspacePersistentState } from '../../common/persistentState'; import { getUserHomeDir, untildify } from '../../common/utils/pathUtils'; -import { isWindows } from '../../common/utils/platformUtils'; +import { isMac, isWindows } from '../../common/utils/platformUtils'; import { getSettingWorkspaceScope } from '../../features/settings/settingHelpers'; import { isNativeEnvInfo, @@ -19,6 +21,8 @@ import { } from '../common/nativePythonFinder'; import { getShellActivationCommands, shortVersion, sortEnvironments } from '../common/utils'; +const exec = promisify(cp.exec); + /** * Checks if the POETRY_VIRTUALENVS_IN_PROJECT environment variable is set to a truthy value. * When true, Poetry creates virtualenvs in the project's `.venv` directory. @@ -214,14 +218,14 @@ export async function getPoetryVirtualenvsPath(poetryExe?: string): Promise { + if (!virtualenvsPath.includes('{cache-dir}')) { + return virtualenvsPath; + } + + // Try to get the actual cache-dir from Poetry + try { + const { stdout } = await exec(`"${poetry}" config cache-dir`); + if (stdout) { + const cacheDir = stdout.trim(); + if (cacheDir && path.isAbsolute(cacheDir)) { + return virtualenvsPath.replace('{cache-dir}', cacheDir); + } + } + } catch (e) { + traceError(`Error getting Poetry cache-dir config: ${e}`); + } + + // Fall back to platform-specific default cache dir + const defaultCacheDir = getDefaultPoetryCacheDir(); + if (defaultCacheDir) { + return virtualenvsPath.replace('{cache-dir}', defaultCacheDir); + } + + // Last resort: return the original path (will likely not be valid) + return virtualenvsPath; +} + export async function getPoetryVersion(poetry: string): Promise { try { const { stdout } = await execProcess(`"${poetry}" --version`); @@ -287,11 +370,11 @@ export async function nativeToPythonEnv( const normalizedVirtualenvsPath = path.normalize(virtualenvsPath); isGlobalPoetryEnv = normalizedPrefix.startsWith(normalizedVirtualenvsPath); } else { - // Fall back to checking the default location if we haven't cached the path yet - const homeDir = getUserHomeDir(); - if (homeDir) { - const defaultPath = path.normalize(path.join(homeDir, '.cache', 'pypoetry', 'virtualenvs')); - isGlobalPoetryEnv = normalizedPrefix.startsWith(defaultPath); + // Fall back to checking the platform-specific default location if we haven't cached the path yet + const defaultPath = getDefaultPoetryVirtualenvsPath(); + if (defaultPath) { + const normalizedDefaultPath = path.normalize(defaultPath); + isGlobalPoetryEnv = normalizedPrefix.startsWith(normalizedDefaultPath); // Try to get the actual path asynchronously for next time getPoetryVirtualenvsPath(_poetry).catch((e) => diff --git a/src/test/managers/poetry/poetryUtils.unit.test.ts b/src/test/managers/poetry/poetryUtils.unit.test.ts index e1bd44e0..4612ed90 100644 --- a/src/test/managers/poetry/poetryUtils.unit.test.ts +++ b/src/test/managers/poetry/poetryUtils.unit.test.ts @@ -1,10 +1,15 @@ import assert from 'node:assert'; +import path from 'node:path'; import * as sinon from 'sinon'; import { EnvironmentManager, PythonEnvironment, PythonEnvironmentApi, PythonEnvironmentInfo } from '../../../api'; import * as childProcessApis from '../../../common/childProcess.apis'; +import * as pathUtils from '../../../common/utils/pathUtils'; +import * as platformUtils from '../../../common/utils/platformUtils'; import { NativeEnvInfo } from '../../../managers/common/nativePythonFinder'; import * as utils from '../../../managers/common/utils'; import { + getDefaultPoetryCacheDir, + getDefaultPoetryVirtualenvsPath, getPoetryVersion, isPoetryVirtualenvsInProject, nativeToPythonEnv, @@ -208,3 +213,155 @@ suite('getPoetryVersion - childProcess.apis mocking pattern', () => { assert.strictEqual(version, undefined); }); }); + +suite('getDefaultPoetryCacheDir', () => { + let isWindowsStub: sinon.SinonStub; + let isMacStub: sinon.SinonStub; + let getUserHomeDirStub: sinon.SinonStub; + let originalLocalAppData: string | undefined; + let originalAppData: string | undefined; + + setup(() => { + isWindowsStub = sinon.stub(platformUtils, 'isWindows'); + isMacStub = sinon.stub(platformUtils, 'isMac'); + getUserHomeDirStub = sinon.stub(pathUtils, 'getUserHomeDir'); + + // Save original env vars + originalLocalAppData = process.env.LOCALAPPDATA; + originalAppData = process.env.APPDATA; + }); + + teardown(() => { + sinon.restore(); + // Restore original env vars + if (originalLocalAppData === undefined) { + delete process.env.LOCALAPPDATA; + } else { + process.env.LOCALAPPDATA = originalLocalAppData; + } + if (originalAppData === undefined) { + delete process.env.APPDATA; + } else { + process.env.APPDATA = originalAppData; + } + }); + + test('Windows: uses LOCALAPPDATA when available', () => { + isWindowsStub.returns(true); + process.env.LOCALAPPDATA = 'C:\\Users\\test\\AppData\\Local'; + + const result = getDefaultPoetryCacheDir(); + + assert.strictEqual(result, path.join('C:\\Users\\test\\AppData\\Local', 'pypoetry', 'Cache')); + }); + + test('Windows: falls back to APPDATA when LOCALAPPDATA is not set', () => { + isWindowsStub.returns(true); + delete process.env.LOCALAPPDATA; + process.env.APPDATA = 'C:\\Users\\test\\AppData\\Roaming'; + + const result = getDefaultPoetryCacheDir(); + + assert.strictEqual(result, path.join('C:\\Users\\test\\AppData\\Roaming', 'pypoetry', 'Cache')); + }); + + test('Windows: returns undefined when neither LOCALAPPDATA nor APPDATA is set', () => { + isWindowsStub.returns(true); + delete process.env.LOCALAPPDATA; + delete process.env.APPDATA; + + const result = getDefaultPoetryCacheDir(); + + assert.strictEqual(result, undefined); + }); + + test('macOS: uses ~/Library/Caches/pypoetry', () => { + isWindowsStub.returns(false); + isMacStub.returns(true); + getUserHomeDirStub.returns('/Users/test'); + + const result = getDefaultPoetryCacheDir(); + + assert.strictEqual(result, path.join('/Users/test', 'Library', 'Caches', 'pypoetry')); + }); + + test('Linux: uses ~/.cache/pypoetry', () => { + isWindowsStub.returns(false); + isMacStub.returns(false); + getUserHomeDirStub.returns('/home/test'); + + const result = getDefaultPoetryCacheDir(); + + assert.strictEqual(result, path.join('/home/test', '.cache', 'pypoetry')); + }); + + test('returns undefined when home directory is not available (non-Windows)', () => { + isWindowsStub.returns(false); + getUserHomeDirStub.returns(undefined); + + const result = getDefaultPoetryCacheDir(); + + assert.strictEqual(result, undefined); + }); +}); + +suite('getDefaultPoetryVirtualenvsPath', () => { + let isWindowsStub: sinon.SinonStub; + let isMacStub: sinon.SinonStub; + let getUserHomeDirStub: sinon.SinonStub; + let originalLocalAppData: string | undefined; + + setup(() => { + isWindowsStub = sinon.stub(platformUtils, 'isWindows'); + isMacStub = sinon.stub(platformUtils, 'isMac'); + getUserHomeDirStub = sinon.stub(pathUtils, 'getUserHomeDir'); + originalLocalAppData = process.env.LOCALAPPDATA; + }); + + teardown(() => { + sinon.restore(); + if (originalLocalAppData === undefined) { + delete process.env.LOCALAPPDATA; + } else { + process.env.LOCALAPPDATA = originalLocalAppData; + } + }); + + test('appends virtualenvs to cache directory', () => { + isWindowsStub.returns(false); + isMacStub.returns(false); + getUserHomeDirStub.returns('/home/test'); + + const result = getDefaultPoetryVirtualenvsPath(); + + assert.strictEqual(result, path.join('/home/test', '.cache', 'pypoetry', 'virtualenvs')); + }); + + test('Windows: returns correct virtualenvs path', () => { + isWindowsStub.returns(true); + process.env.LOCALAPPDATA = 'C:\\Users\\test\\AppData\\Local'; + + const result = getDefaultPoetryVirtualenvsPath(); + + assert.strictEqual(result, path.join('C:\\Users\\test\\AppData\\Local', 'pypoetry', 'Cache', 'virtualenvs')); + }); + + test('macOS: returns correct virtualenvs path', () => { + isWindowsStub.returns(false); + isMacStub.returns(true); + getUserHomeDirStub.returns('/Users/test'); + + const result = getDefaultPoetryVirtualenvsPath(); + + assert.strictEqual(result, path.join('/Users/test', 'Library', 'Caches', 'pypoetry', 'virtualenvs')); + }); + + test('returns undefined when cache dir is not available', () => { + isWindowsStub.returns(false); + getUserHomeDirStub.returns(undefined); + + const result = getDefaultPoetryVirtualenvsPath(); + + assert.strictEqual(result, undefined); + }); +}); From 21492844956b747b15c55b79745e4a6e49259f86 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Wed, 18 Feb 2026 11:51:18 -0800 Subject: [PATCH 2/2] refactor: update resolveVirtualenvsPath to handle {cache-dir} placeholder and improve path resolution --- src/managers/poetry/poetryUtils.ts | 19 +- .../managers/poetry/poetryUtils.unit.test.ts | 176 ++++++++++++++++++ 2 files changed, 185 insertions(+), 10 deletions(-) diff --git a/src/managers/poetry/poetryUtils.ts b/src/managers/poetry/poetryUtils.ts index 889dee42..42915adb 100644 --- a/src/managers/poetry/poetryUtils.ts +++ b/src/managers/poetry/poetryUtils.ts @@ -1,7 +1,5 @@ -import * as cp from 'child_process'; import * as fs from 'fs-extra'; import * as path from 'path'; -import { promisify } from 'util'; import { Uri } from 'vscode'; import which from 'which'; import { EnvironmentManager, PythonEnvironment, PythonEnvironmentApi, PythonEnvironmentInfo } from '../../api'; @@ -21,8 +19,6 @@ import { } from '../common/nativePythonFinder'; import { getShellActivationCommands, shortVersion, sortEnvironments } from '../common/utils'; -const exec = promisify(cp.exec); - /** * Checks if the POETRY_VIRTUALENVS_IN_PROJECT environment variable is set to a truthy value. * When true, Poetry creates virtualenvs in the project's `.venv` directory. @@ -299,19 +295,21 @@ export function getDefaultPoetryVirtualenvsPath(): string | undefined { * First tries to query Poetry's cache-dir config, then falls back to platform-specific default. * @param poetry Path to the poetry executable * @param virtualenvsPath The path possibly containing {cache-dir} placeholder + * @returns The resolved path, or undefined if the placeholder cannot be resolved */ -async function resolveVirtualenvsPath(poetry: string, virtualenvsPath: string): Promise { +async function resolveVirtualenvsPath(poetry: string, virtualenvsPath: string): Promise { if (!virtualenvsPath.includes('{cache-dir}')) { return virtualenvsPath; } // Try to get the actual cache-dir from Poetry try { - const { stdout } = await exec(`"${poetry}" config cache-dir`); + const { stdout } = await execProcess(`"${poetry}" config cache-dir`); if (stdout) { const cacheDir = stdout.trim(); if (cacheDir && path.isAbsolute(cacheDir)) { - return virtualenvsPath.replace('{cache-dir}', cacheDir); + const resolved = virtualenvsPath.replace('{cache-dir}', cacheDir); + return path.normalize(resolved); } } } catch (e) { @@ -321,11 +319,12 @@ async function resolveVirtualenvsPath(poetry: string, virtualenvsPath: string): // Fall back to platform-specific default cache dir const defaultCacheDir = getDefaultPoetryCacheDir(); if (defaultCacheDir) { - return virtualenvsPath.replace('{cache-dir}', defaultCacheDir); + const resolved = virtualenvsPath.replace('{cache-dir}', defaultCacheDir); + return path.normalize(resolved); } - // Last resort: return the original path (will likely not be valid) - return virtualenvsPath; + // Cannot resolve the placeholder - return undefined instead of unresolved path + return undefined; } export async function getPoetryVersion(poetry: string): Promise { diff --git a/src/test/managers/poetry/poetryUtils.unit.test.ts b/src/test/managers/poetry/poetryUtils.unit.test.ts index 4612ed90..ac94d817 100644 --- a/src/test/managers/poetry/poetryUtils.unit.test.ts +++ b/src/test/managers/poetry/poetryUtils.unit.test.ts @@ -3,16 +3,20 @@ import path from 'node:path'; import * as sinon from 'sinon'; import { EnvironmentManager, PythonEnvironment, PythonEnvironmentApi, PythonEnvironmentInfo } from '../../../api'; import * as childProcessApis from '../../../common/childProcess.apis'; +import * as persistentState from '../../../common/persistentState'; import * as pathUtils from '../../../common/utils/pathUtils'; import * as platformUtils from '../../../common/utils/platformUtils'; import { NativeEnvInfo } from '../../../managers/common/nativePythonFinder'; import * as utils from '../../../managers/common/utils'; import { + clearPoetryCache, getDefaultPoetryCacheDir, getDefaultPoetryVirtualenvsPath, getPoetryVersion, + getPoetryVirtualenvsPath, isPoetryVirtualenvsInProject, nativeToPythonEnv, + POETRY_VIRTUALENVS_PATH_KEY, } from '../../../managers/poetry/poetryUtils'; suite('isPoetryVirtualenvsInProject', () => { @@ -365,3 +369,175 @@ suite('getDefaultPoetryVirtualenvsPath', () => { assert.strictEqual(result, undefined); }); }); + +suite('getPoetryVirtualenvsPath - {cache-dir} placeholder resolution', () => { + let execProcessStub: sinon.SinonStub; + let isWindowsStub: sinon.SinonStub; + let isMacStub: sinon.SinonStub; + let getUserHomeDirStub: sinon.SinonStub; + let getWorkspacePersistentStateStub: sinon.SinonStub; + let mockState: { get: sinon.SinonStub; set: sinon.SinonStub }; + + setup(async () => { + execProcessStub = sinon.stub(childProcessApis, 'execProcess'); + isWindowsStub = sinon.stub(platformUtils, 'isWindows'); + isMacStub = sinon.stub(platformUtils, 'isMac'); + getUserHomeDirStub = sinon.stub(pathUtils, 'getUserHomeDir'); + + // Create a mock state object to track persistence + mockState = { + get: sinon.stub(), + set: sinon.stub().resolves(), + }; + getWorkspacePersistentStateStub = sinon.stub(persistentState, 'getWorkspacePersistentState'); + getWorkspacePersistentStateStub.resolves(mockState); + + // Clear Poetry cache before each test + await clearPoetryCache(); + }); + + teardown(() => { + sinon.restore(); + }); + + test('resolves {cache-dir} placeholder when poetry config cache-dir succeeds', async () => { + // First call: virtualenvs.path returns a path with {cache-dir} + execProcessStub.onFirstCall().resolves({ stdout: '{cache-dir}/virtualenvs\n', stderr: '' }); + // Second call: cache-dir config returns the actual path + execProcessStub.onSecondCall().resolves({ stdout: '/home/test/.cache/pypoetry\n', stderr: '' }); + + const result = await getPoetryVirtualenvsPath('/usr/bin/poetry'); + + assert.strictEqual(result, path.join('/home/test/.cache/pypoetry', 'virtualenvs')); + // Verify the resolved path was persisted + assert.ok( + mockState.set.calledWith( + POETRY_VIRTUALENVS_PATH_KEY, + path.join('/home/test/.cache/pypoetry', 'virtualenvs'), + ), + ); + }); + + test('falls back to platform default when poetry config cache-dir fails', async () => { + isWindowsStub.returns(false); + isMacStub.returns(false); + getUserHomeDirStub.returns('/home/test'); + + // First call: virtualenvs.path returns a path with {cache-dir} + execProcessStub.onFirstCall().resolves({ stdout: '{cache-dir}/virtualenvs\n', stderr: '' }); + // Second call: cache-dir config fails + execProcessStub.onSecondCall().rejects(new Error('Command failed')); + + const result = await getPoetryVirtualenvsPath('/usr/bin/poetry'); + + // Should fall back to platform default cache dir + const expectedPath = path.join('/home/test', '.cache', 'pypoetry', 'virtualenvs'); + assert.strictEqual(result, expectedPath); + // The resolved path should still be persisted + assert.ok(mockState.set.calledWith(POETRY_VIRTUALENVS_PATH_KEY, expectedPath)); + }); + + test('falls back to platform default when poetry config cache-dir returns non-absolute path', async () => { + isWindowsStub.returns(false); + isMacStub.returns(false); + getUserHomeDirStub.returns('/home/test'); + + // First call: virtualenvs.path returns a path with {cache-dir} + execProcessStub.onFirstCall().resolves({ stdout: '{cache-dir}/virtualenvs\n', stderr: '' }); + // Second call: cache-dir returns a relative path (invalid) + execProcessStub.onSecondCall().resolves({ stdout: 'relative/path\n', stderr: '' }); + + const result = await getPoetryVirtualenvsPath('/usr/bin/poetry'); + + // Should fall back to platform default cache dir + const expectedPath = path.join('/home/test', '.cache', 'pypoetry', 'virtualenvs'); + assert.strictEqual(result, expectedPath); + }); + + test('does not persist path when placeholder cannot be resolved and no platform default', async () => { + isWindowsStub.returns(false); + isMacStub.returns(false); + getUserHomeDirStub.returns(undefined); // No home dir available + + // First call: virtualenvs.path returns a path with {cache-dir} + execProcessStub.onFirstCall().resolves({ stdout: '{cache-dir}/virtualenvs\n', stderr: '' }); + // Second call: cache-dir config fails + execProcessStub.onSecondCall().rejects(new Error('Command failed')); + + const result = await getPoetryVirtualenvsPath('/usr/bin/poetry'); + + // Should fall back to platform default (which returns undefined when home is not available) + assert.strictEqual(result, undefined); + // Path should NOT be persisted when unresolved + assert.ok(!mockState.set.calledWith(POETRY_VIRTUALENVS_PATH_KEY, sinon.match.any)); + }); + + test('handles virtualenvs.path without {cache-dir} placeholder (absolute path)', async () => { + // virtualenvs.path returns an absolute path directly + execProcessStub.onFirstCall().resolves({ stdout: '/custom/virtualenvs/path\n', stderr: '' }); + + const result = await getPoetryVirtualenvsPath('/usr/bin/poetry'); + + assert.strictEqual(result, '/custom/virtualenvs/path'); + // Should be persisted + assert.ok(mockState.set.calledWith(POETRY_VIRTUALENVS_PATH_KEY, '/custom/virtualenvs/path')); + }); + + test('falls back to platform default when virtualenvs.path returns non-absolute path without placeholder', async () => { + isWindowsStub.returns(false); + isMacStub.returns(false); + getUserHomeDirStub.returns('/home/test'); + + // virtualenvs.path returns a relative path (not valid) + execProcessStub.onFirstCall().resolves({ stdout: 'relative/path\n', stderr: '' }); + + const result = await getPoetryVirtualenvsPath('/usr/bin/poetry'); + + // Should fall back to platform default + const expectedPath = path.join('/home/test', '.cache', 'pypoetry', 'virtualenvs'); + assert.strictEqual(result, expectedPath); + }); + + test('uses cached value from persistent state', async () => { + mockState.get.resolves('/cached/virtualenvs/path'); + + const result = await getPoetryVirtualenvsPath('/usr/bin/poetry'); + + assert.strictEqual(result, '/cached/virtualenvs/path'); + // Should not call exec since we have a cached value + assert.ok(!execProcessStub.called); + }); + + test('handles virtualenvs.path config command failure', async () => { + isWindowsStub.returns(false); + isMacStub.returns(false); + getUserHomeDirStub.returns('/home/test'); + + // virtualenvs.path config fails + execProcessStub.onFirstCall().rejects(new Error('Config command failed')); + + const result = await getPoetryVirtualenvsPath('/usr/bin/poetry'); + + // Should fall back to platform default + const expectedPath = path.join('/home/test', '.cache', 'pypoetry', 'virtualenvs'); + assert.strictEqual(result, expectedPath); + }); + + test('Windows: resolves {cache-dir} with platform default when cache-dir query fails', async () => { + isWindowsStub.returns(true); + process.env.LOCALAPPDATA = 'C:\\Users\\test\\AppData\\Local'; + + // First call: virtualenvs.path returns a path with {cache-dir} + execProcessStub.onFirstCall().resolves({ stdout: '{cache-dir}/virtualenvs\n', stderr: '' }); + // Second call: cache-dir config fails + execProcessStub.onSecondCall().rejects(new Error('Command failed')); + + const result = await getPoetryVirtualenvsPath('C:\\poetry\\poetry.exe'); + + const expectedPath = path.join('C:\\Users\\test\\AppData\\Local', 'pypoetry', 'Cache', 'virtualenvs'); + assert.strictEqual(result, expectedPath); + + // Cleanup + delete process.env.LOCALAPPDATA; + }); +});