Skip to content

Commit 12a7df6

Browse files
Add VenvUv support and alwaysUseUv setting to control UV usage for virtual environments (#940)
## Overview This PR implements support for UV-created virtual environments and adds a new setting to control when UV is used for managing virtual environments, as requested in the issue. ## Changes ### 1. New Environment Kind: `VenvUv` Added `venvUv = 'Uv'` to the `NativePythonEnvironmentKind` enum to represent virtual environments created with UV. This tag comes from PET's native locator when it detects `uv_version` in the `pyvenv.cfg` file. ### 2. New Setting: `python-envs.alwaysUseUv` ```json { "python-envs.alwaysUseUv": { "type": "boolean", "default": true, "scope": "machine", "description": "When set to true, uv will be used to manage all virtual environments if available. When set to false, uv will only manage virtual environments explicitly created by uv." } } ``` ### 3. Smart UV Detection Logic Introduced a `shouldUseUv()` helper function that determines when to use UV: - **VenvUv environments**: Always use UV (if installed), regardless of the setting - **Other environments**: Use UV only if `alwaysUseUv` setting is `true` (if installed) This ensures that UV-created environments always use UV for management, while giving users control over whether UV should manage standard venvs. ### 4. Updated Operations All virtual environment and pip operations now respect the new setting: - **Environment Discovery**: `findVirtualEnvironments()` and `resolveVenvPythonEnvironmentPath()` now handle both `Venv` and `Uv` kinds - **Environment Creation**: `createWithProgress()` uses the setting to decide between `uv venv` and `python -m venv` - **Package Management**: `refreshPipPackagesRaw()` and `managePackages()` use the setting to decide between `uv pip` and `python -m pip` ## Behavior | Environment Type | alwaysUseUv = true | alwaysUseUv = false | |-----------------|-------------------|---------------------| | VenvUv (UV-created) | ✅ Use UV | ✅ Use UV | | Venv (standard) | ✅ Use UV | ❌ Don't use UV | --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: eleanorjboyd <[email protected]>
1 parent c87d304 commit 12a7df6

File tree

12 files changed

+727
-21
lines changed

12 files changed

+727
-21
lines changed

.github/instructions/testing-workflow.instructions.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,22 @@ envConfig.inspect
537537
- ❌ Tests that don't clean up mocks properly
538538
- ❌ Overly complex test setup that's hard to understand
539539

540+
## 🔄 Reviewing and Improving Existing Tests
541+
542+
### Quick Review Process
543+
544+
1. **Read test files** - Check structure and mock setup
545+
2. **Run tests** - Establish baseline functionality
546+
3. **Apply improvements** - Use patterns below
547+
4. **Verify** - Ensure tests still pass
548+
549+
### Common Fixes
550+
551+
- Over-complex mocks → Minimal mocks with only needed methods
552+
- Brittle assertions → Behavior-focused with error messages
553+
- Vague test names → Clear scenario descriptions
554+
- Missing structure → Mock → Run → Assert pattern
555+
540556
## 🧠 Agent Learnings
541557

542558
- Always use dynamic path construction with Node.js `path` module when testing functions that resolve paths against workspace folders to ensure cross-platform compatibility (1)
@@ -551,3 +567,7 @@ envConfig.inspect
551567
- When a targeted test run yields 0 tests, first verify the compiled JS exists under `out/test` (rootDir is `src`); absence almost always means the test file sits outside `src` or compilation hasn't run yet (1)
552568
- When unit tests fail with VS Code API errors like `TypeError: X is not a constructor` or `Cannot read properties of undefined (reading 'Y')`, check if VS Code APIs are properly mocked in `/src/test/unittests.ts` - add missing Task-related APIs (`Task`, `TaskScope`, `ShellExecution`, `TaskRevealKind`, `TaskPanelKind`) and namespace mocks (`tasks`) following the existing pattern of `mockedVSCode.X = vscodeMocks.vscMockExtHostedTypes.X` (1)
553569
- Create minimal mock objects with only required methods and use TypeScript type assertions (e.g., mockApi as PythonEnvironmentApi) to satisfy interface requirements instead of implementing all interface methods when only specific methods are needed for the test (1)
570+
- When reviewing existing tests, focus on behavior rather than implementation details in test names and assertions - transform "should return X when Y" into "should [expected behavior] when [scenario context]" (1)
571+
- Simplify mock setup by only mocking methods actually used in tests and use `as unknown as Type` for TypeScript compatibility (1)
572+
- When testing async functions that use child processes, call the function first to get a promise, then use setTimeout to emit mock events, then await the promise - this ensures proper timing of mock setup versus function execution (1)
573+
- Cannot stub internal function calls within the same module after import - stub external dependencies instead (e.g., stub `childProcessApis.spawnProcess` rather than trying to stub `helpers.isUvInstalled` when testing `helpers.shouldUseUv`) because intra-module calls use direct references, not module exports (1)

package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,12 @@
128128
"items": {
129129
"type": "string"
130130
}
131+
},
132+
"python-envs.alwaysUseUv": {
133+
"type": "boolean",
134+
"description": "%python-envs.alwaysUseUv.description%",
135+
"default": true,
136+
"scope": "machine"
131137
}
132138
}
133139
},

