Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import * as fs from 'fs';
import { cache } from '../../../common/utils/decorators';
import { clearCache } from '../../../common/utils/cacheUtils';
import { traceError, traceVerbose } from '../../../logging';
import { exec, pathExists, readFile, resolveSymbolicLink } from '../externalDependencies';
import { canonicalizePath, exec, isParentPath, pathExists, readFile } from '../externalDependencies';
import { isTestExecution, MINIMUM_PYTHON_VERSION, MAXIMUM_PYTHON_VERSION_EXCLUSIVE } from '../../../common/constants';
import { getPyvenvConfigPathsFrom } from './simplevirtualenvs';
import { getPyvenvConfigPathsFrom, isVenvEnvironment } from './simplevirtualenvs';
import { splitLines } from '../../../common/stringUtils';
import { CreateEnv } from '../../../common/utils/localize';

Expand Down Expand Up @@ -134,7 +134,60 @@ export function resetUvCache(): void {
}

/**
* Checks if the given interpreter belongs to a uv-managed environment.
* Checks whether an interpreter resolves to a location under uv's managed Python directory.
* Both paths are canonicalized first (the same technique getEnvIdentity uses), so a symlinked
* intermediate directory or mount point can't hide a path that really lives under the uv python
* dir: uv's own `cpython-3.14` -> `cpython-3.14.6` version-directory symlink (issue #14489), a
* `.venv/bin/python` symlinked to its uv-managed base, or an install dir relocated behind a
* symlink like `/tmp` -> `/private/tmp`. isParentPath then matches on path boundaries, so a
* sibling like `<uvDir>-backup/...` that only shares the string prefix is not counted as inside.
*
* Both a uv-managed standalone install and a uv-created venv canonicalize to a path here, so
* callers that need to tell base installs apart from venvs must apply their own venv check.
* @param interpreterPath Absolute path to the python interpreter.
* @param uvDir The uv-managed Python directory (from `uv python dir`).
*/
async function isUnderUvPythonDir(interpreterPath: string, uvDir: string): Promise<boolean> {
const [canonicalInterpreter, canonicalUvDir] = await Promise.all([
canonicalizePath(interpreterPath),
canonicalizePath(uvDir),
]);
return isParentPath(canonicalInterpreter, canonicalUvDir);
}

/**
* Checks whether an interpreter is a uv-managed standalone Python installation - a base
* interpreter that uv downloaded (living under `uv python dir`), as distinct from a
* uv-created virtual environment. Standalone installs are base/global interpreters, not
* dedicated environments.
* @param interpreterPath Absolute path to the python interpreter.
* @returns {boolean} Returns true if the interpreter is a uv-managed standalone install.
*/
export async function isUvManagedBasePython(interpreterPath: string): Promise<boolean> {
const uvUtils = await UvUtils.getUvUtils();
if (!uvUtils) {
return false;
}

const uvDir = await uvUtils.getUvDir();
if (!uvDir) {
return false;
}

// A virtual environment is never a standalone base install, even when its interpreter
// symlinks into the uv python directory: a uv-created venv points its `python` at the
// uv-managed base it was built from. Exclude anything with a pyvenv.cfg so those venvs
// stay classified as uv environments rather than global base Pythons.
if (await isVenvEnvironment(interpreterPath)) {
return false;
}

return isUnderUvPythonDir(interpreterPath, uvDir);
}

/**
* Checks if the given interpreter belongs to a uv-managed environment (a standalone install
* or a uv-created venv).
* @param interpreterPath Absolute path to the python interpreter.
* @returns {boolean} Returns true if the interpreter belongs to a uv environment.
*/
Expand All @@ -149,27 +202,11 @@ export async function isUvEnvironment(interpreterPath: string): Promise<boolean>
return false;
}

