Skip to content
Merged
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
1 change: 1 addition & 0 deletions .github/instructions/testing-workflow.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ This guide covers the full testing lifecycle:
- Pip commands that return JSON must pass `--disable-pip-version-check`; the process helper combines stderr with stdout, so update notices can otherwise make valid JSON unparseable (1).
- When a view subscribes to a newly added provider event, TypeMoq-based view tests must return a real `EventEmitter.event`; an unstubbed event yields an undefined disposable and fails during teardown (1).
- Test agent selections across a full reload with `python.defaultInterpreterPath` set. An effective manager value equal to the extension default does not prove a workspace value was saved; tool-owned persistence must inspect `workspaceValue` or startup can restore the global interpreter (1).
- `vscode.executeCodeLensProvider` verifies provider output, not visible editor refresh. VS Code cancels its debounced CodeLens refresh when the editor loses focus; account for background UI automation restoring focus to another app before diagnosing a stale rendered lens (1).

### When to Use This Guide

Expand Down
4 changes: 3 additions & 1 deletion docs/managing-python-projects.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,9 @@ When you create a script, the extension generates a single `.py` file with PEP 7

An inline-script environment is built from the script's `# /// script` block and stored in the extension's cache, where it is shared by every script with the same dependencies and base interpreter. Because editing one would silently change the others, these environments are not user-managed: the Python Environments views do not offer install, uninstall, or version-change actions for them. Their package list remains visible.

A CodeLens above the `# /// script` block offers **Set up environment for this script**, and the same action is available as a quick fix on an unresolved import. For a few seconds after setup succeeds it is replaced by a **Script environment ready (Python X.Y.Z)** confirmation naming the Python that was selected — useful when `requires-python` matches several installed versions, or when one was installed on demand. The confirmation is plain text rather than a clickable action, and it expires on its own; at every other time the setup CodeLens behaves exactly as before.
A CodeLens stays above the `# /// script` block, including while its metadata is incomplete, malformed, or unsaved. It offers **Set up environment for this script** until the block matches a validated environment, then displays **Script environment ready (Python X.Y.Z)** as persistent, non-clickable text. The ready label also appears for environments restored after reopening VS Code.

The CodeLens follows the live block text: editing the block offers setup immediately without requiring a save, while editing Python code outside it does not change its fingerprint. Diagnostics continue to explain malformed metadata as you type; a warning popup appears only if you try to set up an invalid block. Setup saves valid unsaved changes before creating the environment. Execution and interpreter routing still use validated, saved metadata, and saving can restore a matching existing association without rebuilding it.

Setup records which distributions it installed. If that record and the environment's contents later disagree — for example after installing a package into it from a terminal — every script sharing the environment needs setup again. Saving or reopening a script does not repair it; use the script's setup action to rebuild from its declared dependencies.

Expand Down
26 changes: 26 additions & 0 deletions src/common/inlineScript/block.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

export interface InlineScriptBlock {
readonly start: number;
readonly end: number;
}

/**
* Locate the first recognizable script opener and its next closing marker, without validation.
* Incomplete blocks are included so editor actions remain available while typing.
* This approximate presentation range must not be used for metadata validation or fingerprints.
*/
export function findInlineScriptBlock(text: string): InlineScriptBlock | undefined {
const opener = /^[\t \uFEFF]*#[\t ]*\/\/\/[\t ]+script\b[^\r\n]*/gm.exec(text);
if (!opener) {
return undefined;
}
const closerPattern = /^[\t ]*#[\t ]*\/\/\/[\t ]*$/gm;
closerPattern.lastIndex = opener.index + opener[0].length;
const closer = closerPattern.exec(text);
return {
start: opener.index + (opener.index === 0 && text.startsWith('\uFEFF') ? 1 : 0),
end: closer ? closer.index + closer[0].length : text.length,
};
}
180 changes: 126 additions & 54 deletions src/common/inlineScript/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT License.

import * as tomljs from '@iarna/toml';
import { createHash } from 'crypto';
import * as fs from 'fs/promises';
import { l10n, Uri } from 'vscode';
import { traceVerbose, traceWarn } from '../logging';
Expand Down Expand Up @@ -29,6 +30,8 @@ export interface InlineScriptMetadata {
*/
readonly range: { readonly start: number; readonly end: number };
readonly sourceRange?: { readonly start: number; readonly end: number };
/** Saved block fingerprint for edit detection and presentation, separate from semantic/cache identity. */
readonly sourceHash?: string;
}

/**
Expand Down Expand Up @@ -70,6 +73,24 @@ export type InlineScriptMetadataParseResult =
| { readonly kind: 'none' }
| { readonly kind: 'invalid'; readonly problems: readonly InlineScriptMetadataProblem[] };

/**
* Explain why parsed metadata cannot be used by the interactive setup action.
* This presentation check does not change the parser's tolerant metadata or routing contract.
*/
export function getInlineScriptSetupProblem(
result: InlineScriptMetadataParseResult,
): 'metadata' | 'requires-python' | undefined {
if (
result.kind !== 'parsed' ||
result.problems.length > 0 ||
result.metadata.dependencies?.some((dependency) => dependency.trim().length === 0)
) {
return 'metadata';
}
const requirement = result.metadata.requiresPython?.trim();
return requirement && !PythonVersionSpecifier.tryParse(requirement) ? 'requires-python' : undefined;
}