package.nls.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,5 +39,6 @@
3939
"python-envs.terminal.deactivate.title": "Deactivate Environment in Current Terminal",
4040
"python-envs.uninstallPackage.title": "Uninstall Package",
4141
"python-envs.revealProjectInExplorer.title": "Reveal Project in Explorer",
42-
"python-envs.runPetInTerminal.title": "Run Python Environment Tool (PET) in Terminal..."
42+
"python-envs.runPetInTerminal.title": "Run Python Environment Tool (PET) in Terminal...",
43+
"python-envs.alwaysUseUv.description": "When set to true, uv will be used to manage all virtual environments if available. When set to false, uv will only manage virtual environments explicitly created by uv."
4344
}

src/managers/builtin/helpers.ts

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,30 @@
1-
import * as ch from 'child_process';
21
import { CancellationError, CancellationToken, LogOutputChannel } from 'vscode';
3-
import { createDeferred } from '../../common/utils/deferred';
4-
import { sendTelemetryEvent } from '../../common/telemetry/sender';
2+
import { spawnProcess } from '../../common/childProcess.apis';
53
import { EventNames } from '../../common/telemetry/constants';
4+
import { sendTelemetryEvent } from '../../common/telemetry/sender';
5+
import { createDeferred } from '../../common/utils/deferred';
6+
import { getConfiguration } from '../../common/workspace.apis';
7+
import { getUvEnvironments } from './uvEnvironments';
8+
9+
let available = createDeferred<boolean>();
10+
11+
/**
12+
* Reset the UV installation cache.
13+
*/
14+
export function resetUvInstallationCache(): void {
15+
available = createDeferred<boolean>();
16+
}
617

