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
2 changes: 2 additions & 0 deletions docs/managing-python-projects.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ 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.

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.

Once a mismatch is confirmed during an environment lookup, the affected scripts' setup actions return without requiring a save.
Expand Down
7 changes: 7 additions & 0 deletions src/common/localize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ export namespace InlineScriptStrings {

export const diagnosticSource = l10n.t('Python Environments');

export function environmentReady(version: string | undefined): string {
const shown = version?.trim();
return shown
? l10n.t('Script environment ready (Python {0})', shown)
: l10n.t('Script environment ready');
}

export const unterminatedBlock = l10n.t(
"This '# /// script' block is missing its closing '# ///' marker, so its inline script metadata is ignored.",
);
Expand Down
74 changes: 64 additions & 10 deletions src/features/inlineScript/codeLens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@ import {
languages,
Range,
TextDocument,
Uri,
} from 'vscode';
import { InlineScriptRoutingRegistry } from '../../common/inlineScript/routingRegistry';
import { getInlineScriptRoutingKey, InlineScriptRoutingRegistry } from '../../common/inlineScript/routingRegistry';
import { InlineScriptStrings } from '../../common/localize';

export const READY_CONFIRMATION_TIMEOUT_MS = 5000;

/**
* Shows a single "Set up environment for this script" CodeLens above a `.py` file's PEP 723
Expand All @@ -29,10 +33,16 @@ export class InlineScriptCodeLensProvider implements CodeLensProvider, Disposabl
private readonly _onDidChangeCodeLenses = new EventEmitter<void>();
public readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event;
private readonly subscriptions: Disposable[] = [];
private readonly readyConfirmations = new Map<
string,
{ readonly version: string | undefined; readonly timer: ReturnType<typeof setTimeout> }
>();
private disposed = false;

constructor(
private readonly routing: InlineScriptRoutingRegistry,
private readonly setupCommand: string,
private readonly confirmationTimeoutMs: number = READY_CONFIRMATION_TIMEOUT_MS,
) {
this.subscriptions.push(
this.routing.onDidChangeRouteability(() => this._onDidChangeCodeLenses.fire()),
Expand All @@ -47,6 +57,22 @@ export class InlineScriptCodeLensProvider implements CodeLensProvider, Disposabl
);
}

public noteEnvironmentReady(uri: Uri, version: string | undefined): void {
const key = getInlineScriptRoutingKey(uri);
if (this.disposed || !key) {
return;
}
this.clearConfirmation(key);
this.readyConfirmations.set(key, {
version,
timer: setTimeout(() => {
this.readyConfirmations.delete(key);
this._onDidChangeCodeLenses.fire();
}, this.confirmationTimeoutMs),
});
this._onDidChangeCodeLenses.fire();
}

public provideCodeLenses(document: TextDocument, _token: CancellationToken): CodeLens[] {
if (document.isDirty) {
// The association is validated against the saved file (the manager refuses to validate a
Expand All @@ -60,13 +86,24 @@ export class InlineScriptCodeLensProvider implements CodeLensProvider, Disposabl
// No saved PEP 723 metadata (or it is currently being edited).
return [];
}
if (this.routing.shouldRoute(uri)) {
// A validated inline-script environment matching the current metadata already exists.
return [];
}
const offset = metadata.sourceRange?.start ?? metadata.range.start;
const position = document.positionAt(offset);
const range = new Range(position, position);
if (this.routing.shouldRoute(uri)) {
// A validated inline-script environment matching the current metadata already exists.
const key = getInlineScriptRoutingKey(uri);
const confirmation = key ? this.readyConfirmations.get(key) : undefined;
if (!confirmation) {
return [];
}
// An empty command id renders the title as plain, non-clickable text.
return [
new CodeLens(range, {
title: InlineScriptStrings.environmentReady(confirmation.version),
command: '',
}),
];
}
return [
new CodeLens(range, {
title: l10n.t('Set up environment for this script'),
Expand All @@ -77,21 +114,38 @@ export class InlineScriptCodeLensProvider implements CodeLensProvider, Disposabl
}

public dispose(): void {
this.disposed = true;
this.subscriptions.forEach((s) => s.dispose());
this.subscriptions.length = 0;
this.readyConfirmations.forEach((confirmation) => clearTimeout(confirmation.timer));
this.readyConfirmations.clear();
this._onDidChangeCodeLenses.dispose();
}

private clearConfirmation(key: string): void {
const existing = this.readyConfirmations.get(key);
if (existing) {
clearTimeout(existing.timer);
this.readyConfirmations.delete(key);
}
}
}

/**
* Register the inline-script CodeLens provider for local `.py` files. Only called when the PEP 723
* inline-script feature flag is enabled, so it is a no-op for everyone else.
*/
export function registerInlineScriptCodeLens(routing: InlineScriptRoutingRegistry, setupCommand: string): Disposable {
export function registerInlineScriptCodeLens(
routing: InlineScriptRoutingRegistry,
setupCommand: string,
): { readonly disposable: Disposable; readonly provider: InlineScriptCodeLensProvider } {
const provider = new InlineScriptCodeLensProvider(routing, setupCommand);
const registration = languages.registerCodeLensProvider({ scheme: 'file', language: 'python' }, provider);
return new Disposable(() => {
registration.dispose();
provider.dispose();
});
return {
provider,
disposable: new Disposable(() => {
registration.dispose();
provider.dispose();
}),
};
}
13 changes: 11 additions & 2 deletions src/features/inlineScript/setupEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from '../../common/window.apis';
import { asRelativePath, findFiles, getOpenTextDocuments } from '../../common/workspace.apis';
import { EnvironmentManagers } from '../../internal.api';
import { shortenVersionString } from '../../managers/common/utils';
import { registerInlineScriptCodeLens } from './codeLens';
import { promptUpdateExtensionsForInlineScripts } from './extensionVersionCheck';
import { registerInlineScriptSetupCodeAction } from './setupCodeAction';
Expand Down Expand Up @@ -134,6 +135,7 @@ async function saveScriptBeforeSetup(scriptUri: Uri, routing: InlineScriptRoutin
export function setupInlineScriptEnvironmentHandler(
em: EnvironmentManagers,
routing: InlineScriptRoutingRegistry,
onEnvironmentReady?: (scriptUri: Uri, version: string | undefined) => void,
): (scriptUri?: Uri) => Promise<void> {
return async (scriptUri?: Uri): Promise<void> => {
const uri = scriptUri ?? window.activeTextEditor?.document.uri;
Expand Down Expand Up @@ -164,6 +166,7 @@ export function setupInlineScriptEnvironmentHandler(
notifyInlineScriptSetupOutcome(uri, routing);
return;
}
onEnvironmentReady?.(uri, shortenVersionString(environment.version));
// Kept out of the try: the environment is already set up, so a failure in this follow-up
// must not be reported to the user as a setup failure.
await promptUpdateExtensionsForInlineScripts().catch((error) =>
Expand Down Expand Up @@ -362,10 +365,16 @@ async function filterInlineScriptFiles(files: readonly Uri[]): Promise<Uri[]> {
* palette-gated behind the flag.
*/
export function registerInlineScriptUx(em: EnvironmentManagers, routing: InlineScriptRoutingRegistry): Disposable[] {
const codeLens = registerInlineScriptCodeLens(routing, SETUP_INLINE_SCRIPT_ENV_COMMAND);
return [
registerInlineScriptCodeLens(routing, SETUP_INLINE_SCRIPT_ENV_COMMAND),
codeLens.disposable,
registerInlineScriptSetupCodeAction(routing, SETUP_INLINE_SCRIPT_ENV_COMMAND),
commands.registerCommand(SETUP_INLINE_SCRIPT_ENV_COMMAND, setupInlineScriptEnvironmentHandler(em, routing)),
commands.registerCommand(
SETUP_INLINE_SCRIPT_ENV_COMMAND,
setupInlineScriptEnvironmentHandler(em, routing, (uri, version) =>
codeLens.provider.noteEnvironmentReady(uri, version),
),
),
commands.registerCommand(SETUP_INLINE_SCRIPT_ENVS_COMMAND, () =>
setUpInlineScriptEnvironmentsInWorkspace(em, routing),
),
Expand Down
64 changes: 63 additions & 1 deletion src/test/features/inlineScript/codeLens.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
// Licensed under the MIT License.

import assert from 'assert';
import * as sinon from 'sinon';
import { Position, TextDocument, Uri } from 'vscode';
import { InlineScriptMetadata } from '../../../common/inlineScript/metadata';
import { InlineScriptRoutingRegistry } from '../../../common/inlineScript/routingRegistry';
import { InlineScriptCodeLensProvider } from '../../../features/inlineScript/codeLens';
import { InlineScriptCodeLensProvider, READY_CONFIRMATION_TIMEOUT_MS } from '../../../features/inlineScript/codeLens';

const SETUP_COMMAND = 'python-envs.setupInlineScriptEnv';

Expand Down Expand Up @@ -83,4 +84,65 @@ suite('Inline script CodeLens provider', () => {
sub.dispose();
assert.ok(fireCount >= 1, 'onDidChangeCodeLenses should fire when routing state changes');
});

suite('post-setup confirmation', () => {
let clock: sinon.SinonFakeTimers;

setup(() => {
clock = sinon.useFakeTimers();
routing.setMetadata(scriptUri, makeMetadata());
routing.setValidatedAssociation(scriptUri, true);
});

teardown(() => clock.restore());

test('replaces the hidden setup lens with a non-clickable confirmation naming the version', () => {
provider.noteEnvironmentReady(scriptUri, '3.12.4');

const lenses = provider.provideCodeLenses(makeDocument(scriptUri), {} as never);

assert.strictEqual(lenses.length, 1);
assert.strictEqual(lenses[0].command?.title, 'Script environment ready (Python 3.12.4)');
assert.strictEqual(lenses[0].command?.command, '', 'the confirmation must not be clickable');
});

test('omits the version when none was resolved', () => {
provider.noteEnvironmentReady(scriptUri, undefined);

const lenses = provider.provideCodeLenses(makeDocument(scriptUri), {} as never);

Comment thread
StellaHuang95 marked this conversation as resolved.
assert.strictEqual(lenses.length, 1);
assert.strictEqual(lenses[0].command?.title, 'Script environment ready');
});

test('expires on its own and refreshes so the lens disappears', () => {
provider.noteEnvironmentReady(scriptUri, '3.12.4');
let fireCount = 0;
const sub = provider.onDidChangeCodeLenses(() => (fireCount += 1));

clock.tick(READY_CONFIRMATION_TIMEOUT_MS + 1);
sub.dispose();

assert.strictEqual(fireCount, 1, 'expiry must refresh the lenses');
assert.strictEqual(provider.provideCodeLenses(makeDocument(scriptUri), {} as never).length, 0);
});

test('shows nothing for a routed script that was not just set up', () => {
assert.strictEqual(provider.provideCodeLenses(makeDocument(scriptUri), {} as never).length, 0);
});

test('stays hidden while the document is dirty', () => {
provider.noteEnvironmentReady(scriptUri, '3.12.4');

assert.strictEqual(provider.provideCodeLenses(makeDocument(scriptUri, true), {} as never).length, 0);
});

test('does not leak timers past disposal', () => {
provider.noteEnvironmentReady(scriptUri, '3.12.4');

provider.dispose();

assert.doesNotThrow(() => clock.tick(READY_CONFIRMATION_TIMEOUT_MS + 1));
});
});
});
32 changes: 28 additions & 4 deletions src/test/features/inlineScript/setupEnvironment.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ suite('setupInlineScriptEnvironmentHandler', () => {
let errorStub: sinon.SinonStub;
let saveStub: sinon.SinonStub;
let promptStub: sinon.SinonStub;
let readySpy: sinon.SinonStub;

setup(() => {
em = typemoq.Mock.ofType<EnvironmentManagers>();
Expand All @@ -338,6 +339,7 @@ suite('setupInlineScriptEnvironmentHandler', () => {
errorStub = sinon.stub(winapi, 'showErrorMessage').resolves(undefined);
sinon.stub(winapi, 'showInformationMessage').resolves(undefined);
sinon.stub(winapi, 'showWarningMessage').resolves(undefined);
readySpy = sinon.stub();
promptStub = sinon.stub(extensionVersionCheck, 'promptUpdateExtensionsForInlineScripts').resolves();
saveStub = sinon.stub().resolves(true);
});
Expand All @@ -358,8 +360,7 @@ suite('setupInlineScriptEnvironmentHandler', () => {
return env;
}

test('saves a dirty document before setup, because setup reads the block from disk', async () => {
openDirtyDocument();
test('saves a dirty document before setup, because setup reads the block from disk', async () => { openDirtyDocument();
expectEnvironmentCreated();

await setupInlineScriptEnvironmentHandler(em.object, routing)(scriptUri);
Expand Down Expand Up @@ -417,12 +418,35 @@ suite('setupInlineScriptEnvironmentHandler', () => {
sinon.assert.calledOnce(errorStub);
});

test('does not report a failing companion-extension prompt as a setup failure', async () => {
expectEnvironmentCreated();
test('does not report a failing companion-extension prompt as a setup failure', async () => { expectEnvironmentCreated();
promptStub.rejects(new Error('boom'));

await setupInlineScriptEnvironmentHandler(em.object, routing)(scriptUri);

sinon.assert.notCalled(errorStub);
});

test('reports the ready environment and its Python version once setup succeeds', async () => {
expectEnvironmentCreated();

await setupInlineScriptEnvironmentHandler(em.object, routing, readySpy)(scriptUri);

sinon.assert.calledOnceWithExactly(readySpy, scriptUri, '3.12.0');
});

test('reports no ready environment when setup produced none', async () => {
manager.setup((m) => m.create(scriptUri, undefined)).returns(() => Promise.resolve(undefined));

await setupInlineScriptEnvironmentHandler(em.object, routing, readySpy)(scriptUri);

sinon.assert.notCalled(readySpy);
});

test('reports no ready environment when setup throws', async () => {
manager.setup((m) => m.create(scriptUri, undefined)).returns(() => Promise.reject(new Error('boom')));

await setupInlineScriptEnvironmentHandler(em.object, routing, readySpy)(scriptUri);

sinon.assert.notCalled(readySpy);
});
});
Loading