/**
* Canonical block regex from the PEP 723 spec, translated to JavaScript
* (Python's `(?P<name>...)` becomes `(?<name>...)` in JS). The flag
Expand Down Expand Up @@ -122,6 +143,57 @@ const NO_METADATA: InlineScriptMetadataParseResult = { kind: 'none' };

const OPENER_PREFIX = '# /// ';

/**
* Fingerprint the same structural script block selected by the metadata parser, without parsing TOML,
* logging, or I/O. Malformed markers return undefined; ignored body examples do not affect the hash.
* Normalize BOM/line endings as the parser does so disk and editor representations compare equally.
*/
export function getInlineScriptSourceHash(scriptText: string): string | undefined {
const text = scriptText.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
const { scriptMatches, problems } = scanInlineScriptBlocks(
text,
(start, end) => ({ start, end }),
'',
false,
);
return scriptMatches.length === 1 && problems.length === 0
? hashScriptBlock(scriptMatches[0][0])
: undefined;
}

function hashScriptBlock(block: string): string {
return createHash('sha256').update(block, 'utf8').digest('hex');
}

function scanInlineScriptBlocks(
text: string,
toSourceRange: (start: number, end: number) => { start: number; end: number },
where: string,
logProblems = true,
): { scriptMatches: RegExpMatchArray[]; problems: InlineScriptMetadataProblem[] } {
const scriptMatches: RegExpMatchArray[] = [];
// matchAll leaves the shared regex's lastIndex untouched.
for (const match of text.matchAll(BLOCK_RE)) {
if (match.groups?.type === 'script') {
scriptMatches.push(match);
}
}
const matchedRanges = scriptMatches.map((match) => ({ start: match.index!, end: match.index! + match[0].length }));
const problems: InlineScriptMetadataProblem[] = [];
const headerEnd = headerRegionEnd(text);
for (const opener of findScriptOpeners(text)) {
if (matchedRanges.some((range) => opener.offset >= range.start && opener.offset < range.end)) {
continue;
}
const { problem, ignorableBelowHeader } = diagnoseMalformedBlock(text, opener, toSourceRange, where, logProblems);
if (ignorableBelowHeader && opener.offset >= headerEnd) {
continue;
}
problems.push(problem);
}
return { scriptMatches, problems };
}

/** As `readInlineScriptMetadata`, but reports why and where parsing failed. Offsets index the original `scriptText`. */
export function parseInlineScriptMetadata(scriptText: string, source?: string): InlineScriptMetadataParseResult {
const where = source ? ` in ${source}` : '';
Expand All @@ -147,37 +219,7 @@ export function parseInlineScriptMetadata(scriptText: string, source?: string):
end: bomOffset + sourceOffsetForNormalizedOffset(sourceText, end),
});

// Collect ALL matches first so we can detect the "multiple script
// blocks" error case the spec requires us to surface.
//
// `matchAll` constructs a fresh iterator and does not mutate the
// shared `BLOCK_RE.lastIndex`, so this loop is re-entrant and safe
// even if a caller (or an exception) ever interrupts a previous
// pass.
const scriptMatches: RegExpMatchArray[] = [];
for (const m of text.matchAll(BLOCK_RE)) {
// Per spec, tools MUST NOT read non-standardized block types.
// The only standardized type today is `script`.
if (m.groups?.type === 'script') {
scriptMatches.push(m);
}
}

const matchedRanges = scriptMatches.map((m) => ({ start: m.index!, end: m.index! + m[0].length }));
const problems: InlineScriptMetadataProblem[] = [];
const headerEnd = headerRegionEnd(text);
for (const opener of findScriptOpeners(text)) {
if (matchedRanges.some((r) => opener.offset >= r.start && opener.offset < r.end)) {
continue;
}
const { problem, ignorableBelowHeader } = diagnoseMalformedBlock(text, opener, toSourceRange, where);
// Blocks the spec tells us to ignore are only worth flagging in the leading comment
// region, where they are a header being typed rather than a documentation example.
if (ignorableBelowHeader && opener.offset >= headerEnd) {
continue;
}
problems.push(problem);
}
const { scriptMatches, problems } = scanInlineScriptBlocks(text, toSourceRange, where);

