Skip to content

Commit 3c8ba72

Browse files
committed
Add platform-specific default paths for Poetry cache and virtualenvs
1 parent 7a04501 commit 3c8ba72

File tree

3 files changed

+261
-17
lines changed

3 files changed

+261
-17
lines changed

src/common/utils/platformUtils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
export function isWindows(): boolean {
22
return process.platform === 'win32';
33
}
4+
5+
export function isMac(): boolean {
6+
return process.platform === 'darwin';
7+
}

src/managers/poetry/poetryUtils.ts

Lines changed: 100 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import * as cp from 'child_process';
12
import * as fs from 'fs-extra';
23
import * as path from 'path';
4+
import { promisify } from 'util';
35
import { Uri } from 'vscode';
46
import which from 'which';
57
import { EnvironmentManager, PythonEnvironment, PythonEnvironmentApi, PythonEnvironmentInfo } from '../../api';
@@ -8,7 +10,7 @@ import { ENVS_EXTENSION_ID } from '../../common/constants';
810
import { traceError, traceInfo } from '../../common/logging';
911
import { getWorkspacePersistentState } from '../../common/persistentState';
1012
import { getUserHomeDir, untildify } from '../../common/utils/pathUtils';
11-
import { isWindows } from '../../common/utils/platformUtils';
13+
import { isMac, isWindows } from '../../common/utils/platformUtils';
1214
import { getSettingWorkspaceScope } from '../../features/settings/settingHelpers';
1315
import {
1416
isNativeEnvInfo,
@@ -19,6 +21,8 @@ import {
1921
} from '../common/nativePythonFinder';
2022
import { getShellActivationCommands, shortVersion, sortEnvironments } from '../common/utils';
2123

24+
const exec = promisify(cp.exec);
25+
2226
/**
2327
* Checks if the POETRY_VIRTUALENVS_IN_PROJECT environment variable is set to a truthy value.
2428
* When true, Poetry creates virtualenvs in the project's `.venv` directory.
@@ -214,14 +218,14 @@ export async function getPoetryVirtualenvsPath(poetryExe?: string): Promise<stri
214218
if (stdout) {
215219
const venvPath = stdout.trim();
216220
// Poetry might return the path with placeholders like {cache-dir}
217-
// If it doesn't start with / or C:\ etc., assume it's using default
218-
if (!path.isAbsolute(venvPath) || venvPath.includes('{')) {
219-
const home = getUserHomeDir();
220-
if (home) {
221-
poetryVirtualenvsPath = path.join(home, '.cache', 'pypoetry', 'virtualenvs');
222-
}
223-
} else {
221+
// Resolve the placeholder if present
222+
if (venvPath.includes('{cache-dir}')) {
223+
poetryVirtualenvsPath = await resolveVirtualenvsPath(poetry, venvPath);
224+
} else if (path.isAbsolute(venvPath)) {
224225
poetryVirtualenvsPath = venvPath;
226+
} else {
227+
// Not an absolute path and no placeholder, use platform-specific default
228+
poetryVirtualenvsPath = getDefaultPoetryVirtualenvsPath();
225229
}
226230

227231
if (poetryVirtualenvsPath) {
@@ -234,17 +238,96 @@ export async function getPoetryVirtualenvsPath(poetryExe?: string): Promise<stri
234238
}
235239
}
236240

237-
// Fallback to default location
238-
const home = getUserHomeDir();
239-
if (home) {
240-
poetryVirtualenvsPath = path.join(home, '.cache', 'pypoetry', 'virtualenvs');
241+
// Fallback to platform-specific default location
242+
poetryVirtualenvsPath = getDefaultPoetryVirtualenvsPath();
243+
if (poetryVirtualenvsPath) {
241244
await state.set(POETRY_VIRTUALENVS_PATH_KEY, poetryVirtualenvsPath);
242245
return poetryVirtualenvsPath;
243246
}
244247

245248
return undefined;
246249
}
247250

251+
/**
252+
* Returns the default Poetry cache directory based on the current platform.
253+
* - Windows: %LOCALAPPDATA%\pypoetry\Cache or %APPDATA%\pypoetry\Cache
254+
* - macOS: ~/Library/Caches/pypoetry
255+
* - Linux: ~/.cache/pypoetry
256+
*/
257+
export function getDefaultPoetryCacheDir(): string | undefined {
258+
if (isWindows()) {
259+
const localAppData = process.env.LOCALAPPDATA;
260+
if (localAppData) {
261+
return path.join(localAppData, 'pypoetry', 'Cache');
262+
}
263+
const appData = process.env.APPDATA;
264+
if (appData) {
265+
return path.join(appData, 'pypoetry', 'Cache');
266+
}
267+
return undefined;
268+
}
269+
270+
const home = getUserHomeDir();
271+
if (!home) {
272+
return undefined;
273+
}
274+
275+
if (isMac()) {
276+
return path.join(home, 'Library', 'Caches', 'pypoetry');
277+
}
278+
279+
// Linux default
280+
return path.join(home, '.cache', 'pypoetry');
281+
}
282+
283+
/**
284+
* Returns the default Poetry virtualenvs path based on the current platform.
285+
* - Windows: %LOCALAPPDATA%\pypoetry\Cache\virtualenvs or %APPDATA%\pypoetry\Cache\virtualenvs
286+
* - macOS: ~/Library/Caches/pypoetry/virtualenvs
287+
* - Linux: ~/.cache/pypoetry/virtualenvs
288+
*/
289+
export function getDefaultPoetryVirtualenvsPath(): string | undefined {
290+
const cacheDir = getDefaultPoetryCacheDir();
291+
if (cacheDir) {
292+
return path.join(cacheDir, 'virtualenvs');
293+
}
294+
return undefined;
295+
}
296+
297+
/**
298+
* Resolves the {cache-dir} placeholder in a Poetry virtualenvs path.
299+
* First tries to query Poetry's cache-dir config, then falls back to platform-specific default.
300+
* @param poetry Path to the poetry executable
301+
* @param virtualenvsPath The path possibly containing {cache-dir} placeholder
302+
*/
303+
async function resolveVirtualenvsPath(poetry: string, virtualenvsPath: string): Promise<string> {
304+
if (!virtualenvsPath.includes('{cache-dir}')) {
305+
return virtualenvsPath;
306+
}
307+
308+
// Try to get the actual cache-dir from Poetry
309+
try {
310+
const { stdout } = await exec(`"${poetry}" config cache-dir`);
311+
if (stdout) {
312+
const cacheDir = stdout.trim();
313+
if (cacheDir && path.isAbsolute(cacheDir)) {
314+
return virtualenvsPath.replace('{cache-dir}', cacheDir);
315+
}
316+
}
317+
} catch (e) {
318+
traceError(`Error getting Poetry cache-dir config: ${e}`);
319+
}
320+
321+
// Fall back to platform-specific default cache dir
322+
const defaultCacheDir = getDefaultPoetryCacheDir();
323+
if (defaultCacheDir) {
324+
return virtualenvsPath.replace('{cache-dir}', defaultCacheDir);
325+
}
326+
327+
// Last resort: return the original path (will likely not be valid)
328+
return virtualenvsPath;
329+
}
330+
248331
export async function getPoetryVersion(poetry: string): Promise<string | undefined> {
249332
try {
250333
const { stdout } = await execProcess(`"${poetry}" --version`);
@@ -287,11 +370,11 @@ export async function nativeToPythonEnv(
287370
const normalizedVirtualenvsPath = path.normalize(virtualenvsPath);
288371
isGlobalPoetryEnv = normalizedPrefix.startsWith(normalizedVirtualenvsPath);
289372
} else {
290-
// Fall back to checking the default location if we haven't cached the path yet
291-
const homeDir = getUserHomeDir();
292-
if (homeDir) {
293-
const defaultPath = path.normalize(path.join(homeDir, '.cache', 'pypoetry', 'virtualenvs'));
294-
isGlobalPoetryEnv = normalizedPrefix.startsWith(defaultPath);
373+
// Fall back to checking the platform-specific default location if we haven't cached the path yet
374+
const defaultPath = getDefaultPoetryVirtualenvsPath();
375+
if (defaultPath) {
376+
const normalizedDefaultPath = path.normalize(defaultPath);
377+
isGlobalPoetryEnv = normalizedPrefix.startsWith(normalizedDefaultPath);
295378

296379
// Try to get the actual path asynchronously for next time
297380
getPoetryVirtualenvsPath(_poetry).catch((e) =>

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

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import assert from 'node:assert';
2+
import path from 'node:path';
23
import * as sinon from 'sinon';
34
import { EnvironmentManager, PythonEnvironment, PythonEnvironmentApi, PythonEnvironmentInfo } from '../../../api';
45
import * as childProcessApis from '../../../common/childProcess.apis';
6+
import * as pathUtils from '../../../common/utils/pathUtils';
7+
import * as platformUtils from '../../../common/utils/platformUtils';
58
import { NativeEnvInfo } from '../../../managers/common/nativePythonFinder';
69
import * as utils from '../../../managers/common/utils';
710
import {
11+
getDefaultPoetryCacheDir,
12+
getDefaultPoetryVirtualenvsPath,
813
getPoetryVersion,
914
isPoetryVirtualenvsInProject,
1015
nativeToPythonEnv,
@@ -208,3 +213,155 @@ suite('getPoetryVersion - childProcess.apis mocking pattern', () => {
208213
assert.strictEqual(version, undefined);
209214
});
210215
});
216+
217+
suite('getDefaultPoetryCacheDir', () => {
218+
let isWindowsStub: sinon.SinonStub;
219+
let isMacStub: sinon.SinonStub;
220+
let getUserHomeDirStub: sinon.SinonStub;
221+
let originalLocalAppData: string | undefined;
222+
let originalAppData: string | undefined;
223+
224+
setup(() => {
225+
isWindowsStub = sinon.stub(platformUtils, 'isWindows');
226+
isMacStub = sinon.stub(platformUtils, 'isMac');
227+
getUserHomeDirStub = sinon.stub(pathUtils, 'getUserHomeDir');
228+
229+
// Save original env vars
230+
originalLocalAppData = process.env.LOCALAPPDATA;
231+
originalAppData = process.env.APPDATA;
232+
});
233+
234+
teardown(() => {
235+
sinon.restore();
236+
// Restore original env vars
237+
if (originalLocalAppData === undefined) {
238+
delete process.env.LOCALAPPDATA;
239+
} else {
240+
process.env.LOCALAPPDATA = originalLocalAppData;
241+
}
242+
if (originalAppData === undefined) {
243+
delete process.env.APPDATA;
244+
} else {
245+
process.env.APPDATA = originalAppData;
246+
}
247+
});
248+
249+
test('Windows: uses LOCALAPPDATA when available', () => {
250+
isWindowsStub.returns(true);
251+
process.env.LOCALAPPDATA = 'C:\\Users\\test\\AppData\\Local';
252+
253+
const result = getDefaultPoetryCacheDir();
254+
255+
assert.strictEqual(result, path.join('C:\\Users\\test\\AppData\\Local', 'pypoetry', 'Cache'));
256+
});
257+
258+
test('Windows: falls back to APPDATA when LOCALAPPDATA is not set', () => {
259+
isWindowsStub.returns(true);
260+
delete process.env.LOCALAPPDATA;
261+
process.env.APPDATA = 'C:\\Users\\test\\AppData\\Roaming';
262+
263+
const result = getDefaultPoetryCacheDir();
264+
265+
assert.strictEqual(result, path.join('C:\\Users\\test\\AppData\\Roaming', 'pypoetry', 'Cache'));
266+
});
267+
268+
test('Windows: returns undefined when neither LOCALAPPDATA nor APPDATA is set', () => {
269+
isWindowsStub.returns(true);
270+
delete process.env.LOCALAPPDATA;
271+
delete process.env.APPDATA;
272+
273+
const result = getDefaultPoetryCacheDir();
274+
275+
assert.strictEqual(result, undefined);
276+
});
277+
278+
test('macOS: uses ~/Library/Caches/pypoetry', () => {
279+
isWindowsStub.returns(false);
280+
isMacStub.returns(true);
281+
getUserHomeDirStub.returns('/Users/test');
282+
283+
const result = getDefaultPoetryCacheDir();
284+
285+
assert.strictEqual(result, path.join('/Users/test', 'Library', 'Caches', 'pypoetry'));
286+
});
287+
288+
test('Linux: uses ~/.cache/pypoetry', () => {
289+
isWindowsStub.returns(false);
290+
isMacStub.returns(false);
291+
getUserHomeDirStub.returns('/home/test');
292+
293+
const result = getDefaultPoetryCacheDir();
294+
295+
assert.strictEqual(result, path.join('/home/test', '.cache', 'pypoetry'));
296+
});
297+
298+
test('returns undefined when home directory is not available (non-Windows)', () => {
299+
isWindowsStub.returns(false);
300+
getUserHomeDirStub.returns(undefined);
301+
302+
const result = getDefaultPoetryCacheDir();
303+
304+
assert.strictEqual(result, undefined);
305+
});
306+
});
307+
308+
suite('getDefaultPoetryVirtualenvsPath', () => {
309+
let isWindowsStub: sinon.SinonStub;
310+
let isMacStub: sinon.SinonStub;
311+
let getUserHomeDirStub: sinon.SinonStub;
312+
let originalLocalAppData: string | undefined;
313+
314+
setup(() => {
315+
isWindowsStub = sinon.stub(platformUtils, 'isWindows');
316+
isMacStub = sinon.stub(platformUtils, 'isMac');
317+
getUserHomeDirStub = sinon.stub(pathUtils, 'getUserHomeDir');
318+
originalLocalAppData = process.env.LOCALAPPDATA;
319+
});
320+
321+
teardown(() => {
322+
sinon.restore();
323+
if (originalLocalAppData === undefined) {
324+
delete process.env.LOCALAPPDATA;
325+
} else {
326+
process.env.LOCALAPPDATA = originalLocalAppData;
327+
}
328+
});
329+
330+
test('appends virtualenvs to cache directory', () => {
331+
isWindowsStub.returns(false);
332+
isMacStub.returns(false);
333+
getUserHomeDirStub.returns('/home/test');
334+
335+
const result = getDefaultPoetryVirtualenvsPath();
336+
337+
assert.strictEqual(result, path.join('/home/test', '.cache', 'pypoetry', 'virtualenvs'));
338+
});
339+
340+
test('Windows: returns correct virtualenvs path', () => {
341+
isWindowsStub.returns(true);
342+
process.env.LOCALAPPDATA = 'C:\\Users\\test\\AppData\\Local';
343+
344+
const result = getDefaultPoetryVirtualenvsPath();
345+
346+
assert.strictEqual(result, path.join('C:\\Users\\test\\AppData\\Local', 'pypoetry', 'Cache', 'virtualenvs'));
347+
});
348+
349+
test('macOS: returns correct virtualenvs path', () => {
350+
isWindowsStub.returns(false);
351+
isMacStub.returns(true);
352+
getUserHomeDirStub.returns('/Users/test');
353+
354+
const result = getDefaultPoetryVirtualenvsPath();
355+
356+
assert.strictEqual(result, path.join('/Users/test', 'Library', 'Caches', 'pypoetry', 'virtualenvs'));
357+
});
358+
359+
test('returns undefined when cache dir is not available', () => {
360+
isWindowsStub.returns(false);
361+
getUserHomeDirStub.returns(undefined);
362+
363+
const result = getDefaultPoetryVirtualenvsPath();
364+
365+
assert.strictEqual(result, undefined);
366+
});
367+
});

0 commit comments

Comments
 (0)