// Check if interpreter is directly in the uv directory
const normalizedInterpreterPath = path.normalize(interpreterPath);
const normalizedUvDir = path.normalize(uvDir);
if (normalizedInterpreterPath.startsWith(normalizedUvDir)) {
// A uv-managed standalone Python install lives under the uv python directory.
if (await isUnderUvPythonDir(interpreterPath, uvDir)) {
return true;
}

// Check if it's a symlink pointing to the uv directory
try {
const resolvedPath = await resolveSymbolicLink(interpreterPath);
if (
resolvedPath &&
resolvedPath !== interpreterPath &&
path.normalize(resolvedPath).startsWith(normalizedUvDir)
) {
return true;
}
} catch (ex) {
traceVerbose(ex);
}

// Check if there's a pyvenv.cfg file with a uv key
const configPaths = getPyvenvConfigPathsFrom(interpreterPath);
for (const configPath of configPaths) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import {
import { getWorkspaceFolders, onDidChangeWorkspaceFolders } from '../common/vscodeApis/workspaceApis';

// --- Start Positron ---
import { getUvDirs, isUvEnvironment } from './common/environmentManagers/uv';
import { getUvDirs, isUvEnvironment, isUvManagedBasePython } from './common/environmentManagers/uv';
import { isCustomEnvironment } from '../positron/interpreterSettings';
import { isAdditionalGlobalBinPath } from './common/environmentManagers/globalInstalledEnvs';
// eslint-disable-next-line import/no-duplicates
Expand Down Expand Up @@ -582,9 +582,25 @@ class NativePythonEnvironments implements IDiscoveryAPI, Disposable {
private async addEnv(native: NativeEnvInfo, searchLocation?: Uri): Promise<PythonEnvInfo | undefined> {
const info = await toPythonEnvInfo(native, this._condaEnvDirs);
if (info) {
if (info.executable.filename && (await isUvEnvironment(info.executable.filename))) {
traceInfo(`Found uv environment: ${info.executable.filename}`);
info.kind = PythonEnvKind.Uv;
if (info.executable.filename) {
if (await isUvManagedBasePython(info.executable.filename)) {
// A uv-managed standalone Python install is a base interpreter, not a
// dedicated environment. Classify it as a global Python so the picker,
// recommendation ranking, health check, and venv-base selection treat
// it like any other standalone Python rather than a uv virtual env.
traceInfo(`Found uv-managed base Python: ${info.executable.filename}`);
info.kind = PythonEnvKind.OtherGlobal;
// toPythonEnvInfo derived type and display name from the pre-override
// kind, which PET reported as Uv (type Virtual, label "(uv)"). Recompute
// both so the corrected global kind is reflected everywhere.
info.type = getEnvType(info.kind);
const display = getDisplayName(info.version, info.kind, info.arch, info.name);
info.detailedDisplayName = display;
info.display = display;
} else if (await isUvEnvironment(info.executable.filename)) {
traceInfo(`Found uv environment: ${info.executable.filename}`);
info.kind = PythonEnvKind.Uv;
}
}
let old = this._envs.find((item) => item.executable.filename === info.executable.filename);
if (!old) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*---------------------------------------------------------------------------------------------
* Copyright (C) 2025 Posit Software, PBC. All rights reserved.
* Copyright (C) 2025-2026 Posit Software, PBC. All rights reserved.
* Licensed under the Elastic License 2.0. See LICENSE.txt for license information.
*--------------------------------------------------------------------------------------------*/

Expand All @@ -10,6 +10,7 @@ import * as sinon from 'sinon';
import * as fileUtils from '../../../../client/pythonEnvironments/common/externalDependencies';
import {
isUvEnvironment,
isUvManagedBasePython,
isUvInstalled,
getUvDirs,
getUvPythonVersionInfo,
Expand All @@ -21,7 +22,7 @@ import * as logging from '../../../../client/logging';
import * as simplevenv from '../../../../client/pythonEnvironments/common/environmentManagers/simplevirtualenvs';

suite('uv Environment Tests', () => {
let resolveSymbolicLinkStub: sinon.SinonStub;
let canonicalizePathStub: sinon.SinonStub;
let getOSTypeStub: sinon.SinonStub;
let getEnvironmentVariableStub: sinon.SinonStub;
let execStub: sinon.SinonStub;
Expand All @@ -34,7 +35,7 @@ suite('uv Environment Tests', () => {
const exampleUvPython = `${customDir}/cpython-3.12`;

setup(() => {
resolveSymbolicLinkStub = sinon.stub(fileUtils, 'resolveSymbolicLink');
canonicalizePathStub = sinon.stub(fileUtils, 'canonicalizePath');
getOSTypeStub = sinon.stub(platformUtils, 'getOSType');
getEnvironmentVariableStub = sinon.stub(platformUtils, 'getEnvironmentVariable');
execStub = sinon.stub(fileUtils, 'exec');
Expand All @@ -46,6 +47,9 @@ suite('uv Environment Tests', () => {
getPyvenvConfigPathsStub.returns([]);
getOSTypeStub.returns(platformUtils.OSType.Linux);
execStub.resolves({ stdout: customDir });
// Default to identity: a path with no symlinks canonicalizes to itself. Individual
// tests override withArgs to model a symlink/mount resolving to a different real path.
canonicalizePathStub.callsFake(async (p: string) => p);
});

teardown(() => {
Expand Down Expand Up @@ -95,7 +99,9 @@ suite('uv Environment Tests', () => {
test('Works on Windows', async () => {
const appData = 'C:\\Users\\user\\AppData\\Roaming';
const uvDir = `${appData}\\uv\\data\\python`;
const interpreter = `${uvDir}\\env123\\Scripts\\python.exe`;
// Build the interpreter under uvDir with path.join so the path-boundary match lines
// up with the host separator (this suite simulates Windows on a POSIX test host).
const interpreter = path.join(uvDir, 'env123', 'Scripts', 'python.exe');
getOSTypeStub.returns(platformUtils.OSType.Windows);
getEnvironmentVariableStub
.withArgs('APPDATA')
Expand All @@ -108,23 +114,26 @@ suite('uv Environment Tests', () => {
assert.strictEqual(result, true);
});

test('Works with a symlink to the python dir', async () => {
test('Works with a symlink into the python dir', async () => {
const interpreter = '/path/to/venv/bin/python';
resolveSymbolicLinkStub.withArgs(interpreter).resolves(exampleUvPython);
canonicalizePathStub.withArgs(interpreter).resolves(exampleUvPython);

assert.ok(await isUvEnvironment(interpreter));
});

test('symlink resolution fails', async () => {
const interpreter = '/path/to/venv/bin/python';
resolveSymbolicLinkStub.withArgs(interpreter).rejects(new Error('Failed to resolve symlink'));
assert.strictEqual(await isUvEnvironment(interpreter), false);
test('Canonicalizes intermediate directory symlinks into the python dir', async () => {
// uv installs a real `cpython-3.14.6-<platform>` dir and a `cpython-3.14-<platform>`
// symlink to it (issue #14489). An interpreter reached through the symlinked dir is
// not itself a symlink, so full canonicalization (not leaf-only) is what matches it.
const interpreter = `${customDir}/cpython-3.14-linux/bin/python`;
canonicalizePathStub.withArgs(interpreter).resolves(`${customDir}/cpython-3.14.6-linux/bin/python`);

assert.strictEqual(await isUvEnvironment(interpreter), true);
});

test('symlink resolves but not to uv directory', async () => {
test('Resolves but not to uv directory', async () => {
const interpreter = '/path/to/venv/bin/python';
const resolvedPath = '/path/to/other/venv/bin/python';
resolveSymbolicLinkStub.withArgs(interpreter).resolves(resolvedPath);
canonicalizePathStub.withArgs(interpreter).resolves('/path/to/other/venv/bin/python');
assert.strictEqual(await isUvEnvironment(interpreter), false);
});
});
Expand Down Expand Up @@ -203,6 +212,62 @@ suite('uv Environment Tests', () => {
});
});

suite('isUvManagedBasePython Tests', () => {
test('True for an interpreter directly under the uv python dir', async () => {
assert.strictEqual(await isUvManagedBasePython(exampleUvPython), true);
});

test('True for a symlink that resolves into the uv python dir', async () => {
const interpreter = '/home/user/.local/bin/python3.13';
canonicalizePathStub.withArgs(interpreter).resolves(exampleUvPython);

assert.strictEqual(await isUvManagedBasePython(interpreter), true);
});

test('False when uv is not installed', async () => {
execStub.rejects(new Error('Command failed'));

assert.strictEqual(await isUvManagedBasePython(exampleUvPython), false);
});

test('False for a uv venv (pyvenv.cfg uv key, not under the uv dir)', async () => {
// A uv-created venv is a uv environment but NOT a standalone base install: its
// interpreter lives in the venv, and only a pyvenv.cfg uv key identifies it.
const interpreterPath = '/path/to/venv/bin/python';
const configPath = '/path/to/venv/pyvenv.cfg';
getPyvenvConfigPathsStub.returns([configPath]);
pathExistsStub.withArgs(configPath).resolves(true);
readFileStub.withArgs(configPath).resolves('home = /usr/bin\nuv = 0.1.0\nversion = 3.11.0');

assert.strictEqual(await isUvManagedBasePython(interpreterPath), false);
// Same env IS recognized as a uv environment, just not a base install.
assert.strictEqual(await isUvEnvironment(interpreterPath), true);
});

test('False for a uv venv whose interpreter symlinks into the uv python dir', async () => {
// Regression: a uv-created venv's `.venv/bin/python` symlinks to the uv-managed base
// it was built from, which lives under the uv python dir. It is still a venv, so the
// pyvenv.cfg check must win over the canonicalizes-into-the-uv-dir signal.
const interpreterPath = '/workspace/.venv/bin/python';
const configPath = '/workspace/.venv/pyvenv.cfg';
getPyvenvConfigPathsStub.returns([configPath]);
pathExistsStub.withArgs(configPath).resolves(true);
canonicalizePathStub.withArgs(interpreterPath).resolves(exampleUvPython);

assert.strictEqual(await isUvManagedBasePython(interpreterPath), false);
// The same interpreter is still recognized as a uv environment.
assert.strictEqual(await isUvEnvironment(interpreterPath), true);
});

test('False for an interpreter in a sibling dir sharing the uv dir path prefix', async () => {
// Regression: `<uvDir>-backup/...` shares a string prefix with the uv dir but is not
// inside it, so a bare prefix match must not classify it as a uv-managed base Python.
const interpreterPath = `${customDir}-backup/cpython-3.12/bin/python`;

assert.strictEqual(await isUvManagedBasePython(interpreterPath), false);
});
});

suite('isUvInstalled Tests', () => {
test('Returns true when uv is installed and working', async () => {
execStub.resolves({ stdout: customDir });
Expand Down
Loading
Loading