Skip to content

Commit 06a02df

Browse files
feat: offer inline-script env setup as an unresolved-import quick fix
The only discovery surface for PEP 723 inline-script environment setup was a CodeLens, and codeLens.ts hides it whenever the document is dirty. At the moment a user types `import requests` and sees a red squiggle, the CodeLens is gone, so users who do not already know about PEP 723 never find the feature. Register a CodeActionProvider that offers "Set up this script's Python environment" when an unresolved-import diagnostic sits in a `.py` file that declares a `# /// script` block and has no inline-script environment yet. The provider parses the in-memory buffer, so it works while dirty, and the command handler saves the document before setup because the environment manager reads metadata from disk. Gates run cheapest-first, before any parsing, because provideCodeActions can fire on cursor movement: feature flag, then a matching diagnostic, then the routing key, then shouldRoute, and only then the header parse. Diagnostics are matched on `code` only, never `source` (Pyrefly-backed Pylance reports its source as the literal "pylance + pyrefly"). Pyright, Ty, Pyrefly and mypy dialects are all covered, including the {value, target} object form of Diagnostic.code. reportMissingModuleSource is included deliberately, unlike in Pylance's own isMissingImportDiagnostic: a stub without source means the package is not installed, which setup fixes. The action deliberately leaves `diagnostics` and `isPreferred` unset. Setup installs the block's declared dependencies verbatim and may not resolve the import at all, so the action must not claim to fix the diagnostic or pre-empt a real import fix. The title promises environment setup, nothing more. Setup also now seeds routing metadata after saving a dirty document. Without it a just-typed block goes from no metadata to an identity while `create` runs, which setUpInlineScriptEnvironment reads as a concurrent edit and silently skips the association. The companion-extension prompt moves out of the setup try block so a failure there is no longer reported to the user as a setup failure. The feature stays behind the internal python-envs.inlineScripts.enabled flag, so none of this is user-visible yet. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent a48cf3a commit 06a02df

5 files changed

Lines changed: 553 additions & 16 deletions

File tree

