From f82574f50a2847133e9d733884d1e148054ee0e5 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:45:29 -0700 Subject: [PATCH 1/2] feat: support workspace-local uv as a fallback (#812) Prefer uv on PATH, then use a trusted workspace pyprojectx executable for environments in that workspace. Carry the resolved command through package operations and venv creation without modifying terminal PATH, with focused multi-root and trust coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- src/common/workspace.apis.ts | 4 + .../base/commands/packageManagerCommand.ts | 3 + .../builtin/commands/availableVersions.ts | 1 + src/managers/builtin/commands/factory.ts | 8 +- src/managers/builtin/commands/install.ts | 2 +- src/managers/builtin/commands/list.ts | 1 + .../builtin/commands/listDirectNames.ts | 1 + src/managers/builtin/commands/uninstall.ts | 2 +- src/managers/builtin/commands/version.ts | 2 +- src/managers/builtin/helpers.ts | 40 +++++-- src/managers/builtin/venvUtils.ts | 11 +- .../managers/builtin/commands.unit.test.ts | 19 ++++ .../helpers.isUvInstalled.unit.test.ts | 100 +++++++++++++++++- .../builtin/helpers.runUV.unit.test.ts | 16 +++ .../builtin/pipPackageManager.unit.test.ts | 1 + .../venvUtils.createWithProgress.unit.test.ts | 5 + 17 files changed, 201 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index f176f6d1d..2d4c539e3 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ All commands can be accessed via the Command Palette (`ctrl/cmd + Shift + P`): | pythonProjects | `[]` | A list of Python workspaces, specified by the path, in which you can set particular environment and package managers. You can set information for a workspace as `[{"path": "/path/to/workspace", "envManager": "ms-python.python:venv", "packageManager": "ms-python.python:pip"]}`. | | terminal.showActivateButton | `false` | (experimental) Show a button in the terminal to activate/deactivate the current environment for the terminal. This button is only shown if the active terminal is associated with a project that has an activatable environment. | | terminal.autoActivationType | `"command"` | Specifies how the extension can activate an environment in a terminal. Accepted values: `command` (execute activation command in terminal), `shellStartup` (`terminal.integrated.shellIntegration.enabled` successfully enabled or we may modify shell startup scripts ), `off` (no auto-activation). Shell startup is only supported for: zsh, fish, pwsh, bash, cmd. **Takes precedence over** `python.terminal.activateEnvironment`. Restart terminals after changing this setting. To revert shell startup changes, run `Python Envs: Revert Shell Startup Script Changes`. | -| alwaysUseUv | `true` | When `true`, [uv](https://github.com/astral-sh/uv) will be used to manage all virtual environments if available. When `false`, uv will only manage virtual environments explicitly created by uv. | +| alwaysUseUv | `true` | When `true`, [uv](https://github.com/astral-sh/uv) will be used to manage all virtual environments if available. When `false`, uv will only manage virtual environments explicitly created by uv. The extension prefers `uv` on its PATH; if unavailable in a trusted workspace, it also checks that workspace's `.pyprojectx/main/uv` (`uv.exe` on Windows) for environments inside that workspace. External environments still require uv on PATH. This does not alter the integrated terminal's PATH. | | globalSearchPaths | `[]` | Global search paths for Python environments. Array of absolute directory paths to search for environments at the user level. This setting is merged with the legacy `python.venvPath` and `python.venvFolders` settings. | | workspaceSearchPaths | `[]` | Workspace search paths for Python environments. Can be absolute paths or relative directory paths searched within the workspace. | diff --git a/src/common/workspace.apis.ts b/src/common/workspace.apis.ts index ed7276b9e..982bc207d 100644 --- a/src/common/workspace.apis.ts +++ b/src/common/workspace.apis.ts @@ -25,6 +25,10 @@ export function getWorkspaceFolders(): readonly WorkspaceFolder[] | undefined { return workspace.workspaceFolders; } +export function isWorkspaceTrusted(): boolean { + return workspace.isTrusted; +} + export function getWorkspaceFile(): Uri | undefined { return workspace.workspaceFile; } diff --git a/src/managers/base/commands/packageManagerCommand.ts b/src/managers/base/commands/packageManagerCommand.ts index 363c0716c..3449e7665 100644 --- a/src/managers/base/commands/packageManagerCommand.ts +++ b/src/managers/base/commands/packageManagerCommand.ts @@ -14,6 +14,7 @@ export interface BaseExecuteArgs { */ export interface CommandConstructorOptions { pythonExecutable: string; + uvExecutable?: string; cwd?: string; log?: LogOutputChannel; } @@ -26,6 +27,7 @@ export abstract class PackageManagerCommand { protected static readonly configSection?: string; protected pythonExecutable: string; + protected uvExecutable: string; protected cwd?: string; protected log?: LogOutputChannel; protected timeout: number | undefined; @@ -33,6 +35,7 @@ export abstract class PackageManagerCommand { constructor(options: CommandConstructorOptions) { this.pythonExecutable = options.pythonExecutable; + this.uvExecutable = options.uvExecutable ?? 'uv'; this.cwd = options.cwd; this.log = options.log; const configSection = (this.constructor as typeof PackageManagerCommand).configSection; diff --git a/src/managers/builtin/commands/availableVersions.ts b/src/managers/builtin/commands/availableVersions.ts index e11792daf..6ef658c64 100644 --- a/src/managers/builtin/commands/availableVersions.ts +++ b/src/managers/builtin/commands/availableVersions.ts @@ -111,6 +111,7 @@ export class UvAvailableVersionsCommand extends AvailableVersionsCommand { this.log, executeArgs.cancellationToken, this.timeout, + this.uvExecutable, ); return this.parseVersions(parseVersionsJson(output, 'uv'), executeArgs.includePrerelease); } diff --git a/src/managers/builtin/commands/factory.ts b/src/managers/builtin/commands/factory.ts index 947d1de68..cd7fe993d 100644 --- a/src/managers/builtin/commands/factory.ts +++ b/src/managers/builtin/commands/factory.ts @@ -1,5 +1,5 @@ import { CommandConstructorOptions } from '../../base/commands/index'; -import { shouldUseUv } from '../helpers'; +import { getUvExecutable, shouldUseUv } from '../helpers'; type CommandConstructor = new (options: CommandConstructorOptions) => T; @@ -12,11 +12,15 @@ export async function createPipOrUvCommandWithKind( UvCommand: CommandConstructor, ): Promise> { if (await shouldUseUv(options.log, environmentPath)) { + const uvExecutable = await getUvExecutable(options.log, environmentPath); + if (!uvExecutable) { + throw new Error(`uv became unavailable for environment: ${environmentPath}`); + } // uv accepts an environment directory as its `--python` target. A symlinked // environment executable (for example Pipenv) can resolve to the externally // managed base interpreter, so passing the environment directory preserves // the environment boundary. Pip commands keep using the interpreter itself. - return { kind: 'uv', command: new UvCommand({ ...options, pythonExecutable: environmentPath }) }; + return { kind: 'uv', command: new UvCommand({ ...options, pythonExecutable: environmentPath, uvExecutable }) }; } return { kind: 'pip', command: new PipCommand(options) }; } diff --git a/src/managers/builtin/commands/install.ts b/src/managers/builtin/commands/install.ts index 6f484287f..9ba7c69c2 100644 --- a/src/managers/builtin/commands/install.ts +++ b/src/managers/builtin/commands/install.ts @@ -47,6 +47,6 @@ export class UvInstallCommand extends InstallCommand { } async execute(executeArgs: InstallExecuteArgs): Promise { - await runUV(this.buildCommand(executeArgs), undefined, this.log, executeArgs.cancellationToken, this.timeout); + await runUV(this.buildCommand(executeArgs), undefined, this.log, executeArgs.cancellationToken, this.timeout, this.uvExecutable); } } diff --git a/src/managers/builtin/commands/list.ts b/src/managers/builtin/commands/list.ts index 1e129a639..afd89ade9 100644 --- a/src/managers/builtin/commands/list.ts +++ b/src/managers/builtin/commands/list.ts @@ -61,6 +61,7 @@ export class UvListCommand extends ListCommand { this.log, executeArgs?.cancellationToken, this.timeout, + this.uvExecutable, ); let json: unknown; try { diff --git a/src/managers/builtin/commands/listDirectNames.ts b/src/managers/builtin/commands/listDirectNames.ts index bd27cf52a..ad7af6c35 100644 --- a/src/managers/builtin/commands/listDirectNames.ts +++ b/src/managers/builtin/commands/listDirectNames.ts @@ -54,6 +54,7 @@ export class UvListDirectNamesCommand extends ListDirectNamesCommand { this.log, executeArgs?.cancellationToken, this.timeout, + this.uvExecutable, ); const packageNames = new Set(); const lines = output.split('\n'); diff --git a/src/managers/builtin/commands/uninstall.ts b/src/managers/builtin/commands/uninstall.ts index 50ffe61e4..8933898f6 100644 --- a/src/managers/builtin/commands/uninstall.ts +++ b/src/managers/builtin/commands/uninstall.ts @@ -36,6 +36,6 @@ export class UvUninstallCommand extends UninstallCommand { } async execute(executeArgs: UninstallExecuteArgs): Promise { - await runUV(this.buildCommand(executeArgs), undefined, this.log, executeArgs.cancellationToken, this.timeout); + await runUV(this.buildCommand(executeArgs), undefined, this.log, executeArgs.cancellationToken, this.timeout, this.uvExecutable); } } diff --git a/src/managers/builtin/commands/version.ts b/src/managers/builtin/commands/version.ts index 77faa3a09..fe0318bda 100644 --- a/src/managers/builtin/commands/version.ts +++ b/src/managers/builtin/commands/version.ts @@ -39,7 +39,7 @@ export class UvVersionCommand extends VersionCommand { } async execute(): Promise { - const output = await runUV(this.buildCommand(), undefined, this.log, undefined, this.timeout); + const output = await runUV(this.buildCommand(), undefined, this.log, undefined, this.timeout, this.uvExecutable); const match = output.match(/(\d+\.\d+(?:\.\d+)*)/); return match ? (parsePep440Version(match[1]) ?? undefined) : undefined; diff --git a/src/managers/builtin/helpers.ts b/src/managers/builtin/helpers.ts index 7fb7062a2..82a4afb54 100644 --- a/src/managers/builtin/helpers.ts +++ b/src/managers/builtin/helpers.ts @@ -1,9 +1,10 @@ -import { CancellationError, CancellationToken, LogOutputChannel } from 'vscode'; +import * as path from 'path'; +import { CancellationError, CancellationToken, LogOutputChannel, Uri } from 'vscode'; 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 { getConfiguration, getWorkspaceFolder, isWorkspaceTrusted } from '../../common/workspace.apis'; import { getUvEnvironments } from './uvEnvironments'; let available = createDeferred(); @@ -34,18 +35,42 @@ export async function isUvInstalled(log?: LogOutputChannel): Promise { return available.promise; } +/** + * Resolves uv from the extension PATH, or from pyprojectx in the owning trusted workspace. + * @param log Optional command log. + * @param scope Path inside the workspace whose local uv should be considered. + * @returns The command to spawn, or undefined when uv is unavailable. + */ +export async function getUvExecutable(log?: LogOutputChannel, scope?: string): Promise { + if (await isUvInstalled(log)) { + return 'uv'; + } + const workspaceFolder = scope && isWorkspaceTrusted() ? getWorkspaceFolder(Uri.file(scope)) : undefined; + if (!workspaceFolder) { + return undefined; + } + const executable = path.join(workspaceFolder.uri.fsPath, '.pyprojectx', 'main', process.platform === 'win32' ? 'uv.exe' : 'uv'); + return new Promise((resolve) => { + log?.info(`Running: ${executable} --version`); + const proc = spawnProcess(executable, ['--version']); + proc.on('error', () => resolve(undefined)); + proc.on('exit', (code) => resolve(code === 0 ? executable : undefined)); + }); +} + /** * 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 + * @param scope - Path used to locate the workspace's uv; defaults to the environment path. * @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 { +export async function shouldUseUv(log?: LogOutputChannel, envPath?: string, scope = envPath): Promise { 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); + return (await getUvExecutable(log, scope)) !== undefined; } } @@ -54,7 +79,7 @@ export async function shouldUseUv(log?: LogOutputChannel, envPath?: string): Pro const alwaysUseUv = config.get('alwaysUseUv', true); if (alwaysUseUv) { - return await isUvInstalled(log); + return (await getUvExecutable(log, scope)) !== undefined; } return false; } @@ -65,14 +90,15 @@ export async function runUV( log?: LogOutputChannel, token?: CancellationToken, timeout?: number, + executable = 'uv', ): Promise { - log?.info(`Running: uv ${args.join(' ')}`); + log?.info(`Running: ${executable} ${args.join(' ')}`); return new Promise((resolve, reject) => { const spawnOptions: { cwd?: string; timeout?: number } = { cwd }; if (timeout !== undefined) { spawnOptions.timeout = timeout; } - const proc = spawnProcess('uv', args, spawnOptions); + const proc = spawnProcess(executable, args, spawnOptions); let cancellationRequested = false; token?.onCancellationRequested(() => { cancellationRequested = true; diff --git a/src/managers/builtin/venvUtils.ts b/src/managers/builtin/venvUtils.ts index 7b2de8544..e70cc79d3 100644 --- a/src/managers/builtin/venvUtils.ts +++ b/src/managers/builtin/venvUtils.ts @@ -42,7 +42,7 @@ import { NativePythonFinder, } from '../common/nativePythonFinder'; import { getShellActivationCommands, shortenVersionString, sortEnvironments } from '../common/utils'; -import { runPython, runUV, shouldUseUv } from './helpers'; +import { getUvExecutable, runPython, runUV, shouldUseUv } from './helpers'; import { getProjectInstallable, PipPackages, shouldProceedAfterPyprojectValidation } from './pipUtils'; import { resolveSystemPythonEnvironmentPath } from './utils'; import { addUvEnvironment, removeUvEnvironment, UV_ENVS_KEY } from './uvEnvironments'; @@ -431,15 +431,22 @@ export async function createWithProgress( async () => { const result: CreateEnvironmentResult = {}; try { - const useUv = await shouldUseUv(log, basePython.environmentPath.fsPath); + const useUv = await shouldUseUv(log, basePython.environmentPath.fsPath, venvRoot.fsPath); // env creation const baseExecutable = await getBaseInterpreterForVenv(basePython); if (baseExecutable) { if (useUv) { + const uvExecutable = await getUvExecutable(log, venvRoot.fsPath); + if (!uvExecutable) { + throw new Error(`uv became unavailable for workspace: ${venvRoot.fsPath}`); + } await runUV( ['venv', '--verbose', '--seed', '--python', baseExecutable, envPath], venvRoot.fsPath, log, + undefined, + undefined, + uvExecutable, ); } else { await runPython(baseExecutable, ['-m', 'venv', envPath], venvRoot.fsPath, manager.log); diff --git a/src/test/managers/builtin/commands.unit.test.ts b/src/test/managers/builtin/commands.unit.test.ts index 6619c06aa..c49bcce35 100644 --- a/src/test/managers/builtin/commands.unit.test.ts +++ b/src/test/managers/builtin/commands.unit.test.ts @@ -43,6 +43,7 @@ suite('Pip and UV command parsing', () => { runPythonStub = sinon.stub(helpers, 'runPython').resolves(''); runUvStub = sinon.stub(helpers, 'runUV').resolves(''); shouldUseUvStub = sinon.stub(helpers, 'shouldUseUv').resolves(false); + sinon.stub(helpers, 'getUvExecutable').resolves('uv'); }); teardown(() => { @@ -416,6 +417,24 @@ suite('Pip and UV command parsing', () => { } }); + test('UV package commands use the resolved workspace executable', async () => { + const uvExecutable = path.join(process.cwd(), '.pyprojectx', 'main', process.platform === 'win32' ? 'uv.exe' : 'uv'); + shouldUseUvStub.resolves(true); + (helpers.getUvExecutable as sinon.SinonStub).resolves(uvExecutable); + const environmentPath = path.join(process.cwd(), '.venv'); + const command: PipInstallCommand | UvInstallCommand = await createPipOrUvCommand( + { pythonExecutable: path.join(environmentPath, 'bin', 'python'), log: mockLog }, + environmentPath, + PipInstallCommand, + UvInstallCommand, + ); + + await command.execute({ packages: [{ packageName: 'requests' }] }); + + assert.strictEqual(runUvStub.firstCall.args[5], uvExecutable); + assert.deepStrictEqual(runUvStub.firstCall.args[0].slice(0, 4), ['pip', 'install', '--python', environmentPath]); + }); + test('Pip package commands continue using the environment interpreter', async () => { const pythonExecutable = path.join(path.sep, 'virtualenvs', 'project', 'bin', 'python'); const environmentPath = path.join(path.sep, 'virtualenvs', 'project'); diff --git a/src/test/managers/builtin/helpers.isUvInstalled.unit.test.ts b/src/test/managers/builtin/helpers.isUvInstalled.unit.test.ts index 93141c31c..62b408f0d 100644 --- a/src/test/managers/builtin/helpers.isUvInstalled.unit.test.ts +++ b/src/test/managers/builtin/helpers.isUvInstalled.unit.test.ts @@ -1,10 +1,12 @@ import assert from 'assert'; +import * as path from 'path'; import * as sinon from 'sinon'; -import { LogOutputChannel } from 'vscode'; +import { LogOutputChannel, Uri, WorkspaceFolder } from 'vscode'; import * as childProcessApis from '../../../common/childProcess.apis'; +import * as workspaceApis from '../../../common/workspace.apis'; import { EventNames } from '../../../common/telemetry/constants'; import * as telemetrySender from '../../../common/telemetry/sender'; -import { isUvInstalled, resetUvInstallationCache } from '../../../managers/builtin/helpers'; +import { getUvExecutable, isUvInstalled, resetUvInstallationCache } from '../../../managers/builtin/helpers'; import { createMockLogOutputChannel } from '../../mocks/helper'; import { MockChildProcess } from '../../mocks/mockChildProcess'; @@ -199,4 +201,98 @@ suite('Helpers - isUvInstalled', () => { assert.strictEqual(secondResult, false); assert(spawnStub.calledTwice, 'Should spawn process twice after cache reset'); }); + + test('falls back to the owning workspace pyprojectx executable when uv is not on PATH', async () => { + const root = path.join(process.cwd(), 'project'); + const uvExecutable = path.join(root, '.pyprojectx', 'main', process.platform === 'win32' ? 'uv.exe' : 'uv'); + sinon.stub(workspaceApis, 'isWorkspaceTrusted').returns(true); + sinon.stub(workspaceApis, 'getWorkspaceFolder').returns({ + name: 'project', + uri: Uri.file(root), + } as WorkspaceFolder); + const globalProc = new MockChildProcess('uv', ['--version']); + const localProc = new MockChildProcess(uvExecutable, ['--version']); + spawnStub.withArgs('uv', ['--version']).returns(globalProc); + spawnStub.withArgs(uvExecutable, ['--version']).returns(localProc); + + const first = getUvExecutable(mockLog, path.join(root, '.venv')); + globalProc.emit('error', new Error('ENOENT')); + await new Promise((resolve) => setImmediate(resolve)); + localProc.emit('exit', 0, null); + assert.strictEqual(await first, uvExecutable); + assert(spawnStub.calledWith(uvExecutable, ['--version'])); + }); + + test('does not execute workspace binaries in an untrusted workspace', async () => { + sinon.stub(workspaceApis, 'isWorkspaceTrusted').returns(false); + const getWorkspaceFolder = sinon.stub(workspaceApis, 'getWorkspaceFolder'); + const globalProc = new MockChildProcess('uv', ['--version']); + spawnStub.withArgs('uv', ['--version']).returns(globalProc); + + const result = getUvExecutable(mockLog, path.join(process.cwd(), 'project', '.venv')); + globalProc.emit('error', new Error('ENOENT')); + assert.strictEqual(await result, undefined); + sinon.assert.notCalled(getWorkspaceFolder); + sinon.assert.calledOnce(spawnStub); + }); + + test('prefers uv on PATH without looking up a workspace executable', async () => { + const getWorkspaceFolder = sinon.stub(workspaceApis, 'getWorkspaceFolder'); + const globalProc = new MockChildProcess('uv', ['--version']); + spawnStub.withArgs('uv', ['--version']).returns(globalProc); + + const result = getUvExecutable(mockLog, path.join(process.cwd(), 'project', '.venv')); + globalProc.emit('exit', 0, null); + + assert.strictEqual(await result, 'uv'); + sinon.assert.notCalled(getWorkspaceFolder); + sinon.assert.calledOnce(spawnStub); + }); + + test('does not use a missing or failing workspace executable', async () => { + const root = path.join(process.cwd(), 'project'); + const executable = path.join(root, '.pyprojectx', 'main', process.platform === 'win32' ? 'uv.exe' : 'uv'); + sinon.stub(workspaceApis, 'isWorkspaceTrusted').returns(true); + const workspaceFolder = sinon.stub(workspaceApis, 'getWorkspaceFolder'); + workspaceFolder.onFirstCall().returns(undefined); + workspaceFolder.onSecondCall().returns({ name: 'project', uri: Uri.file(root) } as WorkspaceFolder); + const globalProc = new MockChildProcess('uv', ['--version']); + const localProc = new MockChildProcess(executable, ['--version']); + spawnStub.withArgs('uv', ['--version']).returns(globalProc); + spawnStub.withArgs(executable, ['--version']).returns(localProc); + + const missing = getUvExecutable(mockLog, path.join(root, '.venv')); + globalProc.emit('error', new Error('ENOENT')); + assert.strictEqual(await missing, undefined); + + const failing = getUvExecutable(mockLog, path.join(root, '.venv')); + await new Promise((resolve) => setImmediate(resolve)); + localProc.emit('exit', 1, null); + assert.strictEqual(await failing, undefined); + }); + + test('resolves separate workspace executables for different roots', async () => { + const roots = ['first', 'second'].map((name) => path.join(process.cwd(), name)); + const executables = roots.map((root) => + path.join(root, '.pyprojectx', 'main', process.platform === 'win32' ? 'uv.exe' : 'uv'), + ); + sinon.stub(workspaceApis, 'isWorkspaceTrusted').returns(true); + sinon.stub(workspaceApis, 'getWorkspaceFolder').callsFake((uri) => { + const root = roots.find((candidate) => uri.fsPath.startsWith(`${candidate}${path.sep}`)); + return root ? ({ name: path.basename(root), uri: Uri.file(root) } as WorkspaceFolder) : undefined; + }); + const globalProc = new MockChildProcess('uv', ['--version']); + spawnStub.withArgs('uv', ['--version']).returns(globalProc); + for (const [index, root] of roots.entries()) { + const proc = new MockChildProcess(executables[index], ['--version']); + spawnStub.withArgs(executables[index], ['--version']).returns(proc); + const result = getUvExecutable(mockLog, path.join(root, '.venv')); + if (index === 0) { + globalProc.emit('error', new Error('ENOENT')); + } + await new Promise((resolve) => setImmediate(resolve)); + proc.emit('exit', 0, null); + assert.strictEqual(await result, executables[index]); + } + }); }); diff --git a/src/test/managers/builtin/helpers.runUV.unit.test.ts b/src/test/managers/builtin/helpers.runUV.unit.test.ts index a6ff0deb8..af69af96c 100644 --- a/src/test/managers/builtin/helpers.runUV.unit.test.ts +++ b/src/test/managers/builtin/helpers.runUV.unit.test.ts @@ -1,4 +1,5 @@ import assert from 'assert'; +import * as path from 'path'; import * as sinon from 'sinon'; import { CancellationError, CancellationTokenSource, LogOutputChannel } from 'vscode'; import * as childProcessApis from '../../../common/childProcess.apis'; @@ -99,6 +100,21 @@ suite('Helpers - runUV', () => { assert.ok(spawnStub.calledWith('uv', ['pip', 'list'], { cwd })); }); + test('spawns the resolved workspace uv executable instead of relying on PATH', async () => { + const executable = path.join(path.sep, 'workspace', '.pyprojectx', 'main', process.platform === 'win32' ? 'uv.exe' : 'uv'); + const mockProcess = new MockChildProcess(executable, ['pip', 'list']); + spawnStub.withArgs(executable, ['pip', 'list'], { cwd: undefined }).returns(mockProcess); + + const resultPromise = runUV(['pip', 'list'], undefined, mockLog, undefined, undefined, executable); + setTimeout(() => { + mockProcess.emit('exit', 0, null); + (mockProcess as unknown as { emit: (event: string) => void }).emit('close'); + }, 10); + + await resultPromise; + sinon.assert.calledWith(spawnStub, executable, ['pip', 'list']); + }); + test('should work without logger', async () => { const mockProcess = new MockChildProcess('uv', ['--version']); spawnStub.withArgs('uv', ['--version']).returns(mockProcess); diff --git a/src/test/managers/builtin/pipPackageManager.unit.test.ts b/src/test/managers/builtin/pipPackageManager.unit.test.ts index 2b7b70f82..866570626 100644 --- a/src/test/managers/builtin/pipPackageManager.unit.test.ts +++ b/src/test/managers/builtin/pipPackageManager.unit.test.ts @@ -195,6 +195,7 @@ suite('PipPackageManager', () => { const manager = createManager(); const environment = createEnvironment(); sinon.stub(helpers, 'shouldUseUv').resolves(true); + sinon.stub(helpers, 'getUvExecutable').resolves('uv'); const runPython = sinon.stub(helpers, 'runPython'); sinon.stub(helpers, 'runUV').resolves(JSON.stringify({ versions: ['2.32.5', '2.31.0'] })); diff --git a/src/test/managers/builtin/venvUtils.createWithProgress.unit.test.ts b/src/test/managers/builtin/venvUtils.createWithProgress.unit.test.ts index 37797a7a3..1f7061050 100644 --- a/src/test/managers/builtin/venvUtils.createWithProgress.unit.test.ts +++ b/src/test/managers/builtin/venvUtils.createWithProgress.unit.test.ts @@ -67,6 +67,7 @@ suite('createWithProgress uv tracking', () => { sinon.stub(windowApis, 'withProgress').callsFake(async (_options, task) => task({} as never, {} as never)); sinon.stub(builtinHelpers, 'shouldUseUv').resolves(true); + sinon.stub(builtinHelpers, 'getUvExecutable').resolves(path.join(tempRoot, '.pyprojectx', 'main', 'uv')); sinon.stub(builtinHelpers, 'runUV').resolves(''); sinon.stub(managerUtils, 'getShellActivationCommands').resolves({ shellActivation: new Map(), @@ -93,6 +94,10 @@ suite('createWithProgress uv tracking', () => { assert.ok(result?.environment); assert.ok(addUvEnvironmentStub.calledOnce); + assert.strictEqual( + (builtinHelpers.runUV as sinon.SinonStub).firstCall.args[5], + path.join(tempRoot, '.pyprojectx', 'main', 'uv'), + ); }); test('skips workspace-scoped uv tracking when explicitly disabled', async () => { From 41034b4c9e6d13d8b69f7665f3790edd4ef20117 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:20:21 -0700 Subject: [PATCH 2/2] test: normalize workspace uv paths on Windows (#812) Construct mocked executable paths from Uri.fsPath, matching the path returned by workspace folder resolution on Windows where URI drive letters may be lowercased. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../managers/builtin/helpers.isUvInstalled.unit.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/managers/builtin/helpers.isUvInstalled.unit.test.ts b/src/test/managers/builtin/helpers.isUvInstalled.unit.test.ts index 62b408f0d..b789baee8 100644 --- a/src/test/managers/builtin/helpers.isUvInstalled.unit.test.ts +++ b/src/test/managers/builtin/helpers.isUvInstalled.unit.test.ts @@ -204,7 +204,7 @@ suite('Helpers - isUvInstalled', () => { test('falls back to the owning workspace pyprojectx executable when uv is not on PATH', async () => { const root = path.join(process.cwd(), 'project'); - const uvExecutable = path.join(root, '.pyprojectx', 'main', process.platform === 'win32' ? 'uv.exe' : 'uv'); + const uvExecutable = path.join(Uri.file(root).fsPath, '.pyprojectx', 'main', process.platform === 'win32' ? 'uv.exe' : 'uv'); sinon.stub(workspaceApis, 'isWorkspaceTrusted').returns(true); sinon.stub(workspaceApis, 'getWorkspaceFolder').returns({ name: 'project', @@ -251,7 +251,7 @@ suite('Helpers - isUvInstalled', () => { test('does not use a missing or failing workspace executable', async () => { const root = path.join(process.cwd(), 'project'); - const executable = path.join(root, '.pyprojectx', 'main', process.platform === 'win32' ? 'uv.exe' : 'uv'); + const executable = path.join(Uri.file(root).fsPath, '.pyprojectx', 'main', process.platform === 'win32' ? 'uv.exe' : 'uv'); sinon.stub(workspaceApis, 'isWorkspaceTrusted').returns(true); const workspaceFolder = sinon.stub(workspaceApis, 'getWorkspaceFolder'); workspaceFolder.onFirstCall().returns(undefined); @@ -272,7 +272,7 @@ suite('Helpers - isUvInstalled', () => { }); test('resolves separate workspace executables for different roots', async () => { - const roots = ['first', 'second'].map((name) => path.join(process.cwd(), name)); + const roots = ['first', 'second'].map((name) => Uri.file(path.join(process.cwd(), name)).fsPath); const executables = roots.map((root) => path.join(root, '.pyprojectx', 'main', process.platform === 'win32' ? 'uv.exe' : 'uv'), );