Skip to content

Commit bb95db5

Browse files
committed
feat: add WORKON_HOME and XDG_DATA_HOME support for pipenv (Fixes #1185)
1 parent 33e8098 commit bb95db5

File tree

2 files changed

+233
-0
lines changed

2 files changed

+233
-0
lines changed

src/managers/pipenv/pipenvUtils.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Utility functions for Pipenv environment management
22

3+
import * as nativeFs from 'fs';
34
import * as fs from 'fs-extra';
5+
import * as os from 'os';
46
import * as path from 'path';
57
import { Uri } from 'vscode';
68
import which from 'which';
@@ -285,3 +287,59 @@ export async function setPipenvForWorkspaces(fsPath: string[], envPath: string |
285287
});
286288
await state.set(PIPENV_WORKSPACE_KEY, data);
287289
}
290+
291+
/**
292+
* Get the directories where pipenv virtualenvs may be stored.
293+
*
294+
* Pipenv can store virtualenvs in multiple locations with this priority:
295+
* 1. WORKON_HOME (if set) - commonly shared with virtualenvwrapper
296+
* 2. XDG_DATA_HOME/virtualenvs (Linux, if XDG_DATA_HOME is set)
297+
* 3. ~/.local/share/virtualenvs (Linux/macOS default)
298+
* 4. ~/.virtualenvs (Windows default)
299+
*
300+
* @returns Array of existing virtualenv directories
301+
*/
302+
export function getPipenvVirtualenvDirs(): string[] {
303+
const dirs: string[] = [];
304+
305+
// WORKON_HOME takes precedence (shared with virtualenvwrapper)
306+
const workonHome = process.env.WORKON_HOME;
307+
if (workonHome) {
308+
const resolved = untildify(workonHome);
309+
if (nativeFs.existsSync(resolved)) {
310+
dirs.push(resolved);
311+
traceVerbose(`Pipenv: WORKON_HOME found at ${resolved}`);
312+
} else {
313+
traceVerbose(`Pipenv: WORKON_HOME set but does not exist: ${resolved}`);
314+
}
315+
}
316+
317+
// XDG_DATA_HOME/virtualenvs (primarily Linux, but check on all platforms)
318+
const xdgDataHome = process.env.XDG_DATA_HOME;
319+
if (xdgDataHome) {
320+
const xdgVenvs = path.join(untildify(xdgDataHome), 'virtualenvs');
321+
if (nativeFs.existsSync(xdgVenvs) && !dirs.includes(xdgVenvs)) {
322+
dirs.push(xdgVenvs);
323+
traceVerbose(`Pipenv: XDG_DATA_HOME/virtualenvs found at ${xdgVenvs}`);
324+
}
325+
}
326+
327+
// Platform-specific defaults
328+
if (process.platform === 'linux' || process.platform === 'darwin') {
329+
// Linux/macOS: ~/.local/share/virtualenvs
330+
const defaultUnix = path.join(os.homedir(), '.local', 'share', 'virtualenvs');
331+
if (nativeFs.existsSync(defaultUnix) && !dirs.includes(defaultUnix)) {
332+
dirs.push(defaultUnix);
333+
traceVerbose(`Pipenv: Platform default found at ${defaultUnix}`);
334+
}
335+
} else if (process.platform === 'win32') {
336+
// Windows: ~/.virtualenvs
337+
const defaultWin = path.join(os.homedir(), '.virtualenvs');
338+
if (nativeFs.existsSync(defaultWin) && !dirs.includes(defaultWin)) {
339+
dirs.push(defaultWin);
340+
traceVerbose(`Pipenv: Platform default found at ${defaultWin}`);
341+
}
342+
}
343+
344+
return dirs;
345+
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import assert from 'assert';
2+
import * as fs from 'fs';
3+
import * as os from 'os';
4+
import * as path from 'path';
5+
import { getPipenvVirtualenvDirs } from '../../../managers/pipenv/pipenvUtils';
6+
7+
/**
8+
* Tests for getPipenvVirtualenvDirs.
9+
*
10+
* The function should return directories where pipenv virtualenvs are stored,
11+
* checking these locations in priority order:
12+
* 1. WORKON_HOME (if set and exists)
13+
* 2. XDG_DATA_HOME/virtualenvs (if XDG_DATA_HOME is set and path exists)
14+
* 3. ~/.local/share/virtualenvs (Linux/macOS default)
15+
* 4. ~/.virtualenvs (Windows default)
16+
*
17+
* These tests use real temp directories for filesystem operations since
18+
* native fs.existsSync cannot be stubbed (non-configurable property).
19+
*/
20+
suite('Pipenv Utils - getPipenvVirtualenvDirs', () => {
21+
let originalEnv: NodeJS.ProcessEnv;
22+
let tempDir: string;
23+
24+
setup(() => {
25+
// Save original env
26+
originalEnv = { ...process.env };
27+
28+
// Clear relevant env vars
29+
delete process.env.WORKON_HOME;
30+
delete process.env.XDG_DATA_HOME;
31+
32+
// Create a temp directory for tests
33+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pipenv-test-'));
34+
});
35+
36+
teardown(() => {
37+
// Restore original env
38+
process.env = originalEnv;
39+
40+
// Clean up temp directory
41+
if (tempDir && fs.existsSync(tempDir)) {
42+
fs.rmSync(tempDir, { recursive: true, force: true });
43+
}
44+
});
45+
46+
test('Returns WORKON_HOME when set and exists', () => {
47+
const workonPath = path.join(tempDir, 'workon_home');
48+
fs.mkdirSync(workonPath);
49+
process.env.WORKON_HOME = workonPath;
50+
51+
const dirs = getPipenvVirtualenvDirs();
52+
53+
assert.ok(dirs.includes(workonPath), 'WORKON_HOME should be included');
54+
});
55+
56+
test('Ignores WORKON_HOME when set but does not exist', () => {
57+
const workonPath = path.join(tempDir, 'nonexistent_workon');
58+
// Don't create the directory
59+
process.env.WORKON_HOME = workonPath;
60+
61+
const dirs = getPipenvVirtualenvDirs();
62+
63+
assert.ok(!dirs.includes(workonPath), 'Non-existent WORKON_HOME should not be included');
64+
});
65+
66+
test('Returns XDG_DATA_HOME/virtualenvs when set and exists', () => {
67+
const xdgBase = path.join(tempDir, 'xdg_data');
68+
const xdgVenvs = path.join(xdgBase, 'virtualenvs');
69+
fs.mkdirSync(xdgBase);
70+
fs.mkdirSync(xdgVenvs);
71+
process.env.XDG_DATA_HOME = xdgBase;
72+
73+
const dirs = getPipenvVirtualenvDirs();
74+
75+
assert.ok(dirs.includes(xdgVenvs), 'XDG_DATA_HOME/virtualenvs should be included');
76+
});
77+
78+
test('Ignores XDG_DATA_HOME when virtualenvs subdir does not exist', () => {
79+
const xdgBase = path.join(tempDir, 'xdg_data_novenvs');
80+
fs.mkdirSync(xdgBase);
81+
// Don't create virtualenvs subdir
82+
process.env.XDG_DATA_HOME = xdgBase;
83+
84+
const dirs = getPipenvVirtualenvDirs();
85+
86+
const xdgVenvs = path.join(xdgBase, 'virtualenvs');
87+
assert.ok(!dirs.includes(xdgVenvs), 'Non-existent XDG_DATA_HOME/virtualenvs should not be included');
88+
});
89+
90+
test('WORKON_HOME takes precedence and appears first', () => {
91+
const workonPath = path.join(tempDir, 'workon');
92+
const xdgBase = path.join(tempDir, 'xdg');
93+
const xdgVenvs = path.join(xdgBase, 'virtualenvs');
94+
95+
fs.mkdirSync(workonPath);
96+
fs.mkdirSync(xdgBase);
97+
fs.mkdirSync(xdgVenvs);
98+
99+
process.env.WORKON_HOME = workonPath;
100+
process.env.XDG_DATA_HOME = xdgBase;
101+
102+
const dirs = getPipenvVirtualenvDirs();
103+
104+
assert.strictEqual(dirs[0], workonPath, 'WORKON_HOME should be first');
105+
assert.ok(dirs.includes(xdgVenvs), 'XDG path should also be included');
106+
});
107+
108+
test('Does not include duplicate paths', () => {
109+
// This test only makes sense on non-Windows platforms where
110+
// XDG_DATA_HOME/virtualenvs might match the default path
111+
if (process.platform === 'win32') {
112+
return;
113+
}
114+
115+
// Create a unique path that will be used for both XDG and checked for duplicates
116+
const venvBase = path.join(tempDir, 'unique_venvs');
117+
const virtualenvsPath = path.join(venvBase, 'virtualenvs');
118+
fs.mkdirSync(venvBase);
119+
fs.mkdirSync(virtualenvsPath);
120+
121+
// Set XDG_DATA_HOME to the same base
122+
process.env.XDG_DATA_HOME = venvBase;
123+
124+
const dirs = getPipenvVirtualenvDirs();
125+
126+
// Count occurrences of the path
127+
const count = dirs.filter((d) => d === virtualenvsPath).length;
128+
assert.strictEqual(count, 1, 'Path should not be duplicated');
129+
});
130+
131+
test('Returns multiple directories when all exist', () => {
132+
const workonPath = path.join(tempDir, 'workon_multi');
133+
const xdgBase = path.join(tempDir, 'xdg_multi');
134+
const xdgPath = path.join(xdgBase, 'virtualenvs');
135+
136+
fs.mkdirSync(workonPath);
137+
fs.mkdirSync(xdgBase);
138+
fs.mkdirSync(xdgPath);
139+
140+
process.env.WORKON_HOME = workonPath;
141+
process.env.XDG_DATA_HOME = xdgBase;
142+
143+
const dirs = getPipenvVirtualenvDirs();
144+
145+
assert.ok(dirs.length >= 2, 'Should return at least two directories');
146+
assert.strictEqual(dirs[0], workonPath, 'WORKON_HOME should be first');
147+
assert.ok(dirs.includes(xdgPath), 'XDG path should be included');
148+
});
149+
150+
test('Handles tilde expansion in WORKON_HOME', () => {
151+
// Create the target directory in user's home
152+
const customVenvsName = `.pipenv-test-tilde-${Date.now()}`;
153+
const expandedPath = path.join(os.homedir(), customVenvsName);
154+
let created = false;
155+
156+
try {
157+
fs.mkdirSync(expandedPath);
158+
created = true;
159+
// Use path.sep for cross-platform compatibility
160+
process.env.WORKON_HOME = `~${path.sep}${customVenvsName}`;
161+
162+
const dirs = getPipenvVirtualenvDirs();
163+
164+
// Normalize paths for comparison since untildify might produce different path formats
165+
const normalizedDirs = dirs.map((d) => path.normalize(d));
166+
const normalizedExpected = path.normalize(expandedPath);
167+
assert.ok(normalizedDirs.includes(normalizedExpected), 'Tilde-expanded path should be included');
168+
} finally {
169+
// Clean up - only if directory was successfully created
170+
if (created && fs.existsSync(expandedPath)) {
171+
fs.rmSync(expandedPath, { recursive: true, force: true });
172+
}
173+
}
174+
});
175+
});

0 commit comments

Comments
 (0)