Skip to content

Commit 56ccb9d

Browse files
Handle live metadata and uppercase PEP 723 scripts (#1916)
## Summary - Refresh the PEP 723 CodeLens from live text-document changes when an inline block appears, disappears, or is edited. - Avoid unnecessary CodeLens refreshes for edits confined to the script body. - Include every `.py` casing, including `.PY`, in bulk inline-script setup discovery. - Re-enable the Windows CodeLens integration suite after #1911 was fixed. ## User scenarios fixed - **S08:** Bulk setup now finds uppercase and mixed-case Python file extensions. - **S10:** Dirty inline metadata edits immediately request a CodeLens recomputation instead of waiting for a save or later routing event. - **S11:** Adding PEP 723 metadata to an unsaved ordinary Python file immediately surfaces the setup CodeLens. ## Validation - `npm run lint` - `npm run compile-tests` - `npm run unittest` — 2,830 passing, 7 pending - `npm run smoke-test` — 30 passing, 1 pending - `npm run e2e-test` — 4 passing - `npm run integration-test` — all 9 live inline-script CodeLens tests passed on Windows; the full suite had 58 passing, 8 pending, and one unrelated environment-creation timeout - Manual S08 verification confirmed `h03_UPPERCASE.PY` appears in the bulk setup picker - S10/S11 were verified in a real VS Code extension host through CodeLens refresh events and provider output; visible UI automation was avoided after focus restoration caused VS Code to cancel its debounced rendered-lens refresh Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 67eedfe commit 56ccb9d

6 files changed

Lines changed: 179 additions & 11 deletions

File tree

‎src/features/inlineScript/codeLens.ts‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ import {
1111
languages,
1212
Range,
1313
TextDocument,
14+
TextDocumentChangeEvent,
1415
} from 'vscode';
1516
import { findInlineScriptBlock } from '../../common/inlineScript/block';
1617
import { getInlineScriptSourceHash, sliceHeaderBytes } from '../../common/inlineScript/metadata';
1718
import { getInlineScriptRoutingKey, InlineScriptRoutingRegistry } from '../../common/inlineScript/routingRegistry';
1819
import { InlineScriptStrings } from '../../common/localize';
20+
import { onDidChangeTextDocument } from '../../common/workspace.apis';
1921
import { shortenVersionString } from '../../managers/common/utils';
2022

2123
/**
@@ -27,13 +29,15 @@ export class InlineScriptCodeLensProvider implements CodeLensProvider, Disposabl
2729
private readonly _onDidChangeCodeLenses = new EventEmitter<void>();
2830
public readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event;
2931
private readonly subscriptions: Disposable[] = [];
32+
private readonly liveBlockKeys = new Set<string>();
3033
private disposed = false;
3134

3235
constructor(
3336
private readonly routing: InlineScriptRoutingRegistry,
3437
private readonly setupCommand: string,
3538
) {
3639
this.subscriptions.push(
40+
onDidChangeTextDocument((event) => this.handleDocumentChange(event)),
3741
this.routing.onDidChangeRouteability(() => this._onDidChangeCodeLenses.fire()),
3842
this.routing.onDidChangeAvailability(() => this._onDidChangeCodeLenses.fire()),
3943
this.routing.onDidChangeEnvironmentVersion(() => this._onDidChangeCodeLenses.fire()),
@@ -47,14 +51,17 @@ export class InlineScriptCodeLensProvider implements CodeLensProvider, Disposabl
4751

4852
public provideCodeLenses(document: TextDocument, _token: CancellationToken): CodeLens[] {
4953
const uri = document.uri;
50-
if (this.disposed || !getInlineScriptRoutingKey(uri)) {
54+
const routingKey = getInlineScriptRoutingKey(uri);
55+
if (this.disposed || !routingKey) {
5156
return [];
5257
}
5358
const header = sliceHeaderBytes(document.getText());
5459
const block = findInlineScriptBlock(header);
5560
if (!block) {
61+
this.liveBlockKeys.delete(routingKey);
5662
return [];
5763
}
64+
this.liveBlockKeys.add(routingKey);
5865
const position = document.positionAt(block.start);
5966
const range = new Range(position, position);
6067
const savedHash = this.routing.getMetadata(uri)?.sourceHash;
@@ -82,10 +89,45 @@ export class InlineScriptCodeLensProvider implements CodeLensProvider, Disposabl
8289
];
8390
}
8491

92+
private handleDocumentChange(event: TextDocumentChangeEvent): void {
93+
if (this.disposed || event.contentChanges.length === 0) {
94+
return;
95+
}
96+
const uri = event.document.uri;
97+
const routingKey = getInlineScriptRoutingKey(uri);
98+
if (!routingKey) {
99+
return;
100+
}
101+
102+
const header = sliceHeaderBytes(event.document.getText());
103+
const block = findInlineScriptBlock(header);
104+
const hadLiveBlock = this.liveBlockKeys.has(routingKey);
105+
if (block) {
106+
this.liveBlockKeys.add(routingKey);
107+
} else {
108+
this.liveBlockKeys.delete(routingKey);
109+
}
110+
111+
if (hadLiveBlock !== !!block) {
112+
this._onDidChangeCodeLenses.fire();
113+
return;
114+
}
115+
116+
const savedMetadata = this.routing.getMetadata(uri);
117+
const blockEnd = block?.end ?? savedMetadata?.sourceRange?.end ?? savedMetadata?.range.end;
118+
if (
119+
blockEnd !== undefined &&
120+
event.contentChanges.some((change) => change.rangeOffset < blockEnd)
121+
) {
122+
this._onDidChangeCodeLenses.fire();
123+
}
124+
}
125+
85126
public dispose(): void {
86127
this.disposed = true;
87128
this.subscriptions.forEach((s) => s.dispose());
88129
this.subscriptions.length = 0;
130+
this.liveBlockKeys.clear();
89131
this._onDidChangeCodeLenses.dispose();
90132
}
91133
}

‎src/features/inlineScript/setupEnvironment.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export const SETUP_INLINE_SCRIPT_ENV_COMMAND = 'python-envs.setupInlineScriptEnv
4040
*/
4141
export const SETUP_INLINE_SCRIPT_ENVS_COMMAND = 'python-envs.setupInlineScriptEnvs';
4242

43-
/** Upper bound on the number of `.py` files the bulk command scans. */
43+
/** Upper bound on the number of Python files the bulk command scans. */
4444
const MAX_INLINE_SCRIPT_FILES = 1000;
4545

4646
/** How many candidate files' PEP 723 headers are read concurrently during the bulk scan. */
@@ -277,7 +277,11 @@ export async function setUpInlineScriptEnvironmentsInWorkspace(
277277
showErrorMessage(l10n.t('The inline script environment manager is not available yet. Try again shortly.'));
278278
return;
279279
}
280-
const files = await findFiles('**/*.py', '{**/.venv/**,**/node_modules/**}', MAX_INLINE_SCRIPT_FILES);
280+
const files = await findFiles(
281+
'**/*.{py,Py,pY,PY}',
282+
'{**/.venv/**,**/node_modules/**}',
283+
MAX_INLINE_SCRIPT_FILES,
284+
);
281285
if (!files || files.length === 0) {
282286
showInformationMessage(l10n.t('No Python files were found in the workspace.'));
283287
return;

‎src/test/features/inlineScript/codeLens.unit.test.ts‎

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,15 @@
44
import assert from 'assert';
55
import * as path from 'path';
66
import * as sinon from 'sinon';
7-
import { Uri } from 'vscode';
7+
import {
8+
Disposable,
9+
TextDocumentChangeEvent,
10+
TextDocumentContentChangeEvent,
11+
Uri,
12+
} from 'vscode';
813
import { MAX_HEADER_BYTES, readInlineScriptMetadata } from '../../../common/inlineScript/metadata';
914
import { InlineScriptRoutingRegistry } from '../../../common/inlineScript/routingRegistry';
15+
import * as wapi from '../../../common/workspace.apis';
1016
import { InlineScriptCodeLensProvider } from '../../../features/inlineScript/codeLens';
1117
import { MockDocument } from '../../mocks/mockDocument';
1218

@@ -17,8 +23,16 @@ suite('Inline script CodeLens provider', () => {
1723
const scriptUri = Uri.file(path.join(process.cwd(), 'lens-tests', 'app.py'));
1824
let routing: InlineScriptRoutingRegistry;
1925
let provider: InlineScriptCodeLensProvider;
26+
let changeListener: ((event: TextDocumentChangeEvent) => void) | undefined;
2027

2128
setup(() => {
29+
changeListener = undefined;
30+
sinon.stub(wapi, 'onDidChangeTextDocument').callsFake((listener) => {
31+
changeListener = listener;
32+
return new Disposable(() => {
33+
changeListener = undefined;
34+
});
35+
});
2236
routing = new InlineScriptRoutingRegistry();
2337
provider = new InlineScriptCodeLensProvider(routing, SETUP_COMMAND);
2438
});
@@ -49,10 +63,79 @@ suite('Inline script CodeLens provider', () => {
4963
return lenses[0];
5064
}
5165

66+
function fireChange(
67+
text: string,
68+
rangeOffset: number,
69+
rangeLength: number,
70+
insertedText: string,
71+
uri = scriptUri,
72+
): void {
73+
assert.ok(changeListener, 'document change listener should be registered');
74+
const contentChange: TextDocumentContentChangeEvent = {
75+
range: undefined as never,
76+
rangeOffset,
77+
rangeLength,
78+
text: insertedText,
79+
};
80+
changeListener({
81+
document: document(text, true, uri),
82+
contentChanges: [contentChange],
83+
reason: undefined,
84+
});
85+
}
86+
5287
test('does not decorate ordinary Python files', () => {
5388
assert.deepStrictEqual(provider.provideCodeLenses(document('print("hello")'), {} as never), []);
5489
});
5590

91+
test('refreshes when a dirty ordinary file gains its first script block', () => {
92+
assert.deepStrictEqual(provider.provideCodeLenses(document('print("hello")'), {} as never), []);
93+
const changed = sinon.spy();
94+
provider.onDidChangeCodeLenses(changed);
95+
96+
fireChange(SCRIPT, 0, 0, SCRIPT);
97+
98+
sinon.assert.calledOnce(changed);
99+
assert.strictEqual(lens(SCRIPT, true).command?.command, SETUP_COMMAND);
100+
});
101+
102+
test('refreshes when dirty inline metadata changes before routing catches up', () => {
103+
ready();
104+
assert.strictEqual(lens().command?.command, '');
105+
const changed = sinon.spy();
106+
provider.onDidChangeCodeLenses(changed);
107+
const edited = SCRIPT.replace('requests', 'httpx');
108+
109+
fireChange(edited, SCRIPT.indexOf('requests'), 'requests'.length, 'httpx');
110+
111+
sinon.assert.calledOnce(changed);
112+
assert.strictEqual(lens(edited, true).command?.command, SETUP_COMMAND);
113+
});
114+
115+
test('does not refresh for a body-only edit after the inline block', () => {
116+
ready();
117+
assert.strictEqual(lens().command?.command, '');
118+
const changed = sinon.spy();
119+
provider.onDidChangeCodeLenses(changed);
120+
const edited = SCRIPT.replace('hello', 'changed body');
121+
122+
fireChange(edited, SCRIPT.indexOf('hello'), 'hello'.length, 'changed body');
123+
124+
sinon.assert.notCalled(changed);
125+
});
126+
127+
test('refreshes when a dirty script block is removed', () => {
128+
assert.strictEqual(lens(SCRIPT, true).command?.command, SETUP_COMMAND);
129+
const changed = sinon.spy();
130+
provider.onDidChangeCodeLenses(changed);
131+
const plain = 'print("hello")\n';
132+
133+
fireChange(plain, 0, SCRIPT.length, plain);
134+
135+
sinon.assert.calledOnce(changed);
136+
assert.deepStrictEqual(provider.provideCodeLenses(document(plain, true), {} as never), []);
137+
});
138+
56139
test('offers setup for a newly typed block before detection or saving', () => {
57140
const result = lens(SCRIPT, true);
58141
assert.strictEqual(result.command?.command, SETUP_COMMAND);

‎src/test/features/inlineScript/setupEnvironment.unit.test.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import assert from 'assert';
55
import * as sinon from 'sinon';
66
import * as typemoq from 'typemoq';
7-
import { Uri } from 'vscode';
7+
import { Disposable, Uri } from 'vscode';
88
import { PythonEnvironment } from '../../../api';
99
import { INLINE_SCRIPT_MANAGER_ID } from '../../../common/constants';
1010
import { InlineScriptMetadata } from '../../../common/inlineScript/metadata';
@@ -217,6 +217,19 @@ suite('setUpInlineScriptEnvironmentsInWorkspace', () => {
217217
manager.verify((m) => m.create(typemoq.It.isAny(), typemoq.It.isAny()), typemoq.Times.never());
218218
});
219219

220+
test('searches for Python file extensions case-insensitively', async () => {
221+
findFilesStub.resolves([]);
222+
223+
await setUpInlineScriptEnvironmentsInWorkspace(em.object, routing);
224+
225+
sinon.assert.calledOnceWithExactly(
226+
findFilesStub,
227+
'**/*.{py,Py,pY,PY}',
228+
'{**/.venv/**,**/node_modules/**}',
229+
1000,
230+
);
231+
});
232+
220233
test('only sets up the selected files that declare inline metadata', async () => {
221234
findFilesStub.resolves([withMeta, withoutMeta]);
222235
readMetadataStub.callsFake(async (uri: Uri) =>
@@ -543,6 +556,7 @@ suite('setupInlineScriptEnvironmentHandler', () => {
543556
sinon.assert.notCalled(saveStub);
544557
assert.deepStrictEqual(routing.getMetadata(scriptUri), metadata);
545558
assert.strictEqual(routing.shouldRoute(scriptUri), true);
559+
sinon.stub(wapi, 'onDidChangeTextDocument').returns(new Disposable(() => undefined));
546560
const provider = new InlineScriptCodeLensProvider(routing, 'setup');
547561
try {
548562
const document = new MockDocument(currentText, scriptUri.fsPath, async () => true);

‎src/test/integration/inlineScriptCodeLens.integration.test.ts‎

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,21 @@ import * as path from 'path';
77
import * as vscode from 'vscode';
88
import { readInlineScriptMetadata } from '../../common/inlineScript/metadata';
99
import { InlineScriptRoutingRegistry } from '../../common/inlineScript/routingRegistry';
10-
import { registerInlineScriptCodeLens } from '../../features/inlineScript/codeLens';
10+
import { InlineScriptCodeLensProvider, registerInlineScriptCodeLens } from '../../features/inlineScript/codeLens';
1111
import { registerInlineScriptDiagnostics } from '../../features/inlineScript/diagnostics';
1212
import { InlineScriptLazyDetector } from '../../features/inlineScript/lazyDetector';
1313
import { sleep, waitForCondition } from '../testUtils';
1414

1515
const SETUP_COMMAND = 'python-envs.test.inlineScriptLensSetup';
1616
const SCRIPT = '# /// script\n# dependencies = []\n# ///\n\nprint("hello")\n';
1717

18-
// TODO: Re-enable on Windows after https://github.com/microsoft/vscode-python-environments/issues/1911.
19-
const inlineScriptCodeLensSuite = process.platform === 'win32' ? suite.skip : suite;
20-
inlineScriptCodeLensSuite('Integration: Live inline script CodeLens', function () {
18+
suite('Integration: Live inline script CodeLens', function () {
2119
this.timeout(30_000);
2220

2321
let root: string;
2422
let document: vscode.TextDocument;
2523
let routing: InlineScriptRoutingRegistry;
24+
let provider: InlineScriptCodeLensProvider;
2625
let registration: vscode.Disposable;
2726
let diagnostics: vscode.Disposable;
2827
let detector: InlineScriptLazyDetector;
@@ -36,7 +35,9 @@ inlineScriptCodeLensSuite('Integration: Live inline script CodeLens', function (
3635

3736
setup(async () => {
3837
routing = new InlineScriptRoutingRegistry();
39-
registration = registerInlineScriptCodeLens(routing, SETUP_COMMAND).disposable;
38+
const codeLens = registerInlineScriptCodeLens(routing, SETUP_COMMAND);
39+
provider = codeLens.provider;
40+
registration = codeLens.disposable;
4041
diagnostics = registerInlineScriptDiagnostics();
4142
detector = new InlineScriptLazyDetector(routing);
4243
detector.activate();
@@ -90,6 +91,19 @@ inlineScriptCodeLensSuite('Integration: Live inline script CodeLens', function (
9091
assert.strictEqual(await vscode.workspace.applyEdit(edit), true);
9192
}
9293

94+
async function replaceAndWaitForLensRefresh(text: string): Promise<void> {
95+
let refreshed = false;
96+
const subscription = provider.onDidChangeCodeLenses(() => {
97+
refreshed = true;
98+
});
99+
try {
100+
await replace(text);
101+
await waitForCondition(() => refreshed, 5_000, 'The CodeLens provider should refresh after the edit');
102+
} finally {
103+
subscription.dispose();
104+
}
105+
}
106+
93107
function validateSavedEnvironment(version = '3.12.4'): void {
94108
const metadata = readInlineScriptMetadata(document.getText());
95109
assert.ok(metadata);
@@ -137,14 +151,24 @@ inlineScriptCodeLensSuite('Integration: Live inline script CodeLens', function (
137151
await replace(SCRIPT.replace('hello', 'dirty body'));
138152
assert.strictEqual(document.isDirty, true);
139153
assert.strictEqual((await lenses())[0]?.command?.command, '');
140-
await replace(SCRIPT.replace('[]', '["requests"]'));
154+
await replaceAndWaitForLensRefresh(SCRIPT.replace('[]', '["requests"]'));
141155
assert.strictEqual((await lenses())[0]?.command?.command, SETUP_COMMAND);
142156
assert.strictEqual(routing.shouldRoute(document.uri), false, 'the detector must still invalidate changed headers');
143157
await replace(SCRIPT);
144158
assert.strictEqual((await lenses())[0]?.command?.command, '');
145159
assert.strictEqual(routing.shouldRoute(document.uri), true, 'Undo must restore the validated route');
146160
});
147161

162+
test('a dirty ordinary file gains its setup lens without being saved', async () => {
163+
await replace('print("ordinary Python")\n');
164+
assert.deepStrictEqual(await lenses(), []);
165+
166+
await replaceAndWaitForLensRefresh(SCRIPT);
167+
168+
assert.strictEqual(document.isDirty, true);
169+
assert.strictEqual((await lenses())[0]?.command?.command, SETUP_COMMAND);
170+
});
171+
148172
test('restored associations show the version and availability changes switch the label', async () => {
149173
validateSavedEnvironment('3.13.2');
150174
registration.dispose();

‎src/test/managers/builtin/inlineScript/envManager.unit.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ suite('InlineScriptEnvManager', () => {
240240
renameFilesListener = undefined;
241241
});
242242
});
243+
sinon.stub(workspaceApis, 'onDidChangeTextDocument').returns(new Disposable(() => undefined));
243244
openDocumentsStub = sinon.stub(workspaceApis, 'getOpenTextDocuments').returns([]);
244245
createWithProgressStub = sinon.stub(venvUtils, 'createWithProgress').callsFake(async (...args: unknown[]) => {
245246
const envDir = args[6] as string;

0 commit comments

Comments
 (0)