‎src/common/localize.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ export namespace WorkbenchStrings {
2626
export namespace InlineScriptStrings {
2727
export const updateExtension = l10n.t('Update Extension');
2828

29+
export const setUpScriptEnvironment = l10n.t("Set up this script's Python environment");
30+
31+
export const saveFailedBeforeSetup = l10n.t(
32+
'Could not save this script, so its environment was not set up. Save the file and try again.',
33+
);
34+
2935
export const updatePythonExtension = l10n.t(
3036
'The environment for this script was created. Update the Python extension for the full inline script experience.',
3137
);
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import {
5+
CancellationToken,
6+
CodeAction,
7+
CodeActionContext,
8+
CodeActionKind,
9+
CodeActionProvider,
10+
Diagnostic,
11+
Disposable,
12+
languages,
13+
Range,
14+
TextDocument,
15+
} from 'vscode';
16+
import { readInlineScriptMetadata, sliceHeaderBytes } from '../../common/inlineScript/metadata';
17+
import { getInlineScriptRoutingKey, InlineScriptRoutingRegistry } from '../../common/inlineScript/routingRegistry';
18+
import { InlineScriptStrings } from '../../common/localize';
19+
import { isInlineScriptsFeatureEnabled } from '../../helpers';
20+
21+
/**
22+
* Diagnostic codes meaning "this import did not resolve", lowercased for comparison.
23+
*
24+
* `reportMissingModuleSource` is included deliberately, unlike in Pylance's own
25+
* `isMissingImportDiagnostic`: a stub without source means the package is not installed, which
26+
* setting the script's environment up fixes.
27+
*/
28+
const UNRESOLVED_IMPORT_DIAGNOSTIC_CODES: ReadonlySet<string> = new Set([
29+
// Pyright / Pylance / basedpyright.
30+
'reportmissingimports',
31+
'reportmissingmodulesource',
32+
// Ty.
33+
'unresolved-import',
34+
'possibly-missing-import',
35+
// Pyrefly.
36+
'missing-import',
37+
'missing-source',
38+
'missing-source-for-stubs',
39+
// mypy, via ms-python.mypy-type-checker.
40+
'import-not-found',
41+
'import-untyped',
42+
]);
43+
44+
function normalizeDiagnosticCode(code: Diagnostic['code']): string | undefined {
45+
if (code === undefined || code === null) {
46+
return undefined;
47+
}
48+
const value = typeof code === 'object' ? code.value : code;
49+
return typeof value === 'string' || typeof value === 'number' ? String(value).toLowerCase() : undefined;
50+
}
51+
52+
/**
53+
* Whether `diagnostic` reports an import that could not be resolved. Matches on `code`, never on
54+
* `source`: Pyrefly-backed Pylance reports its source as the literal string `pylance + pyrefly`.
55+
*/
56+
export function isUnresolvedImportDiagnostic(diagnostic: Diagnostic): boolean {
57+
const code = normalizeDiagnosticCode(diagnostic.code);
58+
return code !== undefined && UNRESOLVED_IMPORT_DIAGNOSTIC_CODES.has(code);
59+
}
60+
61+
/**
62+
* Offers "Set up this script's Python environment" as a quick fix on an unresolved import in a `.py`
63+
* file that declares a PEP 723 `# /// script` block and has no inline-script environment yet.
64+
*
65+
* Complements the CodeLens, which is hidden while the document is dirty — the moment a user has just
66+
* typed the import that does not resolve. This provider parses the in-memory buffer instead.
67+
*
68+
* `diagnostics` and `isPreferred` are both left unset: setup installs the block's declared
69+
* dependencies verbatim and may not resolve the import at all, so the action must not claim to fix
70+
* the diagnostic or pre-empt a real import fix.
71+
*/
72+
export class InlineScriptSetupCodeActionProvider implements CodeActionProvider {
73+
constructor(
74+
private readonly routing: InlineScriptRoutingRegistry,
75+
private readonly setupCommand: string,
76+
) {}
77+
78+
/** Gates run cheapest-first, and before any parsing: VS Code may call this on every cursor move. */
79+
public provideCodeActions(
80+
document: TextDocument,
81+
_range: Range,
82+
context: CodeActionContext,
83+
_token: CancellationToken,
84+
): CodeAction[] {
85+
if (!isInlineScriptsFeatureEnabled()) {
86+
return [];
87+
}
88+
if (!context.diagnostics.some(isUnresolvedImportDiagnostic)) {
89+
return [];
90+
}
91+
const uri = document.uri;
92+
if (!getInlineScriptRoutingKey(uri)) {
93+
return [];
94+
}
95+
if (this.routing.shouldRoute(uri)) {
96+
return [];
97+
}
98+
if (!readInlineScriptMetadata(sliceHeaderBytes(document.getText()), uri.fsPath)) {
99+
return [];
100+
}
101+
const action = new CodeAction(InlineScriptStrings.setUpScriptEnvironment, CodeActionKind.QuickFix);
102+
action.command = {
103+
title: InlineScriptStrings.setUpScriptEnvironment,
104+
command: this.setupCommand,
105+
arguments: [uri],
106+
};
107+
return [action];
108+
}
109+
}
110+
111+
/** Register the inline-script quick fix for local `.py` files. */
112+
export function registerInlineScriptSetupCodeAction(
113+
routing: InlineScriptRoutingRegistry,
114+
setupCommand: string,
115+
): Disposable {
116+
return languages.registerCodeActionsProvider(
117+
{ scheme: 'file', language: 'python' },
118+
new InlineScriptSetupCodeActionProvider(routing, setupCommand),
119+
{ providedCodeActionKinds: [CodeActionKind.QuickFix] },
120+
);
121+
}

‎src/features/inlineScript/setupEnvironment.ts‎

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

4-
import { commands, Disposable, l10n, QuickPickItem, Uri, window } from 'vscode';
4+
import { commands, Disposable, l10n, QuickPickItem, TextDocument, Uri, window } from 'vscode';
55
import { PythonEnvironment } from '../../api';
66
import { INLINE_SCRIPT_MANAGER_ID } from '../../common/constants';
77
import { readInlineScriptMetadataFromFile } from '../../common/inlineScript/metadata';
88
import { InlineScriptRoutingRegistry } from '../../common/inlineScript/routingRegistry';
9-
import { traceError, traceInfo } from '../../common/logging';
9+
import { InlineScriptStrings } from '../../common/localize';
10+
import { traceError, traceInfo, traceVerbose } from '../../common/logging';
1011
import { normalizePath } from '../../common/utils/pathUtils';
1112
import {
1213
showErrorMessage,
@@ -18,6 +19,7 @@ import { asRelativePath, findFiles, getOpenTextDocuments } from '../../common/wo
1819
import { EnvironmentManagers } from '../../internal.api';
1920
import { registerInlineScriptCodeLens } from './codeLens';
2021
import { promptUpdateExtensionsForInlineScripts } from './extensionVersionCheck';
22+
import { registerInlineScriptSetupCodeAction } from './setupCodeAction';
2123

2224
/**
2325
* Hidden command invoked by the inline-script CodeLens to set up the environment for one script.
@@ -86,20 +88,50 @@ async function seedRoutingMetadataForClosedScript(scriptUri: Uri, routing: Inlin
8688
if (routing.getMetadata(scriptUri)) {
8789
return;
8890
}
91+
if (findOpenDocument(scriptUri)) {
92+
return;
93+
}
94+
const metadata = await readInlineScriptMetadataFromFile(scriptUri);
95+
if (metadata && !routing.getMetadata(scriptUri)) {
96+
routing.setMetadata(scriptUri, metadata);
97+
}
98+
}
99+
100+
/** The open text document backing `scriptUri`, if the user has it open. */
101+
function findOpenDocument(scriptUri: Uri): TextDocument | undefined {
89102
const scriptPath = normalizePath(scriptUri.fsPath);
90-
const isOpen = getOpenTextDocuments().some(
103+
return getOpenTextDocuments().find(
91104
(document) => document.uri.scheme === 'file' && normalizePath(document.uri.fsPath) === scriptPath,
92105
);
93-
if (isOpen) {
94-
return;
106+
}
107+
108+
/**
109+
* Save `scriptUri` if it is open with unsaved changes, so setup reads what the user actually sees.
110+
*
111+
* Returns `false` when the document could not be saved; setup must not run in that case.
112+
*/
113+
async function saveScriptBeforeSetup(scriptUri: Uri, routing: InlineScriptRoutingRegistry): Promise<boolean> {
114+
const document = findOpenDocument(scriptUri);
115+
if (!document?.isDirty) {
116+
return true;
117+
}
118+
if (!(await document.save())) {
119+
traceError(`Could not save ${scriptUri.fsPath} before setting up its inline-script environment.`);
120+
return false;
95121
}
122+
traceVerbose(`Saved ${scriptUri.fsPath} before setting up its inline-script environment.`);
123+
// Seeding here is load-bearing: without it a just-typed block goes from no metadata to an
124+
// identity while `create` runs, which `setUpInlineScriptEnvironment` reads as a concurrent edit
125+
// and silently skips the association.
96126
const metadata = await readInlineScriptMetadataFromFile(scriptUri);
97-
if (metadata && !routing.getMetadata(scriptUri)) {
127+
if (metadata) {
98128
routing.setMetadata(scriptUri, metadata);
99129
}
130+
return true;
100131
}
101132

102-
function setupInlineScriptEnvironmentHandler(
133+
/** Handler for the single-file setup command, shared by the CodeLens and the quick fix. */
134+
export function setupInlineScriptEnvironmentHandler(
103135
em: EnvironmentManagers,
104136
routing: InlineScriptRoutingRegistry,
105137
): (scriptUri?: Uri) => Promise<void> {
@@ -112,21 +144,31 @@ function setupInlineScriptEnvironmentHandler(
112144
showErrorMessage(l10n.t('The inline script environment manager is not available yet. Try again shortly.'));
113145
return;
114146
}
147+
if (!(await saveScriptBeforeSetup(uri, routing))) {
148+
showErrorMessage(InlineScriptStrings.saveFailedBeforeSetup);
149+
return;
150+
}
151+
let environment: PythonEnvironment | undefined;
115152
try {
116-
const environment = await setUpInlineScriptEnvironment(uri, em, routing);
117-
if (!environment) {
118-
notifyInlineScriptSetupOutcome(uri, routing);
119-
return;
120-
}
121-
await promptUpdateExtensionsForInlineScripts();
153+
environment = await setUpInlineScriptEnvironment(uri, em, routing);
122154
} catch (error) {
123155
traceError(`Failed to set up the inline-script environment for ${uri.fsPath}:`, error);
124156
showErrorMessage(
125157
l10n.t(
126158
'Failed to set up the environment for this script. See the Python Environments output for details.',
127159
),
128160
);
161+
return;
162+
}
163+
if (!environment) {
164+
notifyInlineScriptSetupOutcome(uri, routing);
165+
return;
129166
}
167+
// Kept out of the try: the environment is already set up, so a failure in this follow-up
168+
// must not be reported to the user as a setup failure.
169+
await promptUpdateExtensionsForInlineScripts().catch((error) =>
170+
traceError('Failed to check companion extension versions for inline scripts:', error),
171+
);
130172
};
131173
}
132174

@@ -314,13 +356,15 @@ async function filterInlineScriptFiles(files: readonly Uri[]): Promise<Uri[]> {
314356
}
315357

316358
/**
317-
* Register the inline-script user-facing surfaces (the CodeLens and its setup commands). Only called
318-
* when the PEP 723 inline-script feature flag is enabled. The single-file setup command is invoked by
319-
* the CodeLens and stays out of `package.json`; the bulk command is palette-gated behind the flag.
359+
* Register the inline-script user-facing surfaces (the CodeLens, the quick fix, and their setup
360+
* commands). Only called when the PEP 723 inline-script feature flag is enabled. The single-file
361+
* setup command is invoked by both surfaces and stays out of `package.json`; the bulk command is
362+
* palette-gated behind the flag.
320363
*/
321364
export function registerInlineScriptUx(em: EnvironmentManagers, routing: InlineScriptRoutingRegistry): Disposable[] {
322365
return [
323366
registerInlineScriptCodeLens(routing, SETUP_INLINE_SCRIPT_ENV_COMMAND),
367+
registerInlineScriptSetupCodeAction(routing, SETUP_INLINE_SCRIPT_ENV_COMMAND),
324368
commands.registerCommand(SETUP_INLINE_SCRIPT_ENV_COMMAND, setupInlineScriptEnvironmentHandler(em, routing)),
325369
commands.registerCommand(SETUP_INLINE_SCRIPT_ENVS_COMMAND, () =>
326370
setUpInlineScriptEnvironmentsInWorkspace(em, routing),

0 commit comments

Comments
 (0)