Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/common/utils/platformUtils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
export function isWindows(): boolean {
return process.platform === 'win32';
}

export function isMac(): boolean {
return process.platform === 'darwin';
}
113 changes: 96 additions & 17 deletions src/managers/poetry/poetryUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,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 {
isNativeEnvInfo,
NativeEnvInfo,
Expand Down Expand Up @@ -194,14 +194,14 @@ export async function getPoetryVirtualenvsPath(poetryExe?: string): Promise<stri
if (stdout) {
const venvPath = stdout.trim();
// Poetry might return the path with placeholders like {cache-dir}
// If it doesn't start with / or C:\ etc., assume it's using default
if (!path.isAbsolute(venvPath) || venvPath.includes('{')) {
const home = getUserHomeDir();
if (home) {
poetryVirtualenvsPath = path.join(home, '.cache', 'pypoetry', 'virtualenvs');
}
} else {
// Resolve the placeholder if present
if (venvPath.includes('{cache-dir}')) {
poetryVirtualenvsPath = await resolveVirtualenvsPath(poetry, venvPath);
} else if (path.isAbsolute(venvPath)) {
poetryVirtualenvsPath = venvPath;
} else {
// Not an absolute path and no placeholder, use platform-specific default
poetryVirtualenvsPath = getDefaultPoetryVirtualenvsPath();
}

if (poetryVirtualenvsPath) {
Expand All @@ -214,10 +214,9 @@ export async function getPoetryVirtualenvsPath(poetryExe?: string): Promise<stri
}
}

// Fallback to default location
const home = getUserHomeDir();
if (home) {
poetryVirtualenvsPath = path.join(home, '.cache', 'pypoetry', 'virtualenvs');
// Fallback to platform-specific default location
poetryVirtualenvsPath = getDefaultPoetryVirtualenvsPath();
if (poetryVirtualenvsPath) {
await state.set(POETRY_VIRTUALENVS_PATH_KEY, poetryVirtualenvsPath);
return poetryVirtualenvsPath;
}
Expand All @@ -231,6 +230,86 @@ import { promisify } from 'util';

const exec = promisify(cp.exec);

/**
* Returns the default Poetry cache directory based on the current platform.
* - Windows: %LOCALAPPDATA%\pypoetry\Cache or %APPDATA%\pypoetry\Cache
* - macOS: ~/Library/Caches/pypoetry
* - Linux: ~/.cache/pypoetry
*/
export function getDefaultPoetryCacheDir(): string | undefined {
if (isWindows()) {
const localAppData = process.env.LOCALAPPDATA;
if (localAppData) {
return path.join(localAppData, 'pypoetry', 'Cache');
}
const appData = process.env.APPDATA;
if (appData) {
return path.join(appData, 'pypoetry', 'Cache');
}
return undefined;
}

const home = getUserHomeDir();
if (!home) {
return undefined;
}

if (isMac()) {
return path.join(home, 'Library', 'Caches', 'pypoetry');
}

// Linux default
return path.join(home, '.cache', 'pypoetry');
}

/**
* Returns the default Poetry virtualenvs path based on the current platform.
* - Windows: %LOCALAPPDATA%\pypoetry\Cache\virtualenvs or %APPDATA%\pypoetry\Cache\virtualenvs
* - macOS: ~/Library/Caches/pypoetry/virtualenvs
* - Linux: ~/.cache/pypoetry/virtualenvs
*/
export function getDefaultPoetryVirtualenvsPath(): string | undefined {
const cacheDir = getDefaultPoetryCacheDir();
if (cacheDir) {
return path.join(cacheDir, 'virtualenvs');
}
return undefined;
}

/**
* Resolves the {cache-dir} placeholder in a Poetry virtualenvs path.
* 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
*/
async function resolveVirtualenvsPath(poetry: string, virtualenvsPath: string): Promise<string> {
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<string | undefined> {
try {
const { stdout } = await exec(`"${poetry}" --version`);
Expand Down Expand Up @@ -273,11 +352,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) =>
Expand Down
164 changes: 162 additions & 2 deletions src/test/managers/poetry/poetryUtils.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import assert from 'node:assert';
import path from 'node:path';
import * as sinon from 'sinon';
import { isPoetryVirtualenvsInProject, nativeToPythonEnv } from '../../../managers/poetry/poetryUtils';
import * as utils from '../../../managers/common/utils';
import { EnvironmentManager, PythonEnvironment, PythonEnvironmentApi, PythonEnvironmentInfo } from '../../../api';
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,
isPoetryVirtualenvsInProject,
nativeToPythonEnv,
} from '../../../managers/poetry/poetryUtils';

suite('isPoetryVirtualenvsInProject', () => {
test('should return false when env var is not set', () => {
Expand Down Expand Up @@ -157,3 +165,155 @@ suite('nativeToPythonEnv - POETRY_VIRTUALENVS_IN_PROJECT integration', () => {
assert.strictEqual(capturedInfo!.group, undefined, 'Non-global path should not be global');
});
});

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);
});
Comment on lines +169 to +318
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New behavior was added for resolving {cache-dir} in virtualenvs.path, but the unit tests only cover the platform default path helpers. Please add coverage for the placeholder-resolution flow (e.g., virtualenvs.path contains {cache-dir}, poetry config cache-dir succeeds/fails, and ensure the result is not persisted when unresolved).

Copilot uses AI. Check for mistakes.
});
Loading