Skip to content
2 changes: 1 addition & 1 deletion package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,5 @@
"python-envs.revealProjectInExplorer.title": "Reveal Project in Explorer",
"python-envs.revealEnvInManagerView.title": "Reveal in Environment Managers View",
"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."
"python-envs.alwaysUseUv.description": "When set to true, uv will be used to manage all virtual environments if available, and the run button will execute files with uv run. When set to false, uv will only manage environments explicitly created by uv, including for the run button."
}
24 changes: 24 additions & 0 deletions src/features/execution/pep723.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import * as fse from 'fs-extra';

/**
* Checks if a Python script file uses PEP 723 inline script metadata.
*
* PEP 723 scripts declare their own Python version and dependency requirements
* via a `# /// script` block and should be run with `uv run <script>` without
* specifying a `--python` interpreter — uv resolves and manages the environment
* itself based on the inline metadata.
*
* @param filePath - Absolute path to the Python script file to inspect.
* @returns True if the file contains a PEP 723 `# /// script` opening marker,
* false if the marker is absent or the file cannot be read.
*/
export async function isPep723Script(filePath: string): Promise<boolean> {
try {
const content = await fse.readFile(filePath, 'utf-8');
// A PEP 723 script tag opens with a line that is exactly `# /// script`
// (optional trailing whitespace permitted).
return /^# \/\/\/ script\s*$/m.test(content);
} catch {
return false;
}
}
36 changes: 33 additions & 3 deletions src/features/execution/runAsTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import { PythonEnvironment, PythonTaskExecutionOptions } from '../../api';
import { traceInfo, traceWarn } from '../../common/logging';
import { executeTask } from '../../common/tasks.apis';
import { getWorkspaceFolder } from '../../common/workspace.apis';
import { shouldUseUv } from '../../managers/builtin/helpers';
import { quoteStringIfNecessary } from './execUtils';
import { isPep723Script } from './pep723';

