Skip to content

Commit a01a406

Browse files
eleanorjboydCopilot
andcommitted
Add PEP 723 inline script detection for uv run button
When the run button is clicked on a PEP 723 script (one containing a `# /// script` block), uv manages the interpreter and dependencies entirely. We must not pass `--python` in that case or it would override the script's own requirements. Detection logic: - New `isPep723Script(filePath)` helper in pep723.ts reads the script and checks for the PEP 723 opening marker `# /// script` - In runAsTask, when uv mode is active, inspect options.args[0] (the script path) before building the command: `uv run <script> [userArgs]` (no --python, no env args) `uv run --python <interpreter> [envArgs] [userArgs]` Tests added: - pep723.unit.test.ts: unit tests for isPep723Script (marker present, absent, near-misses, trailing whitespace, read error fallback) - runAsTask.unit.test.ts: PEP 723 suite (uv run without --python, extra user args forwarded, flag-first args skip detection, non-PEP 723 still gets --python, read-error graceful fallback) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 2151183 commit a01a406

4 files changed

Lines changed: 290 additions & 10 deletions

File tree

src/features/execution/pep723.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import * as fse from 'fs-extra';
2+
3+
/**
4+
* Checks if a Python script file uses PEP 723 inline script metadata.
5+
*
6+
* PEP 723 scripts declare their own Python version and dependency requirements
7+
* via a `# /// script` block and should be run with `uv run <script>` without
8+
* specifying a `--python` interpreter — uv resolves and manages the environment
9+
* itself based on the inline metadata.
10+
*
11+
* @param filePath - Absolute path to the Python script file to inspect.
12+
* @returns True if the file contains a PEP 723 `# /// script` opening marker,
13+
* false if the marker is absent or the file cannot be read.
14+
*/
15+
export async function isPep723Script(filePath: string): Promise<boolean> {
16+
try {
17+
const content = await fse.readFile(filePath, 'utf-8');
18+
// A PEP 723 script tag opens with a line that is exactly `# /// script`
19+
// (optional trailing whitespace permitted).
20+
return /^# \/\/\/ script\s*$/m.test(content);
21+
} catch {
22+
return false;
23+
}
24+
}

src/features/execution/runAsTask.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { executeTask } from '../../common/tasks.apis';
1414
import { getWorkspaceFolder } from '../../common/workspace.apis';
1515
import { shouldUseUv } from '../../managers/builtin/helpers';
1616
import { quoteStringIfNecessary } from './execUtils';
17+
import { isPep723Script } from './pep723';
1718

