From fd8cbd6afe5f25d3a4365f179afbe16fd4456138 Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Tue, 20 Oct 2026 11:45:02 -0700 Subject: [PATCH] Productize PEP 723 inline script preview Add a default-off experimental setting and use one activation-latched decision across commands, diagnostics, managers, CodeLens, and quick fixes. Update documentation and test coverage for gradual rollout. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 11d44bb8-39ba-4f1f-9a2c-379198878a3a --- .vscode-test.mjs | 11 ++++-- README.md | 5 +++ docs/managing-python-projects.md | 2 + package.json | 11 ++++++ package.nls.json | 1 + src/extension.ts | 3 +- src/features/inlineScript/activation.ts | 2 +- src/features/inlineScript/codeLens.ts | 2 +- src/features/inlineScript/setupCodeAction.ts | 6 +-- src/features/inlineScript/setupEnvironment.ts | 8 ++-- src/helpers.ts | 7 ++-- src/managers/builtin/inlineScript/main.ts | 6 +-- .../inlineScript/setupCodeAction.unit.test.ts | 13 ------- .../helpers.inlineScriptsFeature.unit.test.ts | 4 +- src/test/smoke/registration.smoke.test.ts | 37 ++++++++++++++++--- 15 files changed, 75 insertions(+), 43 deletions(-) diff --git a/.vscode-test.mjs b/.vscode-test.mjs index 445b6eb61..67597e096 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -15,14 +15,17 @@ const diagnosticMocha = diagnosticsDir ? { require: [path.resolve('out', 'test', 'diagnostics.js')] } : {}; -// Seed user settings.json so the extension actually activates: without -// `python.useEnvironmentsExtension=true` it short-circuits during activation -// and the smoke/e2e/integration tests see no registered managers. +// Seed explicit user values so tests are deterministic even when VS Code applies +// experimentation defaults. The environments extension must activate, while the +// inline-script preview stays off unless a dedicated test host opts in. const userDir = path.join(userDataDir, 'User'); fs.mkdirSync(userDir, { recursive: true }); fs.writeFileSync( path.join(userDir, 'settings.json'), - JSON.stringify({ 'python.useEnvironmentsExtension': true }) + '\n', + JSON.stringify({ + 'python.useEnvironmentsExtension': true, + 'python-envs.inlineScripts.enabled': false, + }) + '\n', ); export default defineConfig([ diff --git a/README.md b/README.md index cfbf0f9c1..3d0ff92a8 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ The "Python Projects" fold shows you all of the projects that are currently in y The "Environment Managers" fold shows you all of the environment managers that are available on your machine with all related environments nested below. From this view, you can create new environments, delete old environments, and manage packages. +The PEP 723 inline-script preview can create and select cached environments from dependency metadata embedded in standalone Python files. The preview is controlled by `python-envs.inlineScripts.enabled`, defaults to `false`, and requires a window reload after the effective value changes. + ### Environment Management @@ -118,6 +120,8 @@ All commands can be accessed via the Command Palette (`ctrl/cmd + Shift + P`): | Deactivate Environment in Current Terminal | Deactivates environment in currently opened terminal. | | Run as Task | Runs Python module as a task. | | Create New Project from Template | Creates scaffolded project with virtual environments based on a template. | +| Set Up Environments for Inline Script Files | Selects PEP 723 scripts and creates or reuses their declared environments when the inline-script preview is enabled. | +| Clear Inline Script Environment Cache | Deletes cached PEP 723 script environments and clears their known associations after confirmation. | ## Settings Reference @@ -128,6 +132,7 @@ All commands can be accessed via the Command Palette (`ctrl/cmd + Shift + P`): | defaultEnvManager | `"ms-python.python:venv"` | The default environment manager used for creating and managing environments. | | defaultPackageManager | `"ms-python.python:pip"` | The default package manager to use for installing and managing packages. This is often dictated by the default environment manager but can be customized. | | pythonProjects | `[]` | A list of Python workspaces, specified by the path, in which you can set particular environment and package managers. You can set information for a workspace as `[{"path": "/path/to/workspace", "envManager": "ms-python.python:venv", "packageManager": "ms-python.python:pip"]}`. | +| inlineScripts.enabled | `false` | (preview) Enables creating and selecting environments from PEP 723 inline script metadata. The effective default may be enabled through gradual rollout; an explicit user or workspace value takes precedence. Reload the window after changing this setting. | | terminal.showActivateButton | `false` | (experimental) Show a button in the terminal to activate/deactivate the current environment for the terminal. This button is only shown if the active terminal is associated with a project that has an activatable environment. | | terminal.autoActivationType | `"command"` | Specifies how the extension can activate an environment in a terminal. Accepted values: `command` (execute activation command in terminal), `shellStartup` (`terminal.integrated.shellIntegration.enabled` successfully enabled or we may modify shell startup scripts ), `off` (no auto-activation). Shell startup is only supported for: zsh, fish, pwsh, bash, cmd. **Takes precedence over** `python.terminal.activateEnvironment`. Restart terminals after changing this setting. To revert shell startup changes, run `Python Envs: Revert Shell Startup Script Changes`. | | alwaysUseUv | `true` | When `true`, [uv](https://github.com/astral-sh/uv) will be used to manage all virtual environments if available. When `false`, uv will only manage virtual environments explicitly created by uv. The extension uses `uv` on its PATH, or the executable it installed during the current session. If unavailable in a trusted workspace, it also checks that workspace's `.pyprojectx/main/uv` (`uv.exe` on Windows) for environments inside that workspace. External environments require uv on PATH or a current-session installation. This does not alter the integrated terminal's PATH. | diff --git a/docs/managing-python-projects.md b/docs/managing-python-projects.md index feed6ab6b..feaa3a81f 100644 --- a/docs/managing-python-projects.md +++ b/docs/managing-python-projects.md @@ -99,6 +99,8 @@ my_package_project/ When you create a script, the extension generates a single `.py` file with PEP 723 inline script metadata, which allows you to specify dependencies directly in the file. +> **Preview:** Inline-script environment support is controlled by `python-envs.inlineScripts.enabled` and defaults to `false`. The effective default may be enabled for some users through gradual rollout. You can explicitly enable or disable the setting at user or workspace scope; reload the window after changing it. + 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 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. diff --git a/package.json b/package.json index 561157277..2eab6ccf4 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,17 @@ } } }, + "python-envs.inlineScripts.enabled": { + "type": "boolean", + "description": "%python-envs.inlineScripts.enabled.description%", + "default": false, + "scope": "window", + "requiresReload": true, + "tags": [ + "onExP", + "preview" + ] + }, "python-envs.terminal.showActivateButton": { "type": "boolean", "description": "%python-envs.terminal.showActivateButton.description%", diff --git a/package.nls.json b/package.nls.json index c649a7be0..c24b97198 100644 --- a/package.nls.json +++ b/package.nls.json @@ -5,6 +5,7 @@ "python-envs.pythonProjects.path.description": "The path to a folder or file in the workspace to be treated as a Python project.", "python-envs.pythonProjects.envManager.description": "The environment manager for creating and managing environments for this project.", "python-envs.pythonProjects.packageManager.description": "The package manager for managing packages in environments for this project.", + "python-envs.inlineScripts.enabled.description": "Enables preview support for creating and selecting Python environments from PEP 723 inline script metadata. Reload the window after changing this setting.", "python-envs.terminal.showActivateButton.description": "Whether to show the 'Activate' button in the terminal menu", "python-envs.terminal.autoActivationType.description": "Specifies how the extension can activate an environment in a terminal.\n\nShell startup activates using [shell integration](https://code.visualstudio.com/docs/terminal/shell-integration) or may require changes to the shell initialization files and is only enabled for the following shells: zsh, fsh, pwsh, bash, cmd. When set to `command`, any shell can be activated.\n\n**Legacy Setting Support:** This setting takes precedence over the legacy `python.terminal.activateEnvironment` setting. If this setting is not explicitly set and `python.terminal.activateEnvironment` is set to false, this setting will automatically be set to `off` to preserve your preference.\n\nThis setting applies only when terminals are created, so you will need to restart your terminals for it to take effect.\n\nTo revert changes made during shellStartup, run `Python Envs: Revert Shell Startup Script Changes`.", "python-envs.terminal.autoActivationType.command": "Activation by executing a command in the terminal.", diff --git a/src/extension.ts b/src/extension.ts index 21c0755ac..c1ff579a0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -101,7 +101,6 @@ import { TemporaryStateManager } from './features/views/temporaryStateManager'; import { PythonEnvTreeItem } from './features/views/treeViewItems'; import { getEnvManagerAndPackageManagerConfigLevels, - isInlineScriptsFeatureEnabled, runPetInTerminalImpl, } from './helpers'; import type { ProjectCreators } from './features/creators/projectCreators'; @@ -402,7 +401,7 @@ export async function activate(context: ExtensionContext): Promise { await clearEnvironmentCachesCommand(envManagers, shellStartupProviders, context.workspaceState); }), - ...(isInlineScriptsFeatureEnabled() + ...(inlineScriptFeatureActivation.enabled ? [ commands.registerCommand('python-envs.clearScriptEnvCache', async () => { await clearScriptEnvironmentCacheCommand(envManagers); diff --git a/src/features/inlineScript/activation.ts b/src/features/inlineScript/activation.ts index 74a9f3b64..205f39498 100644 --- a/src/features/inlineScript/activation.ts +++ b/src/features/inlineScript/activation.ts @@ -10,7 +10,7 @@ export interface InlineScriptFeatureActivation { } /** - * Latch the inline-script feature flag once during activation. + * Latch the inline-script preview setting once during activation. * The setting requires a window reload, so later config changes * in the same activation must not change the chosen mode. */ diff --git a/src/features/inlineScript/codeLens.ts b/src/features/inlineScript/codeLens.ts index 0020b7878..88cbedd5b 100644 --- a/src/features/inlineScript/codeLens.ts +++ b/src/features/inlineScript/codeLens.ts @@ -134,7 +134,7 @@ export class InlineScriptCodeLensProvider implements CodeLensProvider, Disposabl /** * 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. + * inline-script preview is enabled for this activation, so it is a no-op for everyone else. */ export function registerInlineScriptCodeLens( routing: InlineScriptRoutingRegistry, diff --git a/src/features/inlineScript/setupCodeAction.ts b/src/features/inlineScript/setupCodeAction.ts index 695dd5f62..acdb1a93a 100644 --- a/src/features/inlineScript/setupCodeAction.ts +++ b/src/features/inlineScript/setupCodeAction.ts @@ -16,7 +16,6 @@ import { import { readInlineScriptMetadata, sliceHeaderBytes } from '../../common/inlineScript/metadata'; import { getInlineScriptRoutingKey, InlineScriptRoutingRegistry } from '../../common/inlineScript/routingRegistry'; import { InlineScriptStrings } from '../../common/localize'; -import { isInlineScriptsFeatureEnabled } from '../../helpers'; /** * Diagnostic codes meaning "this import did not resolve", lowercased for comparison. @@ -82,9 +81,6 @@ export class InlineScriptSetupCodeActionProvider implements CodeActionProvider { context: CodeActionContext, _token: CancellationToken, ): CodeAction[] { - if (!isInlineScriptsFeatureEnabled()) { - return []; - } if (!context.diagnostics.some(isUnresolvedImportDiagnostic)) { return []; } @@ -108,7 +104,7 @@ export class InlineScriptSetupCodeActionProvider implements CodeActionProvider { } } -/** Register the inline-script quick fix for local `.py` files. */ +/** Register the inline-script quick fix for local `.py` files while the preview is active. */ export function registerInlineScriptSetupCodeAction( routing: InlineScriptRoutingRegistry, setupCommand: string, diff --git a/src/features/inlineScript/setupEnvironment.ts b/src/features/inlineScript/setupEnvironment.ts index 13184ab18..58101bf5b 100644 --- a/src/features/inlineScript/setupEnvironment.ts +++ b/src/features/inlineScript/setupEnvironment.ts @@ -29,14 +29,14 @@ import { registerInlineScriptSetupCodeAction } from './setupCodeAction'; /** * Hidden command invoked by the inline-script CodeLens to set up the environment for one script. - * Intentionally not contributed in `package.json` while the feature is behind the internal flag. + * Intentionally not contributed in `package.json` because only the CodeLens and quick fix invoke it. */ export const SETUP_INLINE_SCRIPT_ENV_COMMAND = 'python-envs.setupInlineScriptEnv'; /** * 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). + * preview is active (gated by the activation-latched `pythonEnvsInlineScriptsEnabled` context key). */ export const SETUP_INLINE_SCRIPT_ENVS_COMMAND = 'python-envs.setupInlineScriptEnvs'; @@ -409,9 +409,9 @@ async function filterInlineScriptFiles(files: readonly Uri[]): Promise { /** * Register the inline-script user-facing surfaces (the CodeLens, the quick fix, and their setup - * commands). Only called when the PEP 723 inline-script feature flag is enabled. The single-file + * commands). Only called when the PEP 723 inline-script preview is active. The single-file * setup command is invoked by both surfaces and stays out of `package.json`; the bulk command is - * palette-gated behind the flag. + * palette-gated behind the activation-latched context key. */ export function registerInlineScriptUx(em: EnvironmentManagers, routing: InlineScriptRoutingRegistry): Disposable[] { const codeLens = registerInlineScriptCodeLens(routing, SETUP_INLINE_SCRIPT_ENV_COMMAND); diff --git a/src/helpers.ts b/src/helpers.ts index 5db5e0107..eb999ec6f 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -146,9 +146,10 @@ export function getUserConfiguredSetting(section: string, key: string, scope? } /** - * Whether the PEP 723 inline-script env support is enabled. Internal - * undeclared flag (`python-envs.inlineScripts.enabled`); defaults to - * false. Window reload required to take effect. + * Whether the PEP 723 inline-script preview is enabled. Reads the effective + * `python-envs.inlineScripts.enabled` value, including an experimental default + * supplied by VS Code. The value is latched during activation and requires a + * window reload to change. */ export function isInlineScriptsFeatureEnabled(): boolean { return getConfiguration('python-envs').get('inlineScripts.enabled', false); diff --git a/src/managers/builtin/inlineScript/main.ts b/src/managers/builtin/inlineScript/main.ts index 3c7ea1d21..61293dbf9 100644 --- a/src/managers/builtin/inlineScript/main.ts +++ b/src/managers/builtin/inlineScript/main.ts @@ -11,7 +11,7 @@ import { InlineScriptEnvManager } from './envManager'; /** * Register the inline-script env manager when the activation-latched - * `python-envs.inlineScripts.enabled` flag is true. + * `python-envs.inlineScripts.enabled` preview setting is true. */ export async function registerInlineScriptFeatures( nativeFinder: NativePythonFinder, @@ -23,7 +23,7 @@ export async function registerInlineScriptFeatures( workspaceState: Memento, ): Promise { if (!activation.enabled) { - traceVerbose('Inline-script env manager: skipping registration (internal flag is off)'); + traceVerbose('Inline-script env manager: skipping registration (preview setting is off)'); return; } const { routingRegistry } = activation; @@ -43,5 +43,5 @@ export async function registerInlineScriptFeatures( ); disposables.push(mgr, api.registerEnvironmentManager(mgr)); setImmediate(() => mgr.startActivationDiscovery()); - traceInfo('Inline-script env manager: registered (internal flag is on)'); + traceInfo('Inline-script env manager: registered (preview setting is on)'); } diff --git a/src/test/features/inlineScript/setupCodeAction.unit.test.ts b/src/test/features/inlineScript/setupCodeAction.unit.test.ts index fdbe94482..d46712c00 100644 --- a/src/test/features/inlineScript/setupCodeAction.unit.test.ts +++ b/src/test/features/inlineScript/setupCodeAction.unit.test.ts @@ -2,7 +2,6 @@ // Licensed under the MIT License. import assert from 'assert'; -import * as sinon from 'sinon'; import { CodeActionContext, Diagnostic, Position, Range, TextDocument, Uri } from 'vscode'; import { InlineScriptMetadata, MAX_HEADER_BYTES } from '../../../common/inlineScript/metadata'; import { InlineScriptRoutingRegistry } from '../../../common/inlineScript/routingRegistry'; @@ -11,7 +10,6 @@ import { InlineScriptSetupCodeActionProvider, isUnresolvedImportDiagnostic, } from '../../../features/inlineScript/setupCodeAction'; -import * as helpers from '../../../helpers'; const SETUP_COMMAND = 'python-envs.setupInlineScriptEnv'; @@ -60,17 +58,14 @@ suite('Inline script setup code action', () => { const scriptUri = Uri.file('/workspace/app.py'); let routing: InlineScriptRoutingRegistry; let provider: InlineScriptSetupCodeActionProvider; - let featureEnabledStub: sinon.SinonStub; setup(() => { - featureEnabledStub = sinon.stub(helpers, 'isInlineScriptsFeatureEnabled').returns(true); routing = new InlineScriptRoutingRegistry(); provider = new InlineScriptSetupCodeActionProvider(routing, SETUP_COMMAND); }); teardown(() => { routing.dispose(); - sinon.restore(); }); function provide(document: TextDocument, diagnostics: Diagnostic[]) { @@ -206,14 +201,6 @@ suite('Inline script setup code action', () => { assert.strictEqual(provide(makeDocument(scriptUri), [makeDiagnostic('reportMissingImports')]).length, 0); }); - test('offers nothing when the inline-scripts feature flag is off', () => { - featureEnabledStub.returns(false); - - const actions = provide(makeDocument(scriptUri), [makeDiagnostic('reportMissingImports')]); - - assert.strictEqual(actions.length, 0); - }); - test('offers nothing for a file that cannot carry an inline-script environment', () => { const notebookUri = Uri.parse('vscode-notebook-cell:/workspace/app.py#ch0'); diff --git a/src/test/helpers.inlineScriptsFeature.unit.test.ts b/src/test/helpers.inlineScriptsFeature.unit.test.ts index 7c2436594..3a170ef09 100644 --- a/src/test/helpers.inlineScriptsFeature.unit.test.ts +++ b/src/test/helpers.inlineScriptsFeature.unit.test.ts @@ -26,12 +26,12 @@ suite('isInlineScriptsFeatureEnabled', () => { sinon.restore(); }); - test('returns false by default (no setting written)', () => { + test('returns false when the effective setting value is false', () => { configGet.withArgs('inlineScripts.enabled', false).returns(false); assert.strictEqual(isInlineScriptsFeatureEnabled(), false); }); - test('returns true when the user explicitly enables the setting', () => { + test('returns true when the effective setting value is true', () => { configGet.withArgs('inlineScripts.enabled', false).returns(true); assert.strictEqual(isInlineScriptsFeatureEnabled(), true); }); diff --git a/src/test/smoke/registration.smoke.test.ts b/src/test/smoke/registration.smoke.test.ts index 50b89fadc..599bcf260 100644 --- a/src/test/smoke/registration.smoke.test.ts +++ b/src/test/smoke/registration.smoke.test.ts @@ -135,7 +135,7 @@ suite('Smoke: Registration Checks', function () { assert.strictEqual( paletteEntry.when, 'pythonEnvsInlineScriptsEnabled', - 'python-envs.clearScriptEnvCache should only appear when the inline-scripts feature flag is enabled', + 'python-envs.clearScriptEnvCache should only appear when the inline-script preview is active', ); }); @@ -163,16 +163,43 @@ suite('Smoke: Registration Checks', function () { assert.strictEqual( paletteEntry.when, 'pythonEnvsInlineScriptsEnabled', - 'python-envs.setupInlineScriptEnvs should only appear when the inline-scripts feature flag is enabled', + 'python-envs.setupInlineScriptEnvs should only appear when the inline-script preview is active', ); }); - test('Internal inline-script commands are not registered while the feature flag is off', async function () { + test('Inline-script preview setting is declared for a default-off settings rollout', function () { + const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); + assert.ok(extension, `Extension ${ENVS_EXTENSION_ID} not found`); + + const properties = extension.packageJSON?.contributes?.configuration?.properties as + | Record< + string, + { + type?: string; + default?: unknown; + scope?: string; + requiresReload?: boolean; + tags?: string[]; + } + > + | undefined; + const setting = properties?.['python-envs.inlineScripts.enabled']; + + assert.ok(setting, 'python-envs.inlineScripts.enabled should be contributed'); + assert.strictEqual(setting.type, 'boolean'); + assert.strictEqual(setting.default, false); + assert.strictEqual(setting.scope, 'window'); + assert.strictEqual(setting.requiresReload, true); + assert.ok(setting.tags?.includes('onExP'), 'the setting should participate in settings experiments'); + assert.ok(setting.tags?.includes('preview'), 'the setting should be marked as preview'); + }); + + test('Inline-script commands are not registered while the effective setting is off', async function () { const allCommands = await vscode.commands.getCommands(true); assert.ok( !allCommands.includes('python-envs.clearScriptEnvCache'), - 'python-envs.clearScriptEnvCache should not be registered by default', + 'python-envs.clearScriptEnvCache should not be registered while the preview is off', ); await assert.rejects( () => Promise.resolve(vscode.commands.executeCommand('python-envs.clearScriptEnvCache')), @@ -181,7 +208,7 @@ suite('Smoke: Registration Checks', function () { assert.ok( !allCommands.includes('python-envs.setupInlineScriptEnvs'), - 'python-envs.setupInlineScriptEnvs should not be registered by default', + 'python-envs.setupInlineScriptEnvs should not be registered while the preview is off', ); await assert.rejects( () => Promise.resolve(vscode.commands.executeCommand('python-envs.setupInlineScriptEnvs')),