From d2bd1a9e7e8b04e85c5e5f1e38561608977b119e Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Tue, 23 Jun 2026 15:15:03 -0700 Subject: [PATCH] feat(inline-scripts): add InlineScriptEnvManager skeleton (PEP 723 PR 4/16) First PR in Phase 2 of the inline-script roadmap. Lands the empty manager class and a registration helper gated behind an INTERNAL flag, so the plumbing for PRs 5-16 can land incrementally without any user-visible surface. What is in: - src/managers/builtin/inlineScriptEnvManager.ts (new) Class implementing EnvironmentManager: name="inline-script", displayName="Inline script environments", iconPath=file-code, preferredPackageManagerId="ms-python.python:pip". Every method is a sentinel: getEnvironments returns [], get returns undefined, set / refresh are no-ops, resolve returns undefined. Optional methods (create, remove, quickCreateConfig) are deliberately omitted so the UI hides their entry points until PR 5 lands them. - src/managers/builtin/inlineScriptMain.ts (new) registerInlineScriptFeatures(disposables, log) -- gated registration helper. Reads isInlineScriptsFeatureEnabled() and returns early (with a traceVerbose, not traceInfo, so no per- activation log noise for default users) when the flag is false. When true, constructs the manager and registers it. - src/helpers.ts isInlineScriptsFeatureEnabled(): boolean -- reads python-envs.inlineScripts.enabled via getConfiguration().get(...). The setting is INTENTIONALLY NOT DECLARED in package.json, so: - it does not appear in Settings UI - it does not appear in JSON autocomplete - it does not appear in settings search - end users never discover it Devs / CI can still opt in by manually adding it to settings.json (VS Code will mark it as an unknown setting with a yellow squiggle -- that is a feature, not a bug; signals you are on an internal flag). The whole gate goes away in PR 16 when the feature ships for real. - src/extension.ts Wires registerInlineScriptFeatures into the existing Promise.all of manager-registration tasks, alongside system, conda, pyenv, pipenv, poetry, and shellStartupVars. - src/test/managers/builtin/inlineScriptEnvManager.unit.test.ts (new) 19 tests pinning the skeleton's no-op contract: static metadata, all five EnvironmentManager methods, optional methods correctly omitted, events exposed but never fired, dispose() is idempotent. - src/test/managers/builtin/inlineScriptMain.unit.test.ts (new) 2 tests pinning the gate itself: flag false -> no register call, no getPythonApi call, no disposable pushed; flag true -> exactly one register call, two disposables pushed (mgr + registration handle). These guard the entire "zero user impact" promise against future refactors. - src/test/helpers.inlineScriptsFeature.unit.test.ts (new) 3 tests covering the feature-flag helper: defaults to false, returns true on user-set, reads from the python-envs section. User impact Zero. No setting in package.json, so: - No setting visible in Settings UI search - No autocomplete entry when typing "python-envs..." in settings.json - No "Preview" badge or any indication the feature exists - No empty picker section, no commands, no status-bar changes - No log output on the default log level PR 5-15 will land on top of this gate; PR 16 removes the gate and declares the public setting for real. Design context Implements PR 4 in Phase 2 of pep723_design_questions.md. The internal-flag approach (undeclared setting) is the strictest interpretation of "zero user impact during incremental rollout" -- discussed in the planning conversation as the alternative to a declared-but-default-false setting (which would still show in autocomplete and confuse early adopters). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/extension.ts | 2 + src/helpers.ts | 9 + .../builtin/inlineScriptEnvManager.ts | 68 ++++++++ src/managers/builtin/inlineScriptMain.ts | 26 +++ .../helpers.inlineScriptsFeature.unit.test.ts | 47 +++++ .../inlineScriptEnvManager.unit.test.ts | 161 ++++++++++++++++++ .../builtin/inlineScriptMain.unit.test.ts | 67 ++++++++ 7 files changed, 380 insertions(+) create mode 100644 src/managers/builtin/inlineScriptEnvManager.ts create mode 100644 src/managers/builtin/inlineScriptMain.ts create mode 100644 src/test/helpers.inlineScriptsFeature.unit.test.ts create mode 100644 src/test/managers/builtin/inlineScriptEnvManager.unit.test.ts create mode 100644 src/test/managers/builtin/inlineScriptMain.unit.test.ts diff --git a/src/extension.ts b/src/extension.ts index 67c40944..e574eeab 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -98,6 +98,7 @@ import { ProjectItem, PythonEnvTreeItem } from './features/views/treeViewItems'; import { collectEnvironmentInfo, getEnvManagerAndPackageManagerConfigLevels, runPetInTerminalImpl } from './helpers'; import { EnvironmentManagers, ProjectCreators, PythonProjectManager } from './internal.api'; import { registerSystemPythonFeatures } from './managers/builtin/main'; +import { registerInlineScriptFeatures } from './managers/builtin/inlineScriptMain'; import { SysPythonManager } from './managers/builtin/sysPythonManager'; import { createNativePythonFinder, @@ -656,6 +657,7 @@ export async function activate(context: ExtensionContext): Promise(section: string, key: string, scope? return undefined; } +/** + * 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. + */ +export function isInlineScriptsFeatureEnabled(): boolean { + return getConfiguration('python-envs').get('inlineScripts.enabled', false); +} + /** * Runs the Python Environment Tool (PET) in a terminal window, allowing users to * execute various PET commands like finding all Python environments or resolving diff --git a/src/managers/builtin/inlineScriptEnvManager.ts b/src/managers/builtin/inlineScriptEnvManager.ts new file mode 100644 index 00000000..7a1198af --- /dev/null +++ b/src/managers/builtin/inlineScriptEnvManager.ts @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { Disposable, Event, EventEmitter, l10n, LogOutputChannel, MarkdownString, ThemeIcon } from 'vscode'; +import { + DidChangeEnvironmentEventArgs, + DidChangeEnvironmentsEventArgs, + EnvironmentManager, + GetEnvironmentScope, + GetEnvironmentsScope, + IconPath, + PythonEnvironment, + RefreshEnvironmentsScope, + ResolveEnvironmentContext, + SetEnvironmentScope, +} from '../../api'; + +/** + * Skeleton EnvironmentManager for PEP 723 inline-script envs. Every + * method returns the empty / undefined / no-op equivalent; `create`, + * `remove`, and `quickCreateConfig` are intentionally omitted so the + * picker UI hides their entry points until later PRs land them. + */ +export class InlineScriptEnvManager implements EnvironmentManager, Disposable { + private readonly _onDidChangeEnvironments = new EventEmitter(); + public readonly onDidChangeEnvironments: Event = + this._onDidChangeEnvironments.event; + + private readonly _onDidChangeEnvironment = new EventEmitter(); + public readonly onDidChangeEnvironment: Event = this._onDidChangeEnvironment.event; + + public readonly name = 'inline-script'; + public readonly displayName = l10n.t('Inline script environments'); + public readonly preferredPackageManagerId = 'ms-python.python:pip'; + public readonly description: string | undefined = undefined; + public readonly tooltip: string | MarkdownString = new MarkdownString( + l10n.t('Environments built from PEP 723 inline script metadata.'), + true, + ); + public readonly iconPath: IconPath = new ThemeIcon('file-code'); + + constructor(public readonly log: LogOutputChannel) {} + + async refresh(_scope: RefreshEnvironmentsScope): Promise { + return; + } + + async getEnvironments(_scope: GetEnvironmentsScope): Promise { + return []; + } + + async set(_scope: SetEnvironmentScope, _environment?: PythonEnvironment): Promise { + return; + } + + async get(_scope: GetEnvironmentScope): Promise { + return undefined; + } + + async resolve(_context: ResolveEnvironmentContext): Promise { + return undefined; + } + + dispose(): void { + this._onDidChangeEnvironments.dispose(); + this._onDidChangeEnvironment.dispose(); + } +} diff --git a/src/managers/builtin/inlineScriptMain.ts b/src/managers/builtin/inlineScriptMain.ts new file mode 100644 index 00000000..fcca5643 --- /dev/null +++ b/src/managers/builtin/inlineScriptMain.ts @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { Disposable, LogOutputChannel } from 'vscode'; +import { PythonEnvironmentApi } from '../../api'; +import { traceInfo, traceVerbose } from '../../common/logging'; +import { getPythonApi } from '../../features/pythonApi'; +import { isInlineScriptsFeatureEnabled } from '../../helpers'; +import { InlineScriptEnvManager } from './inlineScriptEnvManager'; + +/** + * Register the inline-script env manager when the internal + * `python-envs.inlineScripts.enabled` flag is true. The flag is + * undeclared in `package.json`, so default users see nothing. + */ +export async function registerInlineScriptFeatures(disposables: Disposable[], log: LogOutputChannel): Promise { + if (!isInlineScriptsFeatureEnabled()) { + traceVerbose('Inline-script env manager: skipping registration (internal flag is off)'); + return; + } + + const api: PythonEnvironmentApi = await getPythonApi(); + const mgr = new InlineScriptEnvManager(log); + disposables.push(mgr, api.registerEnvironmentManager(mgr)); + traceInfo('Inline-script env manager: registered (internal flag is on)'); +} diff --git a/src/test/helpers.inlineScriptsFeature.unit.test.ts b/src/test/helpers.inlineScriptsFeature.unit.test.ts new file mode 100644 index 00000000..7c243659 --- /dev/null +++ b/src/test/helpers.inlineScriptsFeature.unit.test.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import * as sinon from 'sinon'; +import { WorkspaceConfiguration } from 'vscode'; +import * as workspaceApis from '../common/workspace.apis'; +import { isInlineScriptsFeatureEnabled } from '../helpers'; + +suite('isInlineScriptsFeatureEnabled', () => { + let getConfigurationStub: sinon.SinonStub; + let configGet: sinon.SinonStub; + + setup(() => { + configGet = sinon.stub(); + const fakeConfig = { + get: configGet, + has: sinon.stub(), + inspect: sinon.stub(), + update: sinon.stub(), + } as unknown as WorkspaceConfiguration; + getConfigurationStub = sinon.stub(workspaceApis, 'getConfiguration').returns(fakeConfig); + }); + + teardown(() => { + sinon.restore(); + }); + + test('returns false by default (no setting written)', () => { + configGet.withArgs('inlineScripts.enabled', false).returns(false); + assert.strictEqual(isInlineScriptsFeatureEnabled(), false); + }); + + test('returns true when the user explicitly enables the setting', () => { + configGet.withArgs('inlineScripts.enabled', false).returns(true); + assert.strictEqual(isInlineScriptsFeatureEnabled(), true); + }); + + test('reads from the python-envs section', () => { + configGet.withArgs('inlineScripts.enabled', false).returns(false); + isInlineScriptsFeatureEnabled(); + assert.ok( + getConfigurationStub.calledWith('python-envs'), + 'expected getConfiguration("python-envs") to be called', + ); + }); +}); diff --git a/src/test/managers/builtin/inlineScriptEnvManager.unit.test.ts b/src/test/managers/builtin/inlineScriptEnvManager.unit.test.ts new file mode 100644 index 00000000..91e1bd84 --- /dev/null +++ b/src/test/managers/builtin/inlineScriptEnvManager.unit.test.ts @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import * as sinon from 'sinon'; +import { LogOutputChannel, Uri } from 'vscode'; +import { EnvironmentManager, PythonEnvironment } from '../../../api'; +import { InlineScriptEnvManager } from '../../../managers/builtin/inlineScriptEnvManager'; + +function makeFakeLog(): LogOutputChannel { + return sinon.createStubInstance( + class { + info() {} + warn() {} + error() {} + debug() {} + trace() {} + show() {} + dispose() {} + append() {} + appendLine() {} + replace() {} + clear() {} + hide() {} + }, + ) as unknown as LogOutputChannel; +} + +function makeEnv(): PythonEnvironment { + return { + envId: { id: 'fake', managerId: 'ms-python.python:inline-script' }, + name: 'fake', + displayName: 'fake', + displayPath: '/fake', + version: '3.12.0', + environmentPath: Uri.file('/fake'), + execInfo: { run: { executable: '/fake' } }, + sysPrefix: '/fake', + }; +} + +suite('InlineScriptEnvManager (skeleton)', () => { + let mgr: InlineScriptEnvManager; + + setup(() => { + mgr = new InlineScriptEnvManager(makeFakeLog()); + }); + + teardown(() => { + mgr.dispose(); + sinon.restore(); + }); + + suite('static metadata', () => { + test('name is "inline-script"', () => { + assert.strictEqual(mgr.name, 'inline-script'); + }); + + test('displayName is set (for the picker section header)', () => { + assert.ok(mgr.displayName); + assert.ok(mgr.displayName.length > 0); + }); + + test('preferredPackageManagerId is the standard pip manager id', () => { + assert.strictEqual(mgr.preferredPackageManagerId, 'ms-python.python:pip'); + }); + + test('iconPath is defined (renders in the picker)', () => { + assert.ok(mgr.iconPath); + }); + + test('tooltip is defined (shown on hover in the picker)', () => { + assert.ok(mgr.tooltip); + }); + }); + + suite('skeleton method behavior', () => { + test('getEnvironments("all") returns []', async () => { + assert.deepStrictEqual(await mgr.getEnvironments('all'), []); + }); + + test('getEnvironments("global") returns []', async () => { + assert.deepStrictEqual(await mgr.getEnvironments('global'), []); + }); + + test('getEnvironments(Uri) returns []', async () => { + assert.deepStrictEqual(await mgr.getEnvironments(Uri.file('/tmp/script.py')), []); + }); + + test('get(undefined) returns undefined', async () => { + assert.strictEqual(await mgr.get(undefined), undefined); + }); + + test('get(Uri) returns undefined', async () => { + assert.strictEqual(await mgr.get(Uri.file('/tmp/script.py')), undefined); + }); + + test('set(scope, env) is a no-op and does not throw', async () => { + await assert.doesNotReject(mgr.set(Uri.file('/tmp/script.py'), makeEnv())); + await assert.doesNotReject(mgr.set(undefined, undefined)); + }); + + test('refresh(scope) is a no-op and does not throw', async () => { + await assert.doesNotReject(mgr.refresh(undefined)); + await assert.doesNotReject(mgr.refresh(Uri.file('/tmp/script.py'))); + }); + + test('resolve(Uri) returns undefined', async () => { + assert.strictEqual(await mgr.resolve(Uri.file('/tmp/script.py')), undefined); + }); + + test('does not implement optional create / remove / quickCreateConfig', () => { + // Cast via the interface to probe optional methods (the concrete class type doesn't declare them). + const asInterface: EnvironmentManager = mgr; + assert.strictEqual(asInterface.create, undefined); + assert.strictEqual(asInterface.remove, undefined); + assert.strictEqual(asInterface.quickCreateConfig, undefined); + }); + }); + + suite('events', () => { + test('onDidChangeEnvironments is exposed and subscribable', () => { + const disposable = mgr.onDidChangeEnvironments(() => undefined); + assert.ok(disposable); + disposable.dispose(); + }); + + test('onDidChangeEnvironment is exposed and subscribable', () => { + const disposable = mgr.onDidChangeEnvironment(() => undefined); + assert.ok(disposable); + disposable.dispose(); + }); + + test('skeleton methods do not fire any events', async () => { + const envsListener = sinon.spy(); + const envListener = sinon.spy(); + mgr.onDidChangeEnvironments(envsListener); + mgr.onDidChangeEnvironment(envListener); + + await mgr.getEnvironments('all'); + await mgr.get(undefined); + await mgr.set(Uri.file('/tmp/script.py'), makeEnv()); + await mgr.refresh(undefined); + await mgr.resolve(Uri.file('/tmp/script.py')); + + assert.strictEqual(envsListener.callCount, 0, 'getEnvironments/refresh must not fire envs event'); + assert.strictEqual(envListener.callCount, 0, 'set must not fire env event in the skeleton'); + }); + }); + + suite('disposal', () => { + test('dispose() does not throw', () => { + assert.doesNotThrow(() => mgr.dispose()); + }); + + test('dispose() is idempotent', () => { + mgr.dispose(); + assert.doesNotThrow(() => mgr.dispose()); + }); + }); +}); diff --git a/src/test/managers/builtin/inlineScriptMain.unit.test.ts b/src/test/managers/builtin/inlineScriptMain.unit.test.ts new file mode 100644 index 00000000..4d5ac3a3 --- /dev/null +++ b/src/test/managers/builtin/inlineScriptMain.unit.test.ts @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import * as sinon from 'sinon'; +import { Disposable, LogOutputChannel } from 'vscode'; +import { PythonEnvironmentApi } from '../../../api'; +import * as pythonApi from '../../../features/pythonApi'; +import * as helpers from '../../../helpers'; +import { registerInlineScriptFeatures } from '../../../managers/builtin/inlineScriptMain'; + +function makeFakeLog(): LogOutputChannel { + return { + info: () => undefined, + warn: () => undefined, + error: () => undefined, + debug: () => undefined, + trace: () => undefined, + show: () => undefined, + dispose: () => undefined, + append: () => undefined, + appendLine: () => undefined, + replace: () => undefined, + clear: () => undefined, + hide: () => undefined, + } as unknown as LogOutputChannel; +} + +suite('registerInlineScriptFeatures (feature-flag gate)', () => { + let isEnabledStub: sinon.SinonStub; + let getPythonApiStub: sinon.SinonStub; + let registerEnvironmentManagerStub: sinon.SinonStub; + + setup(() => { + isEnabledStub = sinon.stub(helpers, 'isInlineScriptsFeatureEnabled'); + registerEnvironmentManagerStub = sinon.stub<[unknown], Disposable>().returns({ dispose: () => undefined }); + getPythonApiStub = sinon.stub(pythonApi, 'getPythonApi').resolves({ + registerEnvironmentManager: registerEnvironmentManagerStub, + } as unknown as PythonEnvironmentApi); + }); + + teardown(() => { + sinon.restore(); + }); + + test('when the feature flag is FALSE: does not register, does not even fetch the API', async () => { + isEnabledStub.returns(false); + const disposables: Disposable[] = []; + + await registerInlineScriptFeatures(disposables, makeFakeLog()); + + assert.strictEqual(disposables.length, 0, 'no disposables should be added when flag is off'); + assert.strictEqual(getPythonApiStub.called, false, 'should not even call getPythonApi when gated off'); + assert.strictEqual(registerEnvironmentManagerStub.called, false); + }); + + test('when the feature flag is TRUE: registers the manager and pushes the disposable', async () => { + isEnabledStub.returns(true); + const disposables: Disposable[] = []; + + await registerInlineScriptFeatures(disposables, makeFakeLog()); + + assert.strictEqual(getPythonApiStub.callCount, 1); + assert.strictEqual(registerEnvironmentManagerStub.callCount, 1); + assert.strictEqual(disposables.length, 2, 'expected manager + registration disposable'); + }); +});