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'); + }); +});