7-
const available = createDeferred<boolean>();
818
export async function isUvInstalled(log?: LogOutputChannel): Promise<boolean> {
919
if (available.completed) {
1020
return available.promise;
1121
}
1222
log?.info(`Running: uv --version`);
13-
const proc = ch.spawn('uv', ['--version']);
23+
const proc = spawnProcess('uv', ['--version']);
1424
proc.on('error', () => {
1525
available.resolve(false);
1626
});
17-
proc.stdout.on('data', (d) => log?.info(d.toString()));
27+
proc.stdout?.on('data', (d) => log?.info(d.toString()));
1828
proc.on('exit', (code) => {
1929
if (code === 0) {
2030
sendTelemetryEvent(EventNames.VENV_USING_UV);
@@ -24,6 +34,31 @@ export async function isUvInstalled(log?: LogOutputChannel): Promise<boolean> {
2434
return available.promise;
2535
}
2636

37+
/**
38+
* Determines if uv should be used for managing a virtual environment.
39+
* @param log - Optional log output channel for logging operations
40+
* @param envPath - Optional environment path to check against UV environments list
41+
* @returns True if uv should be used, false otherwise. For UV environments, returns true if uv is installed. For other environments, checks the 'python-envs.alwaysUseUv' setting and uv availability.
42+
*/
43+
export async function shouldUseUv(log?: LogOutputChannel, envPath?: string): Promise<boolean> {
44+
if (envPath) {
45+
// always use uv if the given environment is stored as a uv env
46+
const uvEnvs = await getUvEnvironments();
47+
if (uvEnvs.includes(envPath)) {
48+
return await isUvInstalled(log);
49+
}
50+
}
51+
52+
// For other environments, check the user setting
53+
const config = getConfiguration('python-envs');
54+
const alwaysUseUv = config.get<boolean>('alwaysUseUv', true);
55+
56+
if (alwaysUseUv) {
57+
return await isUvInstalled(log);
58+
}
59+
return false;
60+
}
61+
2762
export async function runUV(
2863
args: string[],
2964
cwd?: string,
@@ -32,7 +67,7 @@ export async function runUV(
3267
): Promise<string> {
3368
log?.info(`Running: uv ${args.join(' ')}`);
3469
return new Promise<string>((resolve, reject) => {
35-
const proc = ch.spawn('uv', args, { cwd: cwd });
70+
const proc = spawnProcess('uv', args, { cwd: cwd });
3671
token?.onCancellationRequested(() => {
3772
proc.kill();
3873
reject(new CancellationError());
@@ -72,7 +107,7 @@ export async function runPython(
72107
): Promise<string> {
73108
log?.info(`Running: ${python} ${args.join(' ')}`);
74109
return new Promise<string>((resolve, reject) => {
75-
const proc = ch.spawn(python, args, { cwd: cwd });
110+
const proc = spawnProcess(python, args, { cwd: cwd });
76111
token?.onCancellationRequested(() => {
77112
proc.kill();
78113
reject(new CancellationError());

src/managers/builtin/pipManager.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
CancellationError,
3+
Disposable,
34
Event,
45
EventEmitter,
56
LogOutputChannel,
@@ -18,10 +19,9 @@ import {
1819
PythonEnvironment,
1920
PythonEnvironmentApi,
2021
} from '../../api';
22+
import { getWorkspacePackagesToInstall } from './pipUtils';
2123
import { managePackages, refreshPackages } from './utils';
22-
import { Disposable } from 'vscode-jsonrpc';
2324
import { VenvManager } from './venvManager';
24-
import { getWorkspacePackagesToInstall } from './pipUtils';
2525

2626
function getChanges(before: Package[], after: Package[]): { kind: PackageChangeKind; pkg: Package }[] {
2727
const changes: { kind: PackageChangeKind; pkg: Package }[] = [];

src/managers/builtin/utils.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
NativePythonFinder,
1919
} from '../common/nativePythonFinder';
2020
import { shortVersion, sortEnvironments } from '../common/utils';
21-
import { isUvInstalled, runPython, runUV } from './helpers';
21+
import { runPython, runUV, shouldUseUv } from './helpers';
2222
import { parsePipList, PipPackage } from './pipListUtils';
2323

2424
function asPackageQuickPickItem(name: string, version?: string): QuickPickItem {
@@ -139,11 +139,20 @@ export async function refreshPythons(
139139
}
140140

141141
async function refreshPipPackagesRaw(environment: PythonEnvironment, log?: LogOutputChannel): Promise<string> {
142-
const useUv = await isUvInstalled();
142+
// Use environmentPath directly for consistency with UV environment tracking
143+
const useUv = await shouldUseUv(log, environment.environmentPath.fsPath);
143144
if (useUv) {
144145
return await runUV(['pip', 'list', '--python', environment.execInfo.run.executable], undefined, log);
145146
}
146-
return await runPython(environment.execInfo.run.executable, ['-m', 'pip', 'list'], undefined, log);
147+
try {
148+
return await runPython(environment.execInfo.run.executable, ['-m', 'pip', 'list'], undefined, log);
149+
} catch (ex) {
150+
log?.error('Error running pip list', ex);
151+
log?.info(
152+
'Package list retrieval attempted using pip, action can be done with uv if installed and setting `alwaysUseUv` is enabled.',
153+
);
154+
throw ex;
155+
}
147156
}
148157

149158
export async function refreshPipPackages(
@@ -194,7 +203,8 @@ export async function managePackages(
194203
throw new Error('Python 2.* is not supported (deprecated)');
195204
}
196205

197-
const useUv = await isUvInstalled();
206+
// Use environmentPath directly for consistency with UV environment tracking
207+
const useUv = await shouldUseUv(manager.log, environment.environmentPath.fsPath);
198208
const uninstallArgs = ['pip', 'uninstall'];
199209
if (options.uninstall && options.uninstall.length > 0) {
200210
if (useUv) {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { ENVS_EXTENSION_ID } from '../../common/constants';
2+
import { getWorkspacePersistentState } from '../../common/persistentState';
3+
4+
/**
5+
* Persistent storage key for UV-managed virtual environments.
6+
*
7+
* This key is used to store a list of environment paths that were created or identified
8+
* as UV-managed virtual environments. The stored paths correspond to the
9+
* PythonEnvironmentInfo.environmentPath.fsPath values.
10+
*/
11+
export const UV_ENVS_KEY = `${ENVS_EXTENSION_ID}:uv:UV_ENVIRONMENTS`;
12+
13+
/**
14+
* @returns Array of environment paths (PythonEnvironmentInfo.environmentPath.fsPath values)
15+
* that are known to be UV-managed virtual environments
16+
*/
17+
export async function getUvEnvironments(): Promise<string[]> {
18+
const state = await getWorkspacePersistentState();
19+
return (await state.get(UV_ENVS_KEY)) ?? [];
20+
}
21+
22+
/**
23+
* @param environmentPath The environment path (should be PythonEnvironmentInfo.environmentPath.fsPath)
24+
* to mark as UV-managed. Duplicates are automatically ignored.
25+
*/
26+
export async function addUvEnvironment(environmentPath: string): Promise<void> {
27+
const state = await getWorkspacePersistentState();
28+
const uvEnvs = await getUvEnvironments();
29+
if (!uvEnvs.includes(environmentPath)) {
30+
uvEnvs.push(environmentPath);
31+
await state.set(UV_ENVS_KEY, uvEnvs);
32+
}
33+
}
34+
35+
/**
36+
* @param environmentPath The environment path (PythonEnvironmentInfo.environmentPath.fsPath)
37+
* to remove from UV tracking. No-op if path not found.
38+
*/
39+
export async function removeUvEnvironment(environmentPath: string): Promise<void> {
40+
const state = await getWorkspacePersistentState();
41+
const uvEnvs = await getUvEnvironments();
42+
const filtered = uvEnvs.filter((path) => path !== environmentPath);
43+
await state.set(UV_ENVS_KEY, filtered);
44+
}
45+
46+
/**
47+
* Clears all UV-managed environment paths from the tracking list.
48+
*/
49+
export async function clearUvEnvironments(): Promise<void> {
50+
const state = await getWorkspacePersistentState();
51+
await state.set(UV_ENVS_KEY, []);
52+
}

src/managers/builtin/venvUtils.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@ import {
2525
NativePythonFinder,
2626
} from '../common/nativePythonFinder';
2727
import { getShellActivationCommands, shortVersion, sortEnvironments } from '../common/utils';
28-
import { isUvInstalled, runPython, runUV } from './helpers';
28+
import { runPython, runUV, shouldUseUv } from './helpers';
2929
import { getProjectInstallable, PipPackages } from './pipUtils';
3030
import { resolveSystemPythonEnvironmentPath } from './utils';
31+
import { addUvEnvironment, removeUvEnvironment, UV_ENVS_KEY } from './uvEnvironments';
3132
import { createStepBasedVenvFlow } from './venvStepBasedFlow';
3233

3334
export const VENV_WORKSPACE_KEY = `${ENVS_EXTENSION_ID}:venv:WORKSPACE_SELECTED`;
@@ -54,7 +55,7 @@ export interface CreateEnvironmentResult {
5455
}
5556

5657
export async function clearVenvCache(): Promise<void> {
57-
const keys = [VENV_WORKSPACE_KEY, VENV_GLOBAL_KEY];
58+
const keys = [VENV_WORKSPACE_KEY, VENV_GLOBAL_KEY, UV_ENVS_KEY];
5859
const state = await getWorkspacePersistentState();
5960
await state.clear(keys);
6061
}
@@ -131,6 +132,10 @@ async function getPythonInfo(env: NativeEnvInfo): Promise<PythonEnvironmentInfo>
131132
const venvName = env.name ?? getName(env.executable);
132133
const sv = shortVersion(env.version);
133134
const name = `${venvName} (${sv})`;
135+
let description = undefined;
136+
if (env.kind === NativePythonEnvironmentKind.venvUv) {
137+
description = l10n.t('uv');
138+
}
134139

135140
const binDir = path.dirname(env.executable);
136141

@@ -142,7 +147,7 @@ async function getPythonInfo(env: NativeEnvInfo): Promise<PythonEnvironmentInfo>
142147
shortDisplayName: `${sv} (${venvName})`,
143148
displayPath: env.executable,
144149
version: env.version,
145-
description: undefined,
150+
description: description,
146151
tooltip: env.executable,
147152
environmentPath: Uri.file(env.executable),
148153
iconPath: new ThemeIcon('python'),
@@ -176,7 +181,7 @@ export async function findVirtualEnvironments(
176181
const envs = data
177182
.filter((e) => isNativeEnvInfo(e))
178183
.map((e) => e as NativeEnvInfo)
179-
.filter((e) => e.kind === NativePythonEnvironmentKind.venv);
184+
.filter((e) => e.kind === NativePythonEnvironmentKind.venv || e.kind === NativePythonEnvironmentKind.venvUv);
180185

181186
for (const e of envs) {
182187
if (!(e.prefix && e.executable && e.version)) {
@@ -187,6 +192,11 @@ export async function findVirtualEnvironments(
187192
const env = api.createPythonEnvironmentItem(await getPythonInfo(e), manager);
188193
collection.push(env);
189194
log.info(`Found venv environment: ${env.name}`);
195+
196+
// Track UV environments using environmentPath for consistency
197+
if (e.kind === NativePythonEnvironmentKind.venvUv) {
198+
await addUvEnvironment(env.environmentPath.fsPath);
199+
}
190200
}
191201
return collection;
192202
}
@@ -290,7 +300,7 @@ export async function createWithProgress(
290300
async () => {
291301
const result: CreateEnvironmentResult = {};
292302
try {
293-
const useUv = await isUvInstalled(log);
303+
const useUv = await shouldUseUv(log, basePython.environmentPath.fsPath);
294304
// env creation
295305
if (basePython.execInfo?.run.executable) {
296306
if (useUv) {
@@ -316,6 +326,10 @@ export async function createWithProgress(
316326
const resolved = await nativeFinder.resolve(pythonPath);
317327
const env = api.createPythonEnvironmentItem(await getPythonInfo(resolved), manager);
318328

329+
if (useUv && resolved.kind === NativePythonEnvironmentKind.venvUv) {
330+
await addUvEnvironment(env.environmentPath.fsPath);
331+
}
332+
319333
// install packages
320334
if (packages && (packages.install.length > 0 || packages.uninstall.length > 0)) {
321335
try {
@@ -435,6 +449,7 @@ export async function removeVenv(environment: PythonEnvironment, log: LogOutputC
435449
async () => {
436450
try {
437451
await fsapi.remove(envPath);
452+
await removeUvEnvironment(environment.environmentPath.fsPath);
438453
return true;
439454
} catch (e) {
440455
log.error(`Failed to remove virtual environment: ${e}`);
@@ -459,7 +474,7 @@ export async function resolveVenvPythonEnvironmentPath(
459474
): Promise<PythonEnvironment | undefined> {
460475
const resolved = await nativeFinder.resolve(fsPath);
461476

462-
if (resolved.kind === NativePythonEnvironmentKind.venv) {
477+
if (resolved.kind === NativePythonEnvironmentKind.venv || resolved.kind === NativePythonEnvironmentKind.venvUv) {
463478
const envInfo = await getPythonInfo(resolved);
464479
return api.createPythonEnvironmentItem(envInfo, manager);
465480
}

src/managers/common/nativePythonFinder.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export enum NativePythonEnvironmentKind {
6969
linuxGlobal = 'LinuxGlobal',
7070
macXCode = 'MacXCode',
7171
venv = 'Venv',
72+
venvUv = 'Uv',
7273
virtualEnv = 'VirtualEnv',
7374
virtualEnvWrapper = 'VirtualEnvWrapper',
7475
windowsStore = 'WindowsStore',

0 commit comments

Comments
 (0)