function getWorkspaceFolderOrDefault(uri?: Uri): WorkspaceFolder | TaskScope {
const workspace = uri ? getWorkspaceFolder(uri) : undefined;
Expand All @@ -31,11 +33,39 @@ export async function runAsTask(
traceWarn('No Python executable found in environment; falling back to "python".');
executable = 'python';
}

const envArgs = environment.execInfo?.activatedRun?.args ?? environment.execInfo?.run.args ?? [];
const useUv = await shouldUseUv(undefined, environment.environmentPath.fsPath, options.project?.uri);

let allArgs: string[];
if (useUv) {
// Detect whether the first user argument is a PEP 723 self-contained script.
// A PEP 723 script declares its own Python version and dependencies inline, so
// uv manages the environment entirely — we must NOT pin a `--python` interpreter
// or inject env-specific args, as that would override the script's own requirements.
const candidateScript =
options.args.length > 0 && !options.args[0].startsWith('-') ? options.args[0] : undefined;
const pep723 = candidateScript ? await isPep723Script(candidateScript) : false;

if (pep723) {
// PEP 723: `uv run <script> [userArgs]` — uv picks the interpreter itself
traceInfo(`PEP 723 script detected: ${candidateScript}. Running with uv without --python.`);
allArgs = ['run', ...options.args];
} else {
// Standard script: pin the saved interpreter via --python
let pythonArg = executable;
if (pythonArg.startsWith('"') && pythonArg.endsWith('"')) {
pythonArg = pythonArg.substring(1, pythonArg.length - 1);
}
allArgs = ['run', '--python', pythonArg, ...envArgs, ...options.args];
}
executable = 'uv';
Comment thread
eleanorjboyd marked this conversation as resolved.
} else {
allArgs = [...envArgs, ...options.args];
}

// Check and quote the executable path if necessary
executable = quoteStringIfNecessary(executable);

const args = environment.execInfo?.activatedRun?.args ?? environment.execInfo?.run.args ?? [];
const allArgs = [...args, ...options.args];
traceInfo(`Running as task: ${executable} ${allArgs.join(' ')}`);

const task = new Task(
Expand Down
18 changes: 12 additions & 6 deletions src/managers/builtin/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CancellationError, CancellationToken, LogOutputChannel } from 'vscode';
import { CancellationError, CancellationToken, ConfigurationScope, LogOutputChannel } from 'vscode';
import { spawnProcess } from '../../common/childProcess.apis';
import { EventNames } from '../../common/telemetry/constants';
import { sendTelemetryEvent } from '../../common/telemetry/sender';
Expand Down Expand Up @@ -36,11 +36,17 @@ export async function isUvInstalled(log?: LogOutputChannel): Promise<boolean> {

/**
* 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.
* @param log - Optional log output channel for logging operations.
* @param envPath - Optional environment path to check against the known uv environments list.
* @param scope - Optional configuration scope used when reading the `python-envs.alwaysUseUv` setting.
* Pass the relevant project or workspace-folder `Uri` when available so VS Code resolves settings
* using normal precedence: workspace folder, then workspace, then user/global. If omitted, the
* user/global value is used unless VS Code can infer a broader scope.
* @returns True if uv should be used, false otherwise. For uv-managed environments, returns true
* if uv is installed. For other environments, checks the `python-envs.alwaysUseUv` setting for
* the provided scope and uv availability.
*/
export async function shouldUseUv(log?: LogOutputChannel, envPath?: string): Promise<boolean> {
export async function shouldUseUv(log?: LogOutputChannel, envPath?: string, scope?: ConfigurationScope): Promise<boolean> {
if (envPath) {
// always use uv if the given environment is stored as a uv env
const uvEnvs = await getUvEnvironments();
Expand All @@ -50,7 +56,7 @@ export async function shouldUseUv(log?: LogOutputChannel, envPath?: string): Pro
}

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

if (alwaysUseUv) {
Expand Down
104 changes: 104 additions & 0 deletions src/test/features/execution/pep723.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import * as assert from 'assert';
import * as sinon from 'sinon';
import { isPep723Script } from '../../../features/execution/pep723';

suite('isPep723Script Tests', () => {
let readFileStub: sinon.SinonStub;

setup(() => {
// TypeScript compiles `import * as fse from 'fs-extra'` into a namespace wrapper whose
// properties are non-configurable getters — sinon cannot stub them directly. The actual
// `require('fs-extra')` object has writable/configurable properties AND the namespace
// wrapper's getters delegate to it, so stubbing the real module object is intercepted by
// the source-under-test as well.
// eslint-disable-next-line @typescript-eslint/no-require-imports
readFileStub = sinon.stub(require('fs-extra'), 'readFile');
});

teardown(() => {
sinon.restore();
});

test('should return true for a script with a PEP 723 marker at the top', async () => {
const content = [
'# /// script',
'# requires-python = ">=3.11"',
'# dependencies = ["requests"]',
'# ///',
'',
'import requests',
'print(requests.get("https://example.com").status_code)',
].join('\n');

readFileStub.resolves(content);

const result = await isPep723Script('/some/script.py');
assert.strictEqual(result, true, 'Should detect the PEP 723 marker');
});

test('should return true when marker appears mid-file (non-standard but still matches)', async () => {
const content = [
'# Normal comment',
'',
'# /// script',
'# requires-python = ">=3.9"',
'# ///',
].join('\n');

readFileStub.resolves(content);

const result = await isPep723Script('/some/script.py');
assert.strictEqual(result, true, 'Should detect the marker wherever it appears');
});

test('should return true when marker has trailing whitespace', async () => {
const content = '# /// script \nimport sys\n';

readFileStub.resolves(content);

const result = await isPep723Script('/some/script.py');
assert.strictEqual(result, true, 'Should accept trailing whitespace after the marker');
});

test('should return false for a standard Python script with no PEP 723 block', async () => {
const content = [
'#!/usr/bin/env python3',
'# Normal script',
'import sys',
'print(sys.version)',
].join('\n');

readFileStub.resolves(content);

const result = await isPep723Script('/some/script.py');
assert.strictEqual(result, false, 'Should not detect PEP 723 in a regular script');
});

test('should return false for a comment that looks similar but is not the marker', async () => {
const content = [
'# // script', // only two slashes
'# //// script', // four slashes
'# ///script', // no space between /// and script
'# /// Script', // wrong case
].join('\n');

readFileStub.resolves(content);

const result = await isPep723Script('/some/script.py');
assert.strictEqual(result, false, 'Should not match near-miss patterns');
});

test('should return false when file cannot be read (graceful fallback)', async () => {
readFileStub.rejects(new Error('ENOENT: no such file or directory'));

const result = await isPep723Script('/nonexistent/script.py');
assert.strictEqual(result, false, 'Should return false rather than throwing when file is unreadable');
});

test('should return false for an empty file', async () => {
readFileStub.resolves('');

const result = await isPep723Script('/some/empty.py');
assert.strictEqual(result, false, 'Should return false for an empty file');
});
});
Loading
Loading