Skip to content

Commit ba0913d

Browse files
Add PEP 723 inline-script setup CodeLens and bulk command (PEP 723 PR 11-12)
Behind the internal python-envs.inlineScripts.enabled flag (nothing added to package.json): a 'Set up environment for this script' CodeLens shown above the PEP 723 block only when saved metadata has no current inline environment, and a 'Set Up Environments for Inline Script Files' bulk command that scans the workspace, filters to inline-script files, and sets up the selected ones. Both reuse the existing create -> setEnvironment (register project + persist + publish) pipeline via a shared setUpInlineScriptEnvironment helper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 63ae981 commit ba0913d

6 files changed

Lines changed: 549 additions & 0 deletions

File tree

‎src/common/workspace.apis.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ export function findFiles(
5050
return workspace.findFiles(include, exclude, maxResults, token);
5151
}
5252

53+
export function asRelativePath(pathOrUri: string | Uri, includeWorkspaceFolder?: boolean): string {
54+
return workspace.asRelativePath(pathOrUri, includeWorkspaceFolder);
55+
}
56+
5357
export function createFileSystemWatcher(
5458
globPattern: GlobPattern,
5559
ignoreCreateEvents?: boolean,

‎src/extension.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import { PythonEnvironmentManagers } from './features/envManagers';
6969
import { EnvVarManager, PythonEnvVariableManager } from './features/execution/envVariableManager';
7070
import { latchInlineScriptFeatureActivation } from './features/inlineScript/activation';
7171
import { InlineScriptLazyDetector } from './features/inlineScript/lazyDetector';
72+
import { registerInlineScriptUx } from './features/inlineScript/setupEnvironment';
7273
import {
7374
applyInitialEnvironmentSelection,
7475
registerInterpreterSettingsChangeListener,
@@ -411,6 +412,7 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
411412
}),
412413
]
413414
: []),
415+
...(inlineScriptRouting ? registerInlineScriptUx(envManagers, inlineScriptRouting) : []),
414416
commands.registerCommand('python-envs.runInTerminal', (item) => {
415417
return runInTerminalCommand(item, api, terminalManager);
416418
}),
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import {
5+
CancellationToken,
6+
CodeLens,
7+
CodeLensProvider,
8+
Disposable,
9+
EventEmitter,
10+
l10n,
11+
languages,
12+
Range,
13+
TextDocument,
14+
} from 'vscode';
15+
import { InlineScriptRoutingRegistry } from '../../common/inlineScript/routingRegistry';
16+
17+
/**
18+
* Shows a single "Set up environment for this script" CodeLens above a `.py` file's PEP 723
19+
* `# /// script` block, but only when the file has saved inline metadata that is not currently
20+
* backed by a validated inline-script environment.
21+
*
22+
* The provider is a pure observer of {@link InlineScriptRoutingRegistry}:
23+
* - `getMetadata` returns the last saved metadata (the detector clears it while the metadata
24+
* region is dirty), so the lens tracks *saved* metadata and disappears while it is being edited.
25+
* - `shouldRoute` is true once a validated association matching the current metadata exists, so the
26+
* lens hides after setup and reappears if a metadata change later invalidates that association.
27+
*/
28+
export class InlineScriptCodeLensProvider implements CodeLensProvider, Disposable {
29+
private readonly _onDidChangeCodeLenses = new EventEmitter<void>();
30+
public readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event;
31+
private readonly subscriptions: Disposable[] = [];
32+
33+
constructor(
34+
private readonly routing: InlineScriptRoutingRegistry,
35+
private readonly setupCommand: string,
36+
) {
37+
this.subscriptions.push(
38+
this.routing.onDidChangeRouteability(() => this._onDidChangeCodeLenses.fire()),
39+
// Only metadata arriving or changing can add/replace a lens; a scan that finds no metadata
40+
// (the common case for ordinary .py files) needs no refresh. Hiding a lens for an
41+
// edited/removed block is handled by VS Code re-querying on the document change itself.
42+
this.routing.onDidChangeMetadata((e) => {
43+
if (e.metadata !== undefined) {
44+
this._onDidChangeCodeLenses.fire();
45+
}
46+
}),
47+
);
48+
}
49+
50+
public provideCodeLenses(document: TextDocument, _token: CancellationToken): CodeLens[] {
51+
if (document.isDirty) {
52+
// The association is validated against the saved file (the manager refuses to validate a
53+
// dirty document), so only offer setup for a clean document. This also avoids anchoring the
54+
// lens at a stale offset if the block moved on an unsaved edit.
55+
return [];
56+
}
57+
const uri = document.uri;
58+
const metadata = this.routing.getMetadata(uri);
59+
if (!metadata) {
60+
// No saved PEP 723 metadata (or it is currently being edited).
61+
return [];
62+
}
63+
if (this.routing.shouldRoute(uri)) {
64+
// A validated inline-script environment matching the current metadata already exists.
65+
return [];
66+
}
67+
const offset = metadata.sourceRange?.start ?? metadata.range.start;
68+
const position = document.positionAt(offset);
69+
const range = new Range(position, position);
70+
return [
71+
new CodeLens(range, {
72+
title: l10n.t('Set up environment for this script'),
73+
command: this.setupCommand,
74+
arguments: [uri],
75+
}),
76+
];
77+
}
78+
79+
public dispose(): void {
80+
this.subscriptions.forEach((s) => s.dispose());
81+
this.subscriptions.length = 0;
82+
this._onDidChangeCodeLenses.dispose();
83+
}
84+
}
85+
86+
/**
87+
* Register the inline-script CodeLens provider for local `.py` files. Only called when the PEP 723
88+
* inline-script feature flag is enabled, so it is a no-op for everyone else.
89+
*/
90+
export function registerInlineScriptCodeLens(routing: InlineScriptRoutingRegistry, setupCommand: string): Disposable {
91+
const provider = new InlineScriptCodeLensProvider(routing, setupCommand);
92+
const registration = languages.registerCodeLensProvider({ scheme: 'file', language: 'python' }, provider);
93+
return new Disposable(() => {
94+
registration.dispose();
95+
provider.dispose();
96+
});
97+
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import { commands, Disposable, l10n, QuickPickItem, Uri, window } from 'vscode';
5+
import { PythonEnvironment } from '../../api';
6+
import { INLINE_SCRIPT_MANAGER_ID } from '../../common/constants';
7+
import { InlineScriptRoutingRegistry } from '../../common/inlineScript/routingRegistry';
8+
import { readInlineScriptMetadataFromFile } from '../../common/inlineScript/metadata';
9+
import { traceError, traceInfo } from '../../common/logging';
10+
import { showErrorMessage, showInformationMessage, showQuickPickWithButtons } from '../../common/window.apis';
11+
import { asRelativePath, findFiles } from '../../common/workspace.apis';
12+
import { EnvironmentManagers } from '../../internal.api';
13+
import { registerInlineScriptCodeLens } from './codeLens';
14+
15+
/**
16+
* Hidden command invoked by the inline-script CodeLens to set up the environment for one script.
17+
* Intentionally not contributed in `package.json` while the feature is behind the internal flag.
18+
*/
19+
export const SETUP_INLINE_SCRIPT_ENV_COMMAND = 'python-envs.setupInlineScriptEnv';
20+
21+
/**
22+
* Hidden command that scans the workspace and sets up environments for the selected inline-script
23+
* files. Intentionally not contributed in `package.json` while the feature is behind the internal flag.
24+
*/
25+
export const SETUP_INLINE_SCRIPT_ENVS_COMMAND = 'python-envs.setupInlineScriptEnvs';
26+
27+
/** Upper bound on the number of `.py` files the bulk command scans. */
28+
const MAX_INLINE_SCRIPT_FILES = 1000;
29+
30+
/** How many candidate files' PEP 723 headers are read concurrently during the bulk scan. */
31+
const METADATA_READ_CONCURRENCY = 20;
32+
33+
/**
34+
* Create or reuse the inline-script environment for `scriptUri` and make it the file's environment.
35+
*
36+
* Reuses the pipeline built by earlier PEP 723 PRs:
37+
* - `manager.create` builds or reuses the cached environment from the script's PEP 723 metadata
38+
* (selecting or, with consent, installing a compatible base interpreter);
39+
* - `setEnvironment` persists the association, registers the exact script project, and publishes the
40+
* per-file environment change so routing picks up the inline environment.
41+
*
42+
* Returns the environment on success, or `undefined` when creation produced none (the manager has
43+
* already surfaced the reason — a declined install, no compatible Python, or a cancelled/failed
44+
* build — and emitted telemetry).
45+
*/
46+
export async function setUpInlineScriptEnvironment(
47+
scriptUri: Uri,
48+
em: EnvironmentManagers,
49+
routing: InlineScriptRoutingRegistry,
50+
): Promise<PythonEnvironment | undefined> {
51+
const manager = em.getEnvironmentManager(INLINE_SCRIPT_MANAGER_ID);
52+
if (!manager) {
53+
traceError('Inline-script setup requested but the inline-script environment manager is not registered.');
54+
return undefined;
55+
}
56+
const metadataIdentityBeforeCreate = routing.getMetadataIdentity(scriptUri);
57+
const environment = await manager.create(scriptUri, undefined);
58+
if (!environment) {
59+
return undefined;
60+
}
61+
if (routing.getMetadataIdentity(scriptUri) !== metadataIdentityBeforeCreate) {
62+
// The script's saved metadata changed while the environment was being built, so this
63+
// environment was built for stale metadata. Skip associating it rather than overwrite a newer
64+
// setup; the current metadata's CodeLens stays so the user can run setup again.
65+
traceInfo(`Inline-script metadata for ${scriptUri.fsPath} changed during setup; skipping association.`);
66+
return undefined;
67+
}
68+
await em.setEnvironment(scriptUri, environment);
69+
return environment;
70+
}
71+
72+
function setupInlineScriptEnvironmentHandler(
73+
em: EnvironmentManagers,
74+
routing: InlineScriptRoutingRegistry,
75+
): (scriptUri?: Uri) => Promise<void> {
76+
return async (scriptUri?: Uri): Promise<void> => {
77+
const uri = scriptUri ?? window.activeTextEditor?.document.uri;
78+
if (!uri || uri.scheme !== 'file') {
79+
return;
80+
}
81+
if (!em.getEnvironmentManager(INLINE_SCRIPT_MANAGER_ID)) {
82+
showErrorMessage(l10n.t('The inline script environment manager is not available yet. Try again shortly.'));
83+
return;
84+
}
85+
try {
86+
await setUpInlineScriptEnvironment(uri, em, routing);
87+
} catch (error) {
88+
traceError(`Failed to set up the inline-script environment for ${uri.fsPath}:`, error);
89+
showErrorMessage(
90+
l10n.t(
91+
'Failed to set up the environment for this script. See the Python Environments output for details.',
92+
),
93+
);
94+
}
95+
};
96+
}
97+
98+
interface InlineScriptQuickPickItem extends QuickPickItem {
99+
readonly uri: Uri;
100+
readonly configured: boolean;
101+
}
102+
103+
export async function setUpInlineScriptEnvironmentsInWorkspace(
104+
em: EnvironmentManagers,
105+
routing: InlineScriptRoutingRegistry,
106+
): Promise<void> {
107+
if (!em.getEnvironmentManager(INLINE_SCRIPT_MANAGER_ID)) {
108+
showErrorMessage(l10n.t('The inline script environment manager is not available yet. Try again shortly.'));
109+
return;
110+
}
111+
const files = await findFiles('**/*.py', '{**/.venv/**,**/node_modules/**}', MAX_INLINE_SCRIPT_FILES);
112+
if (!files || files.length === 0) {
113+
showInformationMessage(l10n.t('No Python files were found in the workspace.'));
114+
return;
115+
}
116+
const candidates = await filterInlineScriptFiles(files);
117+
if (candidates.length === 0) {
118+
showInformationMessage(
119+
l10n.t('No Python files with PEP 723 inline script metadata were found in the workspace.'),
120+
);
121+
return;
122+
}
123+
const items: InlineScriptQuickPickItem[] = candidates
124+
.map((uri) => {
125+
const configured = routing.shouldRoute(uri);
126+
return {
127+
label: asRelativePath(uri),
128+
description: configured ? l10n.t('environment already set up') : undefined,
129+
uri,
130+
configured,
131+
};
132+
})
133+
.sort((a, b) => a.label.localeCompare(b.label));
134+
// Pre-select only scripts that do not already have a validated inline environment, so that
135+
// accepting the picker never silently rebuilds or replaces an already-configured script's
136+
// environment (which could otherwise move it onto a newer base interpreter).
137+
const preselected = items.filter((item) => !item.configured);
138+
const selection = await showQuickPickWithButtons(items, {
139+
canPickMany: true,
140+
ignoreFocusOut: true,
141+
title: l10n.t('Set Up Environments for Inline Script Files'),
142+
placeHolder: l10n.t('Select the scripts to set up environments for'),
143+
selected: preselected,
144+
});
145+
const picks = Array.isArray(selection) ? selection : selection ? [selection] : [];
146+
if (picks.length === 0) {
147+
return;
148+
}
149+
let succeeded = 0;
150+
for (const pick of picks) {
151+
try {
152+
if (await setUpInlineScriptEnvironment(pick.uri, em, routing)) {
153+
succeeded += 1;
154+
}
155+
} catch (error) {
156+
traceError(`Failed to set up the inline-script environment for ${pick.uri.fsPath}:`, error);
157+
}
158+
}
159+
traceInfo(`Inline-script bulk setup: created or reused ${succeeded} of ${picks.length} environment(s).`);
160+
showInformationMessage(l10n.t('Set up {0} of {1} selected inline script environment(s).', succeeded, picks.length));
161+
}
162+
163+
/**
164+
* Read the head of each candidate file (bounded concurrency) and keep only those that declare a
165+
* PEP 723 `# /// script` block.
166+
*/
167+
async function filterInlineScriptFiles(files: readonly Uri[]): Promise<Uri[]> {
168+
const candidates: Uri[] = [];
169+
for (let index = 0; index < files.length; index += METADATA_READ_CONCURRENCY) {
170+
const chunk = files.slice(index, index + METADATA_READ_CONCURRENCY);
171+
const results = await Promise.all(
172+
chunk.map(async (uri) => ((await readInlineScriptMetadataFromFile(uri)) ? uri : undefined)),
173+
);
174+
for (const uri of results) {
175+
if (uri) {
176+
candidates.push(uri);
177+
}
178+
}
179+
}
180+
return candidates;
181+
}
182+
183+
/**
184+
* Register the inline-script user-facing surfaces (the CodeLens and its setup commands). Only called
185+
* when the PEP 723 inline-script feature flag is enabled; the commands are intentionally hidden from
186+
* `package.json` for now.
187+
*/
188+
export function registerInlineScriptUx(em: EnvironmentManagers, routing: InlineScriptRoutingRegistry): Disposable[] {
189+
return [
190+
registerInlineScriptCodeLens(routing, SETUP_INLINE_SCRIPT_ENV_COMMAND),
191+
commands.registerCommand(SETUP_INLINE_SCRIPT_ENV_COMMAND, setupInlineScriptEnvironmentHandler(em, routing)),
192+
commands.registerCommand(SETUP_INLINE_SCRIPT_ENVS_COMMAND, () =>
193+
setUpInlineScriptEnvironmentsInWorkspace(em, routing),
194+
),
195+
];
196+
}

0 commit comments

Comments
 (0)