1819
function getWorkspaceFolderOrDefault(uri?: Uri): WorkspaceFolder | TaskScope {
1920
const workspace = uri ? getWorkspaceFolder(uri) : undefined;
@@ -33,21 +34,34 @@ export async function runAsTask(
3334
executable = 'python';
3435
}
3536

36-
const args = environment.execInfo?.activatedRun?.args ?? environment.execInfo?.run.args ?? [];
37-
const allArgs = [...args, ...options.args];
37+
const envArgs = environment.execInfo?.activatedRun?.args ?? environment.execInfo?.run.args ?? [];
3838
const useUv = await shouldUseUv(undefined, environment.environmentPath.fsPath, options.project?.uri);
3939

40+
let allArgs: string[];
4041
if (useUv) {
41-
// Strip surrounding quotes before passing as --python value; uv receives the raw path
42-
// and shell-quoting the argument value causes it to fail to resolve the interpreter.
43-
// (cf. runInBackground.ts which strips quotes for the same reason before spawn)
44-
let pythonArg = executable;
45-
if (pythonArg.startsWith('"') && pythonArg.endsWith('"')) {
46-
pythonArg = pythonArg.substring(1, pythonArg.length - 1);
42+
// Detect whether the first user argument is a PEP 723 self-contained script.
43+
// A PEP 723 script declares its own Python version and dependencies inline, so
44+
// uv manages the environment entirely — we must NOT pin a `--python` interpreter
45+
// or inject env-specific args, as that would override the script's own requirements.
46+
const candidateScript =
47+
options.args.length > 0 && !options.args[0].startsWith('-') ? options.args[0] : undefined;
48+
const pep723 = candidateScript ? await isPep723Script(candidateScript) : false;
49+
50+
if (pep723) {
51+
// PEP 723: `uv run <script> [userArgs]` — uv picks the interpreter itself
52+
traceInfo(`PEP 723 script detected: ${candidateScript}. Running with uv without --python.`);
53+
allArgs = ['run', ...options.args];
54+
} else {
55+
// Standard script: pin the saved interpreter via --python
56+
let pythonArg = executable;
57+
if (pythonArg.startsWith('"') && pythonArg.endsWith('"')) {
58+
pythonArg = pythonArg.substring(1, pythonArg.length - 1);
59+
}
60+
allArgs = ['run', '--python', pythonArg, ...envArgs, ...options.args];
4761
}
48-
allArgs.unshift('--python', pythonArg);
49-
allArgs.unshift('run');
5062
executable = 'uv';
63+
} else {
64+
allArgs = [...envArgs, ...options.args];
5165
}
5266

5367
// Check and quote the executable path if necessary
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import * as assert from 'assert';
2+
import * as sinon from 'sinon';
3+
import * as fse from 'fs-extra';
4+
import { isPep723Script } from '../../../features/execution/pep723';
5+
6+
suite('isPep723Script Tests', () => {
7+
let readFileStub: sinon.SinonStub;
8+
9+
setup(() => {
10+
readFileStub = sinon.stub(fse, 'readFile');
11+
});
12+
13+
teardown(() => {
14+
sinon.restore();
15+
});
16+
17+
test('should return true for a script with a PEP 723 marker at the top', async () => {
18+
const content = [
19+
'# /// script',
20+
'# requires-python = ">=3.11"',
21+
'# dependencies = ["requests"]',
22+
'# ///',
23+
'',
24+
'import requests',
25+
'print(requests.get("https://example.com").status_code)',
26+
].join('\n');
27+
28+
readFileStub.resolves(content);
29+
30+
const result = await isPep723Script('/some/script.py');
31+
assert.strictEqual(result, true, 'Should detect the PEP 723 marker');
32+
});
33+
34+
test('should return true when marker appears mid-file (non-standard but still matches)', async () => {
35+
const content = [
36+
'# Normal comment',
37+
'',
38+
'# /// script',
39+
'# requires-python = ">=3.9"',
40+
'# ///',
41+
].join('\n');
42+
43+
readFileStub.resolves(content);
44+
45+
const result = await isPep723Script('/some/script.py');
46+
assert.strictEqual(result, true, 'Should detect the marker wherever it appears');
47+
});
48+
49+
test('should return true when marker has trailing whitespace', async () => {
50+
const content = '# /// script \nimport sys\n';
51+
52+
readFileStub.resolves(content);
53+
54+
const result = await isPep723Script('/some/script.py');
55+
assert.strictEqual(result, true, 'Should accept trailing whitespace after the marker');
56+
});
57+
58+
test('should return false for a standard Python script with no PEP 723 block', async () => {
59+
const content = [
60+
'#!/usr/bin/env python3',
61+
'# Normal script',
62+
'import sys',
63+
'print(sys.version)',
64+
].join('\n');
65+
66+
readFileStub.resolves(content);
67+
68+
const result = await isPep723Script('/some/script.py');
69+
assert.strictEqual(result, false, 'Should not detect PEP 723 in a regular script');
70+
});
71+
72+
test('should return false for a comment that looks similar but is not the marker', async () => {
73+
const content = [
74+
'# // script', // only two slashes
75+
'# //// script', // four slashes
76+
'# ///script', // no space between /// and script
77+
'# /// Script', // wrong case
78+
].join('\n');
79+
80+
readFileStub.resolves(content);
81+
82+
const result = await isPep723Script('/some/script.py');
83+
assert.strictEqual(result, false, 'Should not match near-miss patterns');
84+
});
85+
86+
test('should return false when file cannot be read (graceful fallback)', async () => {
87+
readFileStub.rejects(new Error('ENOENT: no such file or directory'));
88+
89+
const result = await isPep723Script('/nonexistent/script.py');
90+
assert.strictEqual(result, false, 'Should return false rather than throwing when file is unreadable');
91+
});
92+
93+
test('should return false for an empty file', async () => {
94+
readFileStub.resolves('');
95+
96+
const result = await isPep723Script('/some/empty.py');
97+
assert.strictEqual(result, false, 'Should return false for an empty file');
98+
});
99+
});

src/test/features/execution/runAsTask.unit.test.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as workspaceApis from '../../../common/workspace.apis';
88
import * as execUtils from '../../../features/execution/execUtils';
99
import { runAsTask } from '../../../features/execution/runAsTask';
1010
import * as builtinHelpers from '../../../managers/builtin/helpers';
11+
import * as pep723Module from '../../../features/execution/pep723';
1112

1213
suite('runAsTask Tests', () => {
1314
let mockTraceInfo: sinon.SinonStub;
@@ -16,6 +17,7 @@ suite('runAsTask Tests', () => {
1617
let mockGetWorkspaceFolder: sinon.SinonStub;
1718
let mockQuoteStringIfNecessary: sinon.SinonStub;
1819
let mockShouldUseUv: sinon.SinonStub;
20+
let mockIsPep723Script: sinon.SinonStub;
1921

2022
setup(() => {
2123
mockTraceInfo = sinon.stub(logging, 'traceInfo');
@@ -24,6 +26,7 @@ suite('runAsTask Tests', () => {
2426
mockGetWorkspaceFolder = sinon.stub(workspaceApis, 'getWorkspaceFolder');
2527
mockQuoteStringIfNecessary = sinon.stub(execUtils, 'quoteStringIfNecessary');
2628
mockShouldUseUv = sinon.stub(builtinHelpers, 'shouldUseUv').resolves(false);
29+
mockIsPep723Script = sinon.stub(pep723Module, 'isPep723Script').resolves(false);
2730
});
2831

2932
teardown(() => {
@@ -1172,4 +1175,144 @@ suite('runAsTask Tests', () => {
11721175
assert.ok(mockTraceWarn.notCalled, 'Should not warn for complete environment configuration');
11731176
});
11741177
});
1178+
1179+
suite('PEP 723 Inline Script Metadata', () => {
1180+
function makeEnv(executable: string): PythonEnvironment {
1181+
return {
1182+
envId: { id: 'test-env', managerId: 'test-manager' },
1183+
name: 'Test Environment',
1184+
displayName: 'Test Environment',
1185+
displayPath: '/path/to/env',
1186+
version: '3.11.0',
1187+
environmentPath: Uri.file('/path/to/env'),
1188+
execInfo: {
1189+
run: { executable, args: ['--env-arg'] },
1190+
},
1191+
sysPrefix: '/path/to/env',
1192+
};
1193+
}
1194+
1195+
test('should run PEP 723 script with uv run <script> and no --python flag', async () => {
1196+
const environment = makeEnv('/path/to/python');
1197+
const options: PythonTaskExecutionOptions = {
1198+
name: 'PEP 723 Task',
1199+
args: ['/workspace/pep723_script.py'],
1200+
};
1201+
1202+
mockGetWorkspaceFolder.returns(undefined);
1203+
mockShouldUseUv.resolves(true);
1204+
mockIsPep723Script.withArgs('/workspace/pep723_script.py').resolves(true);
1205+
mockQuoteStringIfNecessary.withArgs('uv').returns('uv');
1206+
mockExecuteTask.resolves({} as TaskExecution);
1207+
1208+
await runAsTask(environment, options);
1209+
1210+
const taskArg = mockExecuteTask.firstCall.args[0] as Task;
1211+
const execution = taskArg.execution as ShellExecution;
1212+
1213+
assert.strictEqual(execution.command, 'uv', 'Should run via uv');
1214+
assert.deepStrictEqual(
1215+
execution.args,
1216+
['run', '/workspace/pep723_script.py'],
1217+
'PEP 723 script should use uv run <script> with no --python and no env args',
1218+
);
1219+
assert.ok(
1220+
mockTraceInfo.calledWith(sinon.match(/PEP 723 script detected/)),
1221+
'Should log that PEP 723 was detected',
1222+
);
1223+
});
1224+
1225+
test('should pass extra user args after the script for PEP 723', async () => {
1226+
const environment = makeEnv('/path/to/python');
1227+
const options: PythonTaskExecutionOptions = {
1228+
name: 'PEP 723 Task With Args',
1229+
args: ['/workspace/pep723_script.py', '--verbose', '--output', 'out.txt'],
1230+
};
1231+
1232+
mockGetWorkspaceFolder.returns(undefined);
1233+
mockShouldUseUv.resolves(true);
1234+
mockIsPep723Script.withArgs('/workspace/pep723_script.py').resolves(true);
1235+
mockQuoteStringIfNecessary.withArgs('uv').returns('uv');
1236+
mockExecuteTask.resolves({} as TaskExecution);
1237+
1238+
await runAsTask(environment, options);
1239+
1240+
const execution = (mockExecuteTask.firstCall.args[0] as Task).execution as ShellExecution;
1241+
assert.deepStrictEqual(
1242+
execution.args,
1243+
['run', '/workspace/pep723_script.py', '--verbose', '--output', 'out.txt'],
1244+
'Extra user args should be appended after the script path for PEP 723',
1245+
);
1246+
});
1247+
1248+
test('should skip PEP 723 detection when first arg starts with a flag', async () => {
1249+
// When args[0] is a flag (e.g. -m), isPep723Script should not be called
1250+
const environment = makeEnv('/path/to/python');
1251+
const options: PythonTaskExecutionOptions = {
1252+
name: 'Module Task',
1253+
args: ['-m', 'pytest'],
1254+
};
1255+
1256+
mockGetWorkspaceFolder.returns(undefined);
1257+
mockShouldUseUv.resolves(true);
1258+
mockQuoteStringIfNecessary.withArgs('uv').returns('uv');
1259+
mockExecuteTask.resolves({} as TaskExecution);
1260+
1261+
await runAsTask(environment, options);
1262+
1263+
assert.ok(mockIsPep723Script.notCalled, 'Should not check PEP 723 when first arg is a flag');
1264+
1265+
const execution = (mockExecuteTask.firstCall.args[0] as Task).execution as ShellExecution;
1266+
// Should fall through to standard uv --python path
1267+
assert.strictEqual(execution.args?.[0], 'run');
1268+
assert.strictEqual(execution.args?.[1], '--python');
1269+
});
1270+
1271+
test('should use standard uv --python path for non-PEP 723 scripts', async () => {
1272+
// Standard .py file with no PEP 723 block → normal uv run --python behavior
1273+
const environment = makeEnv('/path/to/python');
1274+
const options: PythonTaskExecutionOptions = {
1275+
name: 'Standard Script Task',
1276+
args: ['/workspace/regular_script.py'],
1277+
};
1278+
1279+
mockGetWorkspaceFolder.returns(undefined);
1280+
mockShouldUseUv.resolves(true);
1281+
mockIsPep723Script.withArgs('/workspace/regular_script.py').resolves(false);
1282+
mockQuoteStringIfNecessary.withArgs('uv').returns('uv');
1283+
mockExecuteTask.resolves({} as TaskExecution);
1284+
1285+
await runAsTask(environment, options);
1286+
1287+
const execution = (mockExecuteTask.firstCall.args[0] as Task).execution as ShellExecution;
1288+
assert.strictEqual(execution.command, 'uv');
1289+
assert.deepStrictEqual(
1290+
execution.args,
1291+
['run', '--python', '/path/to/python', '--env-arg', '/workspace/regular_script.py'],
1292+
'Non-PEP 723 scripts should use uv run --python <interpreter> with env args',
1293+
);
1294+
});
1295+
1296+
test('should treat isPep723Script read error as non-PEP 723 (graceful fallback)', async () => {
1297+
const environment = makeEnv('/path/to/python');
1298+
const options: PythonTaskExecutionOptions = {
1299+
name: 'Unreadable Script Task',
1300+
args: ['/workspace/missing_script.py'],
1301+
};
1302+
1303+
mockGetWorkspaceFolder.returns(undefined);
1304+
mockShouldUseUv.resolves(true);
1305+
// isPep723Script returns false on read error (per implementation), but also verify
1306+
// that even if it somehow throws, runAsTask falls back gracefully
1307+
mockIsPep723Script.withArgs('/workspace/missing_script.py').resolves(false);
1308+
mockQuoteStringIfNecessary.withArgs('uv').returns('uv');
1309+
mockExecuteTask.resolves({} as TaskExecution);
1310+
1311+
await runAsTask(environment, options);
1312+
1313+
const execution = (mockExecuteTask.firstCall.args[0] as Task).execution as ShellExecution;
1314+
// Falls back to standard uv --python path
1315+
assert.strictEqual(execution.args?.[1], '--python', 'Should fall back to --python when script is unreadable');
1316+
});
1317+
});
11751318
});

0 commit comments

Comments
 (0)