Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
6a5aad4
Initial plan
Copilot Oct 14, 2025
5084351
Add VenvUv support and alwaysUseUv setting
Copilot Oct 14, 2025
cbaf95e
Add unit tests for shouldUseUv function
Copilot Oct 14, 2025
7398ade
Improve LogOutputChannel mock completeness in tests
Copilot Oct 14, 2025
39345db
updates
eleanorjboyd Oct 14, 2025
56a9cad
updates
eleanorjboyd Oct 16, 2025
20c6c09
uv tracking logic
eleanorjboyd Oct 16, 2025
11bfc6e
updates to instructions
eleanorjboyd Oct 16, 2025
3a8220b
fix tests
eleanorjboyd Oct 17, 2025
e7083cf
fix tests
eleanorjboyd Oct 17, 2025
ea1bca3
update with learnings
eleanorjboyd Oct 17, 2025
0d6d5ae
Merge branch 'main' into copilot/add-toggle-for-uv-venvs
eleanorjboyd Oct 17, 2025
a081af8
reset uv cache
eleanorjboyd Oct 17, 2025
964528a
another
eleanorjboyd Oct 17, 2025
3e63004
inspect vs
eleanorjboyd Oct 17, 2025
6eb134d
ugh
eleanorjboyd Oct 17, 2025
f5bf7b1
part 10
eleanorjboyd Oct 17, 2025
d4c047b
this
eleanorjboyd Oct 17, 2025
07385a4
help mock
eleanorjboyd Oct 17, 2025
66f599b
fix mock
eleanorjboyd Oct 17, 2025
e7f6995
update mocking
eleanorjboyd Oct 17, 2025
6837713
whatever
eleanorjboyd Oct 17, 2025
6989f40
switch to child process mock
eleanorjboyd Oct 17, 2025
74a408b
update learning
eleanorjboyd Oct 17, 2025
bbb6a07
Merge branch 'main' into copilot/add-toggle-for-uv-venvs
eleanorjboyd Oct 17, 2025
b71a68e
update from comments
eleanorjboyd Oct 21, 2025
ebaa3b4
switch to `uv` from `venvuv`
eleanorjboyd Oct 27, 2025
d16766f
Merge branch 'main' into copilot/add-toggle-for-uv-venvs
eleanorjboyd Oct 27, 2025
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
20 changes: 20 additions & 0 deletions .github/instructions/testing-workflow.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,22 @@ envConfig.inspect
- ❌ Tests that don't clean up mocks properly
- ❌ Overly complex test setup that's hard to understand

## 🔄 Reviewing and Improving Existing Tests

### Quick Review Process

1. **Read test files** - Check structure and mock setup
2. **Run tests** - Establish baseline functionality
3. **Apply improvements** - Use patterns below
4. **Verify** - Ensure tests still pass

### Common Fixes

- Over-complex mocks → Minimal mocks with only needed methods
- Brittle assertions → Behavior-focused with error messages
- Vague test names → Clear scenario descriptions
- Missing structure → Mock → Run → Assert pattern

## 🧠 Agent Learnings

- 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)
Expand All @@ -551,3 +567,7 @@ envConfig.inspect
- 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)
- 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)
- 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)
- 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)
- Simplify mock setup by only mocking methods actually used in tests and use `as unknown as Type` for TypeScript compatibility (1)
- 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)
- 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)
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,12 @@
"items": {
"type": "string"
}
},
"python-envs.alwaysUseUv": {
"type": "boolean",
"description": "%python-envs.alwaysUseUv.description%",
"default": true,
"scope": "machine"
}
}
},
Expand Down
3 changes: 2 additions & 1 deletion package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,6 @@
"python-envs.terminal.deactivate.title": "Deactivate Environment in Current Terminal",
"python-envs.uninstallPackage.title": "Uninstall Package",
"python-envs.revealProjectInExplorer.title": "Reveal Project in Explorer",
"python-envs.runPetInTerminal.title": "Run Python Environment Tool (PET) in Terminal..."
"python-envs.runPetInTerminal.title": "Run Python Environment Tool (PET) in Terminal...",
"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."
}
51 changes: 43 additions & 8 deletions src/managers/builtin/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,30 @@
import * as ch from 'child_process';
import { CancellationError, CancellationToken, LogOutputChannel } from 'vscode';
import { createDeferred } from '../../common/utils/deferred';
import { sendTelemetryEvent } from '../../common/telemetry/sender';
import { spawnProcess } from '../../common/childProcess.apis';
import { EventNames } from '../../common/telemetry/constants';
import { sendTelemetryEvent } from '../../common/telemetry/sender';
import { createDeferred } from '../../common/utils/deferred';
import { getConfiguration } from '../../common/workspace.apis';
import { getUvEnvironments } from './uvEnvironments';

