From 39714d0f99c209c3e619c4c17c51628309deff5a Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Wed, 2 Sep 2026 16:03:46 -0700 Subject: [PATCH 1/5] Expose inline-script Clear Cache command in the palette behind the feature flag Add python-envs.clearScriptEnvCache to contributes.commands and gate its Command Palette visibility with a pythonEnvsInlineScriptsEnabled context key, set at activation from the latched inline-script feature flag. Keying palette visibility off the same activation-time flag that gates the command's registration keeps the two in lockstep, so the command is never shown before a window reload has actually registered it (a live config. when-clause would reveal it early, and clicking it would fail with "command not found"). When the flag is off the command stays unregistered and hidden, so regular users are unaffected. --- package.json | 10 ++++++++++ package.nls.json | 1 + src/extension.ts | 4 +++- src/test/smoke/registration.smoke.test.ts | 17 ++++++++++++----- 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index dd7cba3cf..154543414 100644 --- a/package.json +++ b/package.json @@ -245,6 +245,12 @@ "category": "Python", "icon": "$(trash)" }, + { + "command": "python-envs.clearScriptEnvCache", + "title": "%python-envs.clearScriptEnvCache.title%", + "category": "Python Envs", + "icon": "$(trash)" + }, { "command": "python-envs.runInTerminal", "title": "%python-envs.runInTerminal.title%", @@ -414,6 +420,10 @@ "command": "python-envs.runAsTask", "when": "config.python.useEnvironmentsExtension != false" }, + { + "command": "python-envs.clearScriptEnvCache", + "when": "pythonEnvsInlineScriptsEnabled" + }, { "command": "python-envs.terminal.activate", "when": "pythonTerminalActivation" diff --git a/package.nls.json b/package.nls.json index 483ecfd29..365517b39 100644 --- a/package.nls.json +++ b/package.nls.json @@ -35,6 +35,7 @@ "python-envs.refreshPackages.title": "Refresh Packages List", "python-envs.packages.title": "Manage Packages", "python-envs.clearCache.title": "Clear Cache", + "python-envs.clearScriptEnvCache.title": "Clear Inline Script Environment Cache", "python-envs.runInTerminal.title": "Run in Terminal", "python-envs.createTerminal.title": "Create Python Terminal", "python-envs.runAsTask.title": "Run as Task", diff --git a/src/extension.ts b/src/extension.ts index f55169d1a..9bdf08210 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -44,9 +44,9 @@ import { NewScriptProject } from './features/creators/newScriptProject'; import { ProjectCreatorsImpl } from './features/creators/projectCreators'; import { addPythonProjectCommand, - copyPathToClipboard, clearEnvironmentCachesCommand, clearScriptEnvironmentCacheCommand, + copyPathToClipboard, createAnyEnvironmentCommand, createEnvironmentCommand, createTerminalCommand, @@ -192,6 +192,8 @@ export async function activate(context: ExtensionContext): Promise(ENVS_EXTENSION_ID); assert.ok(extension, `Extension ${ENVS_EXTENSION_ID} not found`); const contributedCommands = (extension.packageJSON?.contributes?.commands ?? []) as Array<{ command: string }>; const commandPaletteEntries = (extension.packageJSON?.contributes?.menus?.commandPalette ?? []) as Array<{ command: string; + when?: string; }>; assert.ok( - !contributedCommands.some((entry) => entry.command === 'python-envs.clearScriptEnvCache'), - 'python-envs.clearScriptEnvCache should not be publicly contributed before rollout', + contributedCommands.some((entry) => entry.command === 'python-envs.clearScriptEnvCache'), + 'python-envs.clearScriptEnvCache should be contributed so it can appear in the palette', ); + const paletteEntry = commandPaletteEntries.find((entry) => entry.command === 'python-envs.clearScriptEnvCache'); assert.ok( - !commandPaletteEntries.some((entry) => entry.command === 'python-envs.clearScriptEnvCache'), - 'python-envs.clearScriptEnvCache should not appear in contributed menus before rollout', + paletteEntry, + 'python-envs.clearScriptEnvCache should have a commandPalette entry that gates its visibility', + ); + assert.strictEqual( + paletteEntry.when, + 'pythonEnvsInlineScriptsEnabled', + 'python-envs.clearScriptEnvCache should only appear when the inline-scripts feature flag is enabled', ); }); From ed83cc23d6c180916bb31b65efd6731c7e1562b1 Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Wed, 2 Sep 2026 16:24:06 -0700 Subject: [PATCH 2/5] Expose inline-script bulk setup command in the palette behind the feature flag Contribute python-envs.setupInlineScriptEnvs and gate its Command Palette visibility with the pythonEnvsInlineScriptsEnabled context key, which is already set at activation from the latched inline-script feature flag. No activation code is needed here: the command is already registered (only when the flag is on) via registerInlineScriptUx, so it stays hidden and unregistered for regular users. Title it "Set Up Environments for Inline Script Files" to match the QuickPick the command opens. Correct the now-stale doc comments that described the command as hidden/not-contributed, and add smoke tests mirroring the clear-cache command (contributed-but-palette-gated, and not-registered-when-the-flag-is-off). --- package.json | 10 +++++ package.nls.json | 1 + src/features/inlineScript/setupEnvironment.ts | 9 +++-- src/test/smoke/registration.smoke.test.ts | 39 ++++++++++++++++++- 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 154543414..b8e3f1228 100644 --- a/package.json +++ b/package.json @@ -251,6 +251,12 @@ "category": "Python Envs", "icon": "$(trash)" }, + { + "command": "python-envs.setupInlineScriptEnvs", + "title": "%python-envs.setupInlineScriptEnvs.title%", + "category": "Python Envs", + "icon": "$(tools)" + }, { "command": "python-envs.runInTerminal", "title": "%python-envs.runInTerminal.title%", @@ -424,6 +430,10 @@ "command": "python-envs.clearScriptEnvCache", "when": "pythonEnvsInlineScriptsEnabled" }, + { + "command": "python-envs.setupInlineScriptEnvs", + "when": "pythonEnvsInlineScriptsEnabled" + }, { "command": "python-envs.terminal.activate", "when": "pythonTerminalActivation" diff --git a/package.nls.json b/package.nls.json index 365517b39..c649a7be0 100644 --- a/package.nls.json +++ b/package.nls.json @@ -36,6 +36,7 @@ "python-envs.packages.title": "Manage Packages", "python-envs.clearCache.title": "Clear Cache", "python-envs.clearScriptEnvCache.title": "Clear Inline Script Environment Cache", + "python-envs.setupInlineScriptEnvs.title": "Set Up Environments for Inline Script Files", "python-envs.runInTerminal.title": "Run in Terminal", "python-envs.createTerminal.title": "Create Python Terminal", "python-envs.runAsTask.title": "Run as Task", diff --git a/src/features/inlineScript/setupEnvironment.ts b/src/features/inlineScript/setupEnvironment.ts index e071536e7..9fd1ff380 100644 --- a/src/features/inlineScript/setupEnvironment.ts +++ b/src/features/inlineScript/setupEnvironment.ts @@ -19,8 +19,9 @@ import { registerInlineScriptCodeLens } from './codeLens'; export const SETUP_INLINE_SCRIPT_ENV_COMMAND = 'python-envs.setupInlineScriptEnv'; /** - * Hidden command that scans the workspace and sets up environments for the selected inline-script - * files. Intentionally not contributed in `package.json` while the feature is behind the internal flag. + * Command that scans the workspace and sets up environments for the selected inline-script files. + * Contributed in `package.json` but only shown in the Command Palette while the inline-scripts + * feature flag is enabled (gated by the `pythonEnvsInlineScriptsEnabled` context key). */ export const SETUP_INLINE_SCRIPT_ENVS_COMMAND = 'python-envs.setupInlineScriptEnvs'; @@ -182,8 +183,8 @@ async function filterInlineScriptFiles(files: readonly Uri[]): Promise { /** * Register the inline-script user-facing surfaces (the CodeLens and its setup commands). Only called - * when the PEP 723 inline-script feature flag is enabled; the commands are intentionally hidden from - * `package.json` for now. + * when the PEP 723 inline-script feature flag is enabled. The single-file setup command is invoked by + * the CodeLens and stays out of `package.json`; the bulk command is palette-gated behind the flag. */ export function registerInlineScriptUx(em: EnvironmentManagers, routing: InlineScriptRoutingRegistry): Disposable[] { return [ diff --git a/src/test/smoke/registration.smoke.test.ts b/src/test/smoke/registration.smoke.test.ts index 428cee314..50b89fadc 100644 --- a/src/test/smoke/registration.smoke.test.ts +++ b/src/test/smoke/registration.smoke.test.ts @@ -139,7 +139,35 @@ suite('Smoke: Registration Checks', function () { ); }); - test('Internal inline clear command is not registered while the feature flag is off', async function () { + test('Inline bulk setup command is contributed but palette-gated behind the inline-scripts flag', function () { + const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); + assert.ok(extension, `Extension ${ENVS_EXTENSION_ID} not found`); + + const contributedCommands = (extension.packageJSON?.contributes?.commands ?? []) as Array<{ command: string }>; + const commandPaletteEntries = (extension.packageJSON?.contributes?.menus?.commandPalette ?? []) as Array<{ + command: string; + when?: string; + }>; + + assert.ok( + contributedCommands.some((entry) => entry.command === 'python-envs.setupInlineScriptEnvs'), + 'python-envs.setupInlineScriptEnvs should be contributed so it can appear in the palette', + ); + const paletteEntry = commandPaletteEntries.find( + (entry) => entry.command === 'python-envs.setupInlineScriptEnvs', + ); + assert.ok( + paletteEntry, + 'python-envs.setupInlineScriptEnvs should have a commandPalette entry that gates its visibility', + ); + assert.strictEqual( + paletteEntry.when, + 'pythonEnvsInlineScriptsEnabled', + 'python-envs.setupInlineScriptEnvs should only appear when the inline-scripts feature flag is enabled', + ); + }); + + test('Internal inline-script commands are not registered while the feature flag is off', async function () { const allCommands = await vscode.commands.getCommands(true); assert.ok( @@ -150,6 +178,15 @@ suite('Smoke: Registration Checks', function () { () => Promise.resolve(vscode.commands.executeCommand('python-envs.clearScriptEnvCache')), /not found/i, ); + + assert.ok( + !allCommands.includes('python-envs.setupInlineScriptEnvs'), + 'python-envs.setupInlineScriptEnvs should not be registered by default', + ); + await assert.rejects( + () => Promise.resolve(vscode.commands.executeCommand('python-envs.setupInlineScriptEnvs')), + /not found/i, + ); }); // ========================================================================= From 995ec1643e412e328d2aff51dc0ced7c30ab464b Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Thu, 3 Sep 2026 14:50:04 -0700 Subject: [PATCH 3/5] Show readable names and setup progress for inline-script envs PEP 723 inline-script environments previously surfaced their internal content-addressed cache-key hash in both the UI name and the creation progress notification, which is meaningless to users. Now the setup progress names the script being set up, and the environment shows a readable name (script env (3.12.4), short form 3.12.4 (script)) across all resolution paths: build, reuse, discovery, validation and rehydration. Implemented via an optional nameStyle/progressTitle on createWithProgress and a nameStyle argument on resolveVenvPythonEnvironmentPath; regular venv naming and progress are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7415b86e-8399-43d5-a504-071cd90592df --- .../builtin/inlineScript/envManager.ts | 21 +++++++++- src/managers/builtin/venvUtils.ts | 39 ++++++++++++++----- 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/src/managers/builtin/inlineScript/envManager.ts b/src/managers/builtin/inlineScript/envManager.ts index 30233c195..9ca0bb02d 100644 --- a/src/managers/builtin/inlineScript/envManager.ts +++ b/src/managers/builtin/inlineScript/envManager.ts @@ -108,6 +108,7 @@ interface CreateOrReuseEnvironmentOptions { readonly metadata: InlineScriptMetadata; readonly selectedBase: SelectedBaseInterpreter; readonly pendingCreation: PendingCreationContext; + readonly scriptUri: Uri; } interface BuildCacheEntryResult { @@ -374,6 +375,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { metadata, selectedBase, pendingCreation, + scriptUri, }); pendingCreation.promise = creation; this.pendingCreations.set(cacheKey, pendingCreation); @@ -780,6 +782,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { this.api, this, this.baseManager, + 'inlineScript', ); } catch (error) { this.log.warn(`Unable to resolve inline-script cache entry ${envDir.fsPath}: ${getErrorMessage(error)}`); @@ -1132,6 +1135,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { this.api, this, this.baseManager, + 'inlineScript', ); if (!this.isCurrentAssociationRevision(scriptPath, revision)) { return this.fsPathToEnv.get(scriptPath); @@ -1304,6 +1308,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { this.api, this, this.baseManager, + 'inlineScript', ); } catch (error) { this.log.warn( @@ -2622,6 +2627,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { metadata, selectedBase, pendingCreation, + scriptUri, }: CreateOrReuseEnvironmentOptions): Promise { const dependencyCount = this.getTelemetryDependencyCount(packages); const cacheRoot = getScriptEnvCacheRoot(this.globalStorageUri); @@ -2650,7 +2656,14 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } const buildStartAtMs = Date.now(); - const build = await this.buildCacheEntry(envDir, cacheRoot, packages, selectedBase, pendingCreation); + const build = await this.buildCacheEntry( + envDir, + cacheRoot, + packages, + selectedBase, + pendingCreation, + scriptUri, + ); if (build.retainLock) { try { await lock.retain(); @@ -2738,6 +2751,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { this.api, this, this.baseManager, + 'inlineScript', ); if (!environment) { return { kind: 'uncertain' }; @@ -2784,6 +2798,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { packages: ReadonlyArray, selectedBase: SelectedBaseInterpreter, pendingCreation: PendingCreationContext, + scriptUri: Uri, ): Promise { let result; try { @@ -2797,6 +2812,10 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { envDir.fsPath, { install: [...packages], uninstall: [] }, false, // trackUvEnvironment + { + progressTitle: l10n.t('Setting up environment for {0}', path.basename(scriptUri.fsPath)), + nameStyle: 'inlineScript', + }, ); } catch (error) { this.log.error(`Failed to build inline-script environment: ${getErrorMessage(error)}`); diff --git a/src/managers/builtin/venvUtils.ts b/src/managers/builtin/venvUtils.ts index fac901cf5..4e83bb06e 100644 --- a/src/managers/builtin/venvUtils.ts +++ b/src/managers/builtin/venvUtils.ts @@ -156,7 +156,10 @@ function getName(binPath: string): string { return path.basename(dir1); } -async function getPythonInfo(env: NativeEnvInfo): Promise { +/** Controls how {@link getPythonInfo} formats an environment's user-facing name. */ +export type VenvNameStyle = 'default' | 'inlineScript'; + +async function getPythonInfo(env: NativeEnvInfo, nameStyle: VenvNameStyle = 'default'): Promise { // Handle broken environments that have an error field if (env.error) { const venvName = env.name ?? (env.prefix ? path.basename(env.prefix) : 'Unknown'); @@ -185,7 +188,12 @@ async function getPythonInfo(env: NativeEnvInfo): Promise if (env.executable && env.version && env.prefix) { const venvName = env.name ?? getName(env.executable); const sv = shortenVersionString(env.version); - const name = `${venvName} (${sv})`; + // Inline-script (PEP 723) environments live in content-addressed cache folders whose names + // are hashes. Surface a human-readable label instead of leaking that hash: the short form + // leads with the version (compact for the status bar), the full name reads "script env". + const isInlineScript = nameStyle === 'inlineScript'; + const name = isInlineScript ? l10n.t('script env ({0})', sv) : `${venvName} (${sv})`; + const shortDisplayName = isInlineScript ? l10n.t('{0} (script)', sv) : `${sv} (${venvName})`; let description = undefined; if (env.kind === NativePythonEnvironmentKind.venvUv) { description = l10n.t('uv'); @@ -200,7 +208,7 @@ async function getPythonInfo(env: NativeEnvInfo): Promise return { name: name, displayName: name, - shortDisplayName: `${sv} (${venvName})`, + shortDisplayName: shortDisplayName, displayPath: env.executable, version: env.version, description: description, @@ -351,6 +359,13 @@ export async function getGlobalVenvLocation(): Promise { return undefined; } +export interface CreateWithProgressOptions { + /** Overrides the progress-notification title shown while the environment is created. */ + readonly progressTitle?: string; + /** Controls how the created environment's user-facing name is formatted. */ + readonly nameStyle?: VenvNameStyle; +} + export async function createWithProgress( nativeFinder: NativePythonFinder, api: PythonEnvironmentApi, @@ -361,17 +376,20 @@ export async function createWithProgress( envPath: string, packages?: PipPackages, trackUvEnvironment = true, + options?: CreateWithProgressOptions, ): Promise { const pythonPath = getVenvPythonPath(envPath); return await withProgress( { location: ProgressLocation.Notification, - title: l10n.t( - 'Creating virtual environment named {0} using python version {1}.', - path.basename(envPath), - basePython.version, - ), + title: + options?.progressTitle ?? + l10n.t( + 'Creating virtual environment named {0} using python version {1}.', + path.basename(envPath), + basePython.version, + ), }, async () => { const result: CreateEnvironmentResult = {}; @@ -400,7 +418,7 @@ export async function createWithProgress( // handle admin of new env const resolved = await nativeFinder.resolve(pythonPath); - const env = api.createPythonEnvironmentItem(await getPythonInfo(resolved), manager); + const env = api.createPythonEnvironmentItem(await getPythonInfo(resolved, options?.nameStyle), manager); if ( trackUvEnvironment && @@ -622,6 +640,7 @@ export async function resolveVenvPythonEnvironmentPath( api: PythonEnvironmentApi, manager: EnvironmentManager, baseManager: EnvironmentManager, + nameStyle: VenvNameStyle = 'default', ): Promise { try { const resolved = await nativeFinder.resolve(fsPath); @@ -631,7 +650,7 @@ export async function resolveVenvPythonEnvironmentPath( resolved.kind === NativePythonEnvironmentKind.venvUv || resolved.kind === NativePythonEnvironmentKind.uvWorkspace ) { - const envInfo = await getPythonInfo(resolved); + const envInfo = await getPythonInfo(resolved, nameStyle); return api.createPythonEnvironmentItem(envInfo, manager); } } catch (ex) { From 1ccb4bd2eb9943d2326248669da9853ad9dc50b8 Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Thu, 3 Sep 2026 14:54:53 -0700 Subject: [PATCH 4/5] Rename inline-script env manager label to Inline scripts Aligns the Environment Managers tree label with the terse style of the other managers (venv, Conda, Global) by dropping the redundant environments suffix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7415b86e-8399-43d5-a504-071cd90592df --- src/managers/builtin/inlineScript/envManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/managers/builtin/inlineScript/envManager.ts b/src/managers/builtin/inlineScript/envManager.ts index 9ca0bb02d..418bdff3a 100644 --- a/src/managers/builtin/inlineScript/envManager.ts +++ b/src/managers/builtin/inlineScript/envManager.ts @@ -223,7 +223,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { public readonly onDidChangeEnvironment: Event = this._onDidChangeEnvironment.event; public readonly name = 'inline-script'; - public readonly displayName = l10n.t('Inline script environments'); + public readonly displayName = l10n.t('Inline scripts'); public readonly preferredPackageManagerId = 'ms-python.python:pip'; public readonly description: string | undefined = undefined; public readonly tooltip: string | MarkdownString = new MarkdownString( From 6038c8b24b7027a54bee70289e1f15460d73a231 Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Thu, 3 Sep 2026 16:09:33 -0700 Subject: [PATCH 5/5] Route closed inline scripts to their environments The routing registry was only ever populated by the lazy detector, which observes open documents. A script set up in an earlier session, or by the bulk command while it was closed, therefore had no routing metadata, could never satisfy shouldRoute, and fell through to the default env manager -- so the project view showed the wrong environment until the file was opened. Seed the registry from the saved file at the two points that already know an inline association exists: - initializePersistedAssociations, so a reload routes persisted scripts - setUpInlineScriptEnvironment, so bulk setup of closed scripts routes Both skip open documents, which the detector owns and whose metadata it deliberately withholds while the block is being edited, and both re-check the registry after the async read so they cannot race it. No metadata is invented: it is read from the saved file exactly as the detector would. Detection telemetry is unaffected because seeding bypasses the detector. The restart-reselect test now lets startup validation settle before measuring, since persisted associations are validated eagerly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/features/inlineScript/setupEnvironment.ts | 23 ++++- .../builtin/inlineScript/envManager.ts | 51 ++++++---- .../setupEnvironment.unit.test.ts | 48 ++++++++++ .../inlineScript/envManager.unit.test.ts | 94 +++++++++++++++++++ 4 files changed, 197 insertions(+), 19 deletions(-) diff --git a/src/features/inlineScript/setupEnvironment.ts b/src/features/inlineScript/setupEnvironment.ts index 9fd1ff380..6bef5ba89 100644 --- a/src/features/inlineScript/setupEnvironment.ts +++ b/src/features/inlineScript/setupEnvironment.ts @@ -4,11 +4,12 @@ import { commands, Disposable, l10n, QuickPickItem, Uri, window } from 'vscode'; import { PythonEnvironment } from '../../api'; import { INLINE_SCRIPT_MANAGER_ID } from '../../common/constants'; -import { InlineScriptRoutingRegistry } from '../../common/inlineScript/routingRegistry'; import { readInlineScriptMetadataFromFile } from '../../common/inlineScript/metadata'; +import { InlineScriptRoutingRegistry } from '../../common/inlineScript/routingRegistry'; import { traceError, traceInfo } from '../../common/logging'; +import { normalizePath } from '../../common/utils/pathUtils'; import { showErrorMessage, showInformationMessage, showQuickPickWithButtons } from '../../common/window.apis'; -import { asRelativePath, findFiles } from '../../common/workspace.apis'; +import { asRelativePath, findFiles, getOpenTextDocuments } from '../../common/workspace.apis'; import { EnvironmentManagers } from '../../internal.api'; import { registerInlineScriptCodeLens } from './codeLens'; @@ -54,6 +55,7 @@ export async function setUpInlineScriptEnvironment( traceError('Inline-script setup requested but the inline-script environment manager is not registered.'); return undefined; } + await seedRoutingMetadataForClosedScript(scriptUri, routing); const metadataIdentityBeforeCreate = routing.getMetadataIdentity(scriptUri); const environment = await manager.create(scriptUri, undefined); if (!environment) { @@ -70,6 +72,23 @@ export async function setUpInlineScriptEnvironment( return environment; } +async function seedRoutingMetadataForClosedScript(scriptUri: Uri, routing: InlineScriptRoutingRegistry): Promise { + if (routing.getMetadata(scriptUri)) { + return; + } + const scriptPath = normalizePath(scriptUri.fsPath); + const isOpen = getOpenTextDocuments().some( + (document) => document.uri.scheme === 'file' && normalizePath(document.uri.fsPath) === scriptPath, + ); + if (isOpen) { + return; + } + const metadata = await readInlineScriptMetadataFromFile(scriptUri); + if (metadata && !routing.getMetadata(scriptUri)) { + routing.setMetadata(scriptUri, metadata); + } +} + function setupInlineScriptEnvironmentHandler( em: EnvironmentManagers, routing: InlineScriptRoutingRegistry, diff --git a/src/managers/builtin/inlineScript/envManager.ts b/src/managers/builtin/inlineScript/envManager.ts index 418bdff3a..bbf12eff0 100644 --- a/src/managers/builtin/inlineScript/envManager.ts +++ b/src/managers/builtin/inlineScript/envManager.ts @@ -1,9 +1,9 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +import type { Stats } from 'fs'; import * as fs from 'fs-extra'; import * as path from 'path'; -import type { Stats } from 'fs'; import { Disposable, Event, @@ -20,8 +20,8 @@ import { CreateEnvironmentScope, DidChangeEnvironmentEventArgs, DidChangeEnvironmentsEventArgs, - EnvironmentManager, EnvironmentChangeKind, + EnvironmentManager, GetEnvironmentScope, GetEnvironmentsScope, IconPath, @@ -31,23 +31,29 @@ import { ResolveEnvironmentContext, SetEnvironmentScope, } from '../../../api'; +import { + CONDA_MANAGER_ID, + INLINE_SCRIPT_MANAGER_ID, + PYENV_MANAGER_ID, + SYSTEM_MANAGER_ID, +} from '../../../common/constants'; import { getErrorMessage } from '../../../common/errors/utils'; import { computeCacheKey, normalizeDependency } from '../../../common/inlineScript/cacheKey'; import { CacheEntrySummary, CacheEnvironmentInspection, - INLINE_SCRIPT_CACHE_DIR_NAME, - InlineScriptEnvMeta, - hashSourceMetadataIdentity, - mergeSourceMetadataIdentityHashes, - META_SCHEMA_VERSION, getBaseInterpreterStatus, getScriptEnvCacheRoot, getScriptEnvDir, - inspectOwnedCacheEntry, + hashSourceMetadataIdentity, + INLINE_SCRIPT_CACHE_DIR_NAME, + InlineScriptEnvMeta, inspectMetaJson, - restoreMetaJsonBackupUnderLock, + inspectOwnedCacheEntry, + mergeSourceMetadataIdentityHashes, + META_SCHEMA_VERSION, resolveCacheEntryPath, + restoreMetaJsonBackupUnderLock, selectStaleEntries, writeMetaJson, } from '../../../common/inlineScript/cacheLayout'; @@ -59,20 +65,13 @@ import { InlineScriptRoutingRegistry, } from '../../../common/inlineScript/routingRegistry'; import { - CONDA_MANAGER_ID, - INLINE_SCRIPT_MANAGER_ID, - PYENV_MANAGER_ID, - SYSTEM_MANAGER_ID, -} from '../../../common/constants'; -import { - acquireFileLock, AcquiredFileLock, + acquireFileLock, FILE_LOCK_DIR_SUFFIX, getFileLockPath, inspectFileLock, reclaimFileLock, } from '../../../common/lockfile.apis'; -import { InlineAssociationAccessor, InlineScriptAssociationStore } from './associationStore'; import { EventNames, InlineScriptEnvErrorCategory } from '../../../common/telemetry/constants'; import { sendTelemetryEvent } from '../../../common/telemetry/sender'; import { createDeferred, Deferred } from '../../../common/utils/deferred'; @@ -87,6 +86,7 @@ import { sortEnvironments } from '../../common/utils'; import { resolveSystemPythonEnvironmentPath } from '../utils'; import * as uvPythonInstaller from '../uvPythonInstaller'; import { createWithProgress, hasMinimumPathDepth, isDriveRoot, resolveVenvPythonEnvironmentPath } from '../venvUtils'; +import { InlineAssociationAccessor, InlineScriptAssociationStore } from './associationStore'; const BASE_INTERPRETER_MANAGER_IDS = new Set([SYSTEM_MANAGER_ID, CONDA_MANAGER_ID, PYENV_MANAGER_ID]); @@ -1835,10 +1835,27 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { }); } + private async seedRoutingMetadataFromSavedFile(uri: Uri, scriptPath: string): Promise { + if (this.routingRegistry.getMetadata(scriptPath) || this.isDocumentOpen(scriptPath)) { + return; + } + const metadata = await readInlineScriptMetadataFromFile(uri); + if (metadata && !this.routingRegistry.getMetadata(scriptPath)) { + this.routingRegistry.setMetadata(uri, metadata); + } + } + + private isDocumentOpen(scriptPath: string): boolean { + return getOpenTextDocuments().some( + (document) => document.uri.scheme === 'file' && normalizePath(document.uri.fsPath) === scriptPath, + ); + } + private initializePersistedAssociations(): Promise { return this.persistedAssociationsLoaded.then(async () => { await Promise.all( [...this.fsPathToPersistedAssociation.keys()].map(async (scriptPath) => { + await this.seedRoutingMetadataFromSavedFile(Uri.file(scriptPath), scriptPath); const uri = this.routingRegistry.getUri(scriptPath); const metadata = this.routingRegistry.getMetadata(scriptPath); if (uri && metadata) { diff --git a/src/test/features/inlineScript/setupEnvironment.unit.test.ts b/src/test/features/inlineScript/setupEnvironment.unit.test.ts index 176fe5550..4a16c5c59 100644 --- a/src/test/features/inlineScript/setupEnvironment.unit.test.ts +++ b/src/test/features/inlineScript/setupEnvironment.unit.test.ts @@ -44,11 +44,15 @@ suite('setUpInlineScriptEnvironment', () => { let em: typemoq.IMock; let manager: typemoq.IMock; let routing: InlineScriptRoutingRegistry; + let readMetadataStub: sinon.SinonStub; + let openDocumentsStub: sinon.SinonStub; setup(() => { em = typemoq.Mock.ofType(); manager = typemoq.Mock.ofType(); routing = new InlineScriptRoutingRegistry(); + readMetadataStub = sinon.stub(metadataApi, 'readInlineScriptMetadataFromFile').resolves(undefined); + openDocumentsStub = sinon.stub(wapi, 'getOpenTextDocuments').returns([]); em.setup((m) => m.getEnvironmentManager(INLINE_SCRIPT_MANAGER_ID)).returns(() => manager.object); }); @@ -87,6 +91,49 @@ suite('setUpInlineScriptEnvironment', () => { em.verify((m) => m.setEnvironment(scriptUri, env), typemoq.Times.once()); }); + test('publishes saved metadata for a closed script so its project can route', async () => { + // The lazy detector only observes open documents, so bulk setup of a closed script would + // otherwise leave the registry with no metadata and the script permanently non-routeable. + const metadata = makeMetadata(['requests']); + readMetadataStub.resolves(metadata); + const env = makeEnv(); + manager.setup((m) => m.create(scriptUri, undefined)).returns(() => Promise.resolve(env)); + em.setup((m) => m.setEnvironment(scriptUri, env)).returns(() => Promise.resolve()); + + const result = await setUpInlineScriptEnvironment(scriptUri, em.object, routing); + + assert.strictEqual(result, env); + assert.deepStrictEqual(routing.getMetadata(scriptUri), metadata); + sinon.assert.calledOnceWithExactly(readMetadataStub, scriptUri); + }); + + test('leaves an open document to the detector instead of publishing from disk', async () => { + openDocumentsStub.returns([{ uri: scriptUri, isDirty: true }]); + readMetadataStub.resolves(makeMetadata(['requests'])); + const env = makeEnv(); + manager.setup((m) => m.create(scriptUri, undefined)).returns(() => Promise.resolve(env)); + em.setup((m) => m.setEnvironment(scriptUri, env)).returns(() => Promise.resolve()); + + await setUpInlineScriptEnvironment(scriptUri, em.object, routing); + + assert.strictEqual(routing.getMetadata(scriptUri), undefined); + sinon.assert.notCalled(readMetadataStub); + }); + + test('does not overwrite metadata the detector already published', async () => { + const observed = makeMetadata(['observed']); + routing.setMetadata(scriptUri, observed); + readMetadataStub.resolves(makeMetadata(['from-disk'])); + const env = makeEnv(); + manager.setup((m) => m.create(scriptUri, undefined)).returns(() => Promise.resolve(env)); + em.setup((m) => m.setEnvironment(scriptUri, env)).returns(() => Promise.resolve()); + + await setUpInlineScriptEnvironment(scriptUri, em.object, routing); + + assert.deepStrictEqual(routing.getMetadata(scriptUri), observed); + sinon.assert.notCalled(readMetadataStub); + }); + test('skips association when the script metadata changes during creation', async () => { routing.setMetadata(scriptUri, makeMetadata(['a'])); const env = makeEnv(); @@ -124,6 +171,7 @@ suite('setUpInlineScriptEnvironmentsInWorkspace', () => { findFilesStub = sinon.stub(wapi, 'findFiles'); sinon.stub(wapi, 'asRelativePath').callsFake((p) => (p instanceof Uri ? p.fsPath : String(p))); + sinon.stub(wapi, 'getOpenTextDocuments').returns([]); readMetadataStub = sinon.stub(metadataApi, 'readInlineScriptMetadataFromFile'); quickPickStub = sinon.stub(winapi, 'showQuickPickWithButtons'); infoStub = sinon.stub(winapi, 'showInformationMessage'); diff --git a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts index 88b387c91..7d8c71e2b 100644 --- a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts +++ b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts @@ -4916,6 +4916,91 @@ suite('InlineScriptEnvManager', () => { restarted.dispose(); }); + test('routes a persisted association after restart without opening the script', async () => { + // Regression: the lazy detector only publishes metadata for open documents, so a script + // set up in an earlier session stayed non-routeable after a reload and its project + // resolved through the default manager (wrong environment in the project view). + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + persistedAssociations = { + [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), + }; + const restartRoutingRegistry = new InlineScriptRoutingRegistry(); + + const restarted = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + globalStorageUri, + makeFakeLog(), + workspaceMemento, + restartRoutingRegistry, + ); + + await waitForCondition( + () => restartRoutingRegistry.shouldRoute(uri), + 'Expected the persisted association to route without the script being opened', + ); + assert.strictEqual(await restarted.get(uri), environment); + restarted.dispose(); + }); + + test('does not seed routing metadata for a persisted script that is open', async () => { + // Open documents belong to the detector, which withholds metadata while the block is + // being edited; seeding from disk would publish content the user has already changed. + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + persistedAssociations = { + [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), + }; + (workspaceApis.getOpenTextDocuments as sinon.SinonStub).returns([ + { uri, isDirty: true } as unknown as TextDocument, + ]); + const restartRoutingRegistry = new InlineScriptRoutingRegistry(); + + const restarted = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + globalStorageUri, + makeFakeLog(), + workspaceMemento, + restartRoutingRegistry, + ); + await nextTurn(); + await nextTurn(); + + assert.strictEqual(restartRoutingRegistry.getMetadata(uri), undefined); + assert.strictEqual(restartRoutingRegistry.shouldRoute(uri), false); + restarted.dispose(); + }); + + test('does not seed routing metadata when the saved script no longer declares metadata', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + persistedAssociations = { + [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), + }; + readMetadataStub.resolves(undefined); + const restartRoutingRegistry = new InlineScriptRoutingRegistry(); + + const restarted = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + globalStorageUri, + makeFakeLog(), + workspaceMemento, + restartRoutingRegistry, + ); + await nextTurn(); + await nextTurn(); + + assert.strictEqual(restartRoutingRegistry.getMetadata(uri), undefined); + assert.strictEqual(restartRoutingRegistry.shouldRoute(uri), false); + restarted.dispose(); + }); + test('does not rewrite or notify when a restart reselects the same persisted executable', async () => { const uri = scriptUri(); const environment = await createOwnedEnvironment(); @@ -4930,6 +5015,15 @@ suite('InlineScriptEnvManager', () => { workspaceMemento, restartRoutingRegistry, ); + // Startup now seeds routing metadata for persisted associations from the saved file and + // validates them, so a closed script routes without being opened first. Let that settle so + // the assertions below measure only the work done by reselecting the same executable. + await waitForCondition( + () => restartRoutingRegistry.hasValidatedAssociation(uri), + 'Expected startup validation to make the persisted association routeable', + ); + resolveVenvStub.resetHistory(); + workspaceState.update.resetHistory(); const listener = sinon.spy(); restarted.onDidChangeEnvironment(listener);