Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
4 changes: 4 additions & 0 deletions src/common/workspace.apis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
3 changes: 3 additions & 0 deletions src/managers/base/commands/packageManagerCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface BaseExecuteArgs {
*/
export interface CommandConstructorOptions {
pythonExecutable: string;
uvExecutable?: string;
cwd?: string;
log?: LogOutputChannel;
}
Expand All @@ -26,13 +27,15 @@ 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;
protected config?: WorkspaceConfiguration;

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;
Expand Down
1 change: 1 addition & 0 deletions src/managers/builtin/commands/availableVersions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
8 changes: 6 additions & 2 deletions src/managers/builtin/commands/factory.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CommandConstructorOptions } from '../../base/commands/index';
import { shouldUseUv } from '../helpers';
import { getUvExecutable, shouldUseUv } from '../helpers';

type CommandConstructor<T> = new (options: CommandConstructorOptions) => T;

Expand All @@ -12,11 +12,15 @@ export async function createPipOrUvCommandWithKind<P, U>(
UvCommand: CommandConstructor<U>,
): Promise<PipOrUvCommand<P, U>> {
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) };
}
Expand Down
2 changes: 1 addition & 1 deletion src/managers/builtin/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,6 @@ export class UvInstallCommand extends InstallCommand {
}

async execute(executeArgs: InstallExecuteArgs): Promise<void> {
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);
}
}
1 change: 1 addition & 0 deletions src/managers/builtin/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export class UvListCommand extends ListCommand {
this.log,
executeArgs?.cancellationToken,
this.timeout,
this.uvExecutable,
);
let json: unknown;
try {
Expand Down
1 change: 1 addition & 0 deletions src/managers/builtin/commands/listDirectNames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export class UvListDirectNamesCommand extends ListDirectNamesCommand {
this.log,
executeArgs?.cancellationToken,
this.timeout,
this.uvExecutable,
);
const packageNames = new Set<string>();
const lines = output.split('\n');
Expand Down
2 changes: 1 addition & 1 deletion src/managers/builtin/commands/uninstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,6 @@ export class UvUninstallCommand extends UninstallCommand {
}

async execute(executeArgs: UninstallExecuteArgs): Promise<void> {
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);
}
}
2 changes: 1 addition & 1 deletion src/managers/builtin/commands/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export class UvVersionCommand extends VersionCommand {
}

async execute(): Promise<Pep440Version | undefined> {
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;
Expand Down
40 changes: 33 additions & 7 deletions src/managers/builtin/helpers.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>();
Expand Down Expand Up @@ -34,18 +35,42 @@ export async function isUvInstalled(log?: LogOutputChannel): Promise<boolean> {
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<string | undefined> {
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<boolean> {
export async function shouldUseUv(log?: LogOutputChannel, envPath?: string, scope = envPath): 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);
return (await getUvExecutable(log, scope)) !== undefined;
}
}

Expand All @@ -54,7 +79,7 @@ export async function shouldUseUv(log?: LogOutputChannel, envPath?: string): Pro
const alwaysUseUv = config.get<boolean>('alwaysUseUv', true);

if (alwaysUseUv) {
return await isUvInstalled(log);
return (await getUvExecutable(log, scope)) !== undefined;
}
return false;
}
Expand All @@ -65,14 +90,15 @@ export async function runUV(
log?: LogOutputChannel,
token?: CancellationToken,
timeout?: number,
executable = 'uv',
): Promise<string> {
log?.info(`Running: uv ${args.join(' ')}`);
log?.info(`Running: ${executable} ${args.join(' ')}`);
return new Promise<string>((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;
Expand Down
11 changes: 9 additions & 2 deletions src/managers/builtin/venvUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
19 changes: 19 additions & 0 deletions src/test/managers/builtin/commands.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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');
Expand Down
Loading
Loading