let available = createDeferred<boolean>();

/**
* Reset the UV installation cache.
*/
export function resetUvInstallationCache(): void {
available = createDeferred<boolean>();
}

const available = createDeferred<boolean>();
export async function isUvInstalled(log?: LogOutputChannel): Promise<boolean> {
if (available.completed) {
return available.promise;
}
log?.info(`Running: uv --version`);
const proc = ch.spawn('uv', ['--version']);
const proc = spawnProcess('uv', ['--version']);
proc.on('error', () => {
available.resolve(false);
});
proc.stdout.on('data', (d) => log?.info(d.toString()));
proc.stdout?.on('data', (d) => log?.info(d.toString()));
proc.on('exit', (code) => {
if (code === 0) {
sendTelemetryEvent(EventNames.VENV_USING_UV);
Expand All @@ -24,6 +34,31 @@ export async function isUvInstalled(log?: LogOutputChannel): Promise<boolean> {
return available.promise;
}

/**
* Determines if uv should be used for managing a virtual environment.
* @param log - Optional log output channel for logging operations
* @param envPath - Optional environment path to check against UV environments list
* @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.
*/
export async function shouldUseUv(log?: LogOutputChannel, envPath?: string): Promise<boolean> {
if (envPath) {
// always use uv if the given environment is stored as a uv env
const uvEnvs = await getUvEnvironments();
if (uvEnvs.includes(envPath)) {
return await isUvInstalled(log);
}
}

// For other environments, check the user setting
const config = getConfiguration('python-envs');
const alwaysUseUv = config.get<boolean>('alwaysUseUv', true);

if (alwaysUseUv) {
return await isUvInstalled(log);
}
return false;
}

export async function runUV(
args: string[],
cwd?: string,
Expand All @@ -32,7 +67,7 @@ export async function runUV(
): Promise<string> {
log?.info(`Running: uv ${args.join(' ')}`);
return new Promise<string>((resolve, reject) => {
const proc = ch.spawn('uv', args, { cwd: cwd });
const proc = spawnProcess('uv', args, { cwd: cwd });
token?.onCancellationRequested(() => {
proc.kill();
reject(new CancellationError());
Expand Down Expand Up @@ -67,7 +102,7 @@ export async function runPython(
): Promise<string> {
log?.info(`Running: ${python} ${args.join(' ')}`);
return new Promise<string>((resolve, reject) => {
const proc = ch.spawn(python, args, { cwd: cwd });
const proc = spawnProcess(python, args, { cwd: cwd });
token?.onCancellationRequested(() => {
proc.kill();
reject(new CancellationError());
Expand Down
4 changes: 2 additions & 2 deletions src/managers/builtin/pipManager.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
CancellationError,
Disposable,
Event,
EventEmitter,
LogOutputChannel,
Expand All @@ -18,10 +19,9 @@ import {
PythonEnvironment,
PythonEnvironmentApi,
} from '../../api';
import { getWorkspacePackagesToInstall } from './pipUtils';
import { managePackages, refreshPackages } from './utils';
import { Disposable } from 'vscode-jsonrpc';
import { VenvManager } from './venvManager';
import { getWorkspacePackagesToInstall } from './pipUtils';

function getChanges(before: Package[], after: Package[]): { kind: PackageChangeKind; pkg: Package }[] {
const changes: { kind: PackageChangeKind; pkg: Package }[] = [];
Expand Down
18 changes: 14 additions & 4 deletions src/managers/builtin/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
NativePythonFinder,
} from '../common/nativePythonFinder';
import { shortVersion, sortEnvironments } from '../common/utils';
import { isUvInstalled, runPython, runUV } from './helpers';
import { runPython, runUV, shouldUseUv } from './helpers';
import { parsePipList, PipPackage } from './pipListUtils';

function asPackageQuickPickItem(name: string, version?: string): QuickPickItem {
Expand Down Expand Up @@ -139,11 +139,20 @@ export async function refreshPythons(
}

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

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

const useUv = await isUvInstalled();
// Use environmentPath directly for consistency with UV environment tracking
const useUv = await shouldUseUv(manager.log, environment.environmentPath.fsPath);
const uninstallArgs = ['pip', 'uninstall'];
if (options.uninstall && options.uninstall.length > 0) {
if (useUv) {
Expand Down
52 changes: 52 additions & 0 deletions src/managers/builtin/uvEnvironments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { ENVS_EXTENSION_ID } from '../../common/constants';
import { getWorkspacePersistentState } from '../../common/persistentState';

/**
* Persistent storage key for UV-managed virtual environments.
*
* This key is used to store a list of environment paths that were created or identified
* as UV-managed virtual environments. The stored paths correspond to the
* PythonEnvironmentInfo.environmentPath.fsPath values.
*/
export const UV_ENVS_KEY = `${ENVS_EXTENSION_ID}:uv:UV_ENVIRONMENTS`;

/**
* @returns Array of environment paths (PythonEnvironmentInfo.environmentPath.fsPath values)
* that are known to be UV-managed virtual environments
*/
export async function getUvEnvironments(): Promise<string[]> {
const state = await getWorkspacePersistentState();
return (await state.get(UV_ENVS_KEY)) ?? [];
}

/**
* @param environmentPath The environment path (should be PythonEnvironmentInfo.environmentPath.fsPath)
* to mark as UV-managed. Duplicates are automatically ignored.
*/
export async function addUvEnvironment(environmentPath: string): Promise<void> {
const state = await getWorkspacePersistentState();
const uvEnvs = await getUvEnvironments();
if (!uvEnvs.includes(environmentPath)) {
uvEnvs.push(environmentPath);
await state.set(UV_ENVS_KEY, uvEnvs);
}
}

/**
* @param environmentPath The environment path (PythonEnvironmentInfo.environmentPath.fsPath)
* to remove from UV tracking. No-op if path not found.
*/
export async function removeUvEnvironment(environmentPath: string): Promise<void> {
const state = await getWorkspacePersistentState();
const uvEnvs = await getUvEnvironments();
const filtered = uvEnvs.filter((path) => path !== environmentPath);
await state.set(UV_ENVS_KEY, filtered);
}

/**
* Clears all UV-managed environment paths from the tracking list.
*/
export async function clearUvEnvironments(): Promise<void> {
const state = await getWorkspacePersistentState();
await state.set(UV_ENVS_KEY, []);
}
27 changes: 21 additions & 6 deletions src/managers/builtin/venvUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ import {
NativePythonFinder,
} from '../common/nativePythonFinder';
import { getShellActivationCommands, shortVersion, sortEnvironments } from '../common/utils';
import { isUvInstalled, runPython, runUV } from './helpers';
import { runPython, runUV, shouldUseUv } from './helpers';
import { getProjectInstallable, PipPackages } from './pipUtils';
import { resolveSystemPythonEnvironmentPath } from './utils';
import { addUvEnvironment, removeUvEnvironment, UV_ENVS_KEY } from './uvEnvironments';
import { createStepBasedVenvFlow } from './venvStepBasedFlow';

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

export async function clearVenvCache(): Promise<void> {
const keys = [VENV_WORKSPACE_KEY, VENV_GLOBAL_KEY];
const keys = [VENV_WORKSPACE_KEY, VENV_GLOBAL_KEY, UV_ENVS_KEY];
const state = await getWorkspacePersistentState();
await state.clear(keys);
}
Expand Down Expand Up @@ -131,6 +132,10 @@ async function getPythonInfo(env: NativeEnvInfo): Promise<PythonEnvironmentInfo>
const venvName = env.name ?? getName(env.executable);
const sv = shortVersion(env.version);
const name = `${venvName} (${sv})`;
let description = undefined;
if (env.kind === NativePythonEnvironmentKind.venvUv) {
description = l10n.t('uv');
}

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

Expand All @@ -142,7 +147,7 @@ async function getPythonInfo(env: NativeEnvInfo): Promise<PythonEnvironmentInfo>
shortDisplayName: `${sv} (${venvName})`,
displayPath: env.executable,
version: env.version,
description: undefined,
description: description,
tooltip: env.executable,
environmentPath: Uri.file(env.executable),
iconPath: new ThemeIcon('python'),
Expand Down Expand Up @@ -176,7 +181,7 @@ export async function findVirtualEnvironments(
const envs = data
.filter((e) => isNativeEnvInfo(e))
.map((e) => e as NativeEnvInfo)
.filter((e) => e.kind === NativePythonEnvironmentKind.venv);
.filter((e) => e.kind === NativePythonEnvironmentKind.venv || e.kind === NativePythonEnvironmentKind.venvUv);

for (const e of envs) {
if (!(e.prefix && e.executable && e.version)) {
Expand All @@ -187,6 +192,11 @@ export async function findVirtualEnvironments(
const env = api.createPythonEnvironmentItem(await getPythonInfo(e), manager);
collection.push(env);
log.info(`Found venv environment: ${env.name}`);

// Track UV environments using environmentPath for consistency
if (e.kind === NativePythonEnvironmentKind.venvUv) {
await addUvEnvironment(env.environmentPath.fsPath);
}
}
return collection;
}
Expand Down Expand Up @@ -290,7 +300,7 @@ export async function createWithProgress(
async () => {
const result: CreateEnvironmentResult = {};
try {
const useUv = await isUvInstalled(log);
const useUv = await shouldUseUv(log, basePython.environmentPath.fsPath);
// env creation
if (basePython.execInfo?.run.executable) {
if (useUv) {
Expand All @@ -316,6 +326,10 @@ export async function createWithProgress(
const resolved = await nativeFinder.resolve(pythonPath);
const env = api.createPythonEnvironmentItem(await getPythonInfo(resolved), manager);

if (useUv && resolved.kind === NativePythonEnvironmentKind.venvUv) {
await addUvEnvironment(env.environmentPath.fsPath);
}

// install packages
if (packages && (packages.install.length > 0 || packages.uninstall.length > 0)) {
try {
Expand Down Expand Up @@ -435,6 +449,7 @@ export async function removeVenv(environment: PythonEnvironment, log: LogOutputC
async () => {
try {
await fsapi.remove(envPath);
await removeUvEnvironment(environment.environmentPath.fsPath);
return true;
} catch (e) {
log.error(`Failed to remove virtual environment: ${e}`);
Expand All @@ -459,7 +474,7 @@ export async function resolveVenvPythonEnvironmentPath(
): Promise<PythonEnvironment | undefined> {
const resolved = await nativeFinder.resolve(fsPath);

if (resolved.kind === NativePythonEnvironmentKind.venv) {
if (resolved.kind === NativePythonEnvironmentKind.venv || resolved.kind === NativePythonEnvironmentKind.venvUv) {
const envInfo = await getPythonInfo(resolved);
return api.createPythonEnvironmentItem(envInfo, manager);
}
Expand Down
1 change: 1 addition & 0 deletions src/managers/common/nativePythonFinder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export enum NativePythonEnvironmentKind {
linuxGlobal = 'LinuxGlobal',
macXCode = 'MacXCode',
venv = 'Venv',
venvUv = 'Uv',
virtualEnv = 'VirtualEnv',
virtualEnvWrapper = 'VirtualEnvWrapper',
windowsStore = 'WindowsStore',
Expand Down
Loading