if (scriptMatches.length === 0) {
if (problems.length === 0) {
Expand Down Expand Up @@ -368,7 +410,7 @@ export function parseInlineScriptMetadata(scriptText: string, source?: string):
end += 1;
}

return {
const result: InlineScriptMetadataParseResult = {
kind: 'parsed',
problems,
metadata: {
Expand All @@ -379,6 +421,13 @@ export function parseInlineScriptMetadata(scriptText: string, source?: string):
sourceRange: toSourceRange(matchStart, end),
},
};
return {
...result,
metadata: {
...result.metadata,
sourceHash: getInlineScriptSetupProblem(result) === undefined ? hashScriptBlock(match[0]) : undefined,
},
};
}

/** 1-based line number of `offset` within LF-normalized `text`. */
Expand Down Expand Up @@ -447,13 +496,16 @@ function diagnoseMalformedBlock(
opener: ScriptOpener,
toSourceRange: (start: number, end: number) => { start: number; end: number },
where: string,
logProblems: boolean,
): MalformedBlockDiagnosis {
const openerRange = toSourceRange(opener.offset, opener.lineEnd);

if (opener.trailing.length > 0) {
traceWarn(
`inline script metadata${where}: the \`# /// script\` marker on line ${countLines(text, opener.offset)} has trailing whitespace`,
);
if (logProblems) {
traceWarn(
`inline script metadata${where}: the \`# /// script\` marker on line ${countLines(text, opener.offset)} has trailing whitespace`,
);
}
return {
ignorableBelowHeader: false,
problem: {
Expand All @@ -475,9 +527,11 @@ function diagnoseMalformedBlock(
}

if (line !== line.trimEnd() && line.trimEnd() === CLOSER_LINE) {
traceWarn(
`inline script metadata${where}: the closing \`# ///\` marker on line ${countLines(text, offset)} has trailing whitespace`,
);
if (logProblems) {
traceWarn(
`inline script metadata${where}: the closing \`# ///\` marker on line ${countLines(text, offset)} has trailing whitespace`,
);
}
return {
ignorableBelowHeader: false,
problem: {
Expand All @@ -494,10 +548,12 @@ function diagnoseMalformedBlock(
// closing marker is still ahead the author wrote a real block around a bad line.
const isComment = line.startsWith('#');
if (isComment || hasCloserAhead(text, lineEnd)) {
traceWarn(
`inline script metadata${where}: invalid content line ${countLines(text, offset)} ` +
`(expected '#' or '# '): ${JSON.stringify(line)}`,
);
if (logProblems) {
traceWarn(
`inline script metadata${where}: invalid content line ${countLines(text, offset)} ` +
`(expected '#' or '# '): ${JSON.stringify(line)}`,
);
}
return {
ignorableBelowHeader: !isComment,
problem: {
Expand All @@ -520,9 +576,11 @@ function diagnoseMalformedBlock(
offset = lineEnd + 1;
}

traceWarn(
`inline script metadata${where}: the \`# /// script\` block on line ${countLines(text, opener.offset)} is missing its closing \`# ///\` marker`,
);
if (logProblems) {
traceWarn(
`inline script metadata${where}: the \`# /// script\` block on line ${countLines(text, opener.offset)} is missing its closing \`# ///\` marker`,
);
}
return {
ignorableBelowHeader: true,
problem: { code: 'unterminated-block', severity: 'warning', sourceRange: openerRange },
Expand Down Expand Up @@ -661,6 +719,28 @@ export async function readInlineScriptMetadataFromFile(
uri: Uri,
strict = false,
): Promise<InlineScriptMetadata | undefined> {
const text = await readInlineScriptHeaderFromFile(uri, strict);
if (text === undefined) {
return undefined;
}
const result = parseInlineScriptMetadata(text, uri.fsPath);
if (
strict &&
(result.kind === 'invalid' ||
(result.kind === 'parsed' && result.problems.some((problem) => problem.severity === 'error')))
) {
throw new Error(l10n.t('Fix the PEP 723 metadata in {0} before configuring its environment.', uri.fsPath));
}
return result.kind === 'parsed' ? result.metadata : undefined;
}

/**
* Read at most MAX_HEADER_BYTES of a local script as UTF-8, without parsing its metadata.
* Unsupported URI schemes and I/O failures return undefined and are logged.
* @param uri The local script to read.
* @param strict Throw on I/O errors instead of treating them as absent.
*/
export async function readInlineScriptHeaderFromFile(uri: Uri, strict = false): Promise<string | undefined> {
if (uri.scheme !== 'file') {
traceVerbose(`inline script metadata: skipping non-file URI scheme '${uri.scheme}'`);
return undefined;
Expand All @@ -683,15 +763,7 @@ export async function readInlineScriptMetadataFromFile(
return undefined;
}

const result = parseInlineScriptMetadata(text, uri.fsPath);
if (
strict &&
(result.kind === 'invalid' ||
(result.kind === 'parsed' && result.problems.some((problem) => problem.severity === 'error')))
) {
throw new Error(l10n.t('Fix the PEP 723 metadata in {0} before configuring its environment.', uri.fsPath));
}
return result.kind === 'parsed' ? result.metadata : undefined;
return text;
}

/**
Expand Down
Loading
Loading