Skip to content

Commit 707f2a8

Browse files
Add empty InlineScriptEnvManager skeleton behind internal flag (PEP 723 PR 4/16) (#1610)
> Part of #1602 (PEP 723 inline script env support). Design doc: #1601. ### Roadmap context — where this PR sits This is **PR 4 of 16** in the PEP 723 inline-script roadmap. The full plan lives in #1602; here's a one-line summary of where each PR sits relative to this one: | Phase | PR | Status | |---|---|---| | **Phase 1 — Foundation** | PR 1: cache key hash utility | _in progress_ | | | PR 2: cache layout + `meta.json` sidecar | _in progress_ | | | PR 3: `requires-python` → interpreter selection | _in progress_ | | **Phase 2 — Manager** | **PR 4: `InlineScriptEnvManager` skeleton** | **this PR** | | | PR 5: `create()` happy path | not started (needs 1, 2, 3, 4) | | | PR 6: `create()` uv-install fallback | not started (needs 3, 5) | | | PR 7: persistence — `get` / `set` + Memento | not started (needs 4) | | | PR 8: activation-time discovery | not started (needs 2, 4, 7) | | **Phase 3 — Routing** | PR 9: route PEP 723 scripts to inline manager | not started (needs 4, 7) | | | PR 10: per-script project registration | not started (needs 9) | | **Phase 3.5 — Cross-repo** | PR 17 (pyrx), PR 18 (pyrx), PR 19 (vscode-python) | not started | | **Phase 4 — UX** | PR 11: picker item, PR 12: bulk command | not started | | **Phase 5 — Lifecycle & polish** | PR 13: clear cache, PR 14: TTL, PR 15: telemetry, PR 16: status bar | not started | After PRs 1–4 merge, **PR 7** unlocks the largest downstream wave (PR 8, 9, 13, 17, 19) — see [the ordering comment on #1602](#1602 (comment)) for the full timeline. ### Why a skeleton The roadmap rolls out across 16 PRs. Landing each one behind a feature gate keeps `main` shippable at every step and lets reviewers see one concern at a time. This PR is the **smallest possible mount point** for that gate: an `EnvironmentManager` implementation that satisfies the interface contract and registers cleanly, but otherwise does nothing. Every subsequent PR (5–8) replaces one of the no-ops in this skeleton with the real thing. ### What this PR does 1. **Adds `InlineScriptEnvManager`** (`src/managers/builtin/inlineScriptEnvManager.ts`) — implements `EnvironmentManager`: - **Metadata**: `name = "inline-script"`, `displayName = "Inline script environments"`, `iconPath = file-code`, `preferredPackageManagerId = "ms-python.python:pip"`. - **Methods**: `getEnvironments` returns `[]`, `get` / `resolve` return `undefined`, `set` / `refresh` are no-ops. - **Optional methods omitted**: `create`, `remove`, `quickCreateConfig` are deliberately not declared so the picker UI hides their entry points until PR 5 lands them. - **Events**: both `onDidChangeEnvironments` and `onDidChangeEnvironment` exposed and disposed correctly; never fired by this skeleton. 2. **Adds `registerInlineScriptFeatures`** (`src/managers/builtin/inlineScriptMain.ts`) — a gated registration helper that reads the internal flag and registers the manager only when on. Wired into the existing `Promise.all` of manager-registration tasks in `extension.ts` (alongside system, conda, pyenv, pipenv, poetry, shellStartupVars). 3. **Adds `isInlineScriptsFeatureEnabled`** (`src/helpers.ts`) — reads `python-envs.inlineScripts.enabled`. **The setting is intentionally NOT declared in `package.json`**, so it does not appear in Settings UI, JSON autocomplete, or settings search. End users never discover it. Devs / CI can opt in by manually adding it to `settings.json`. Default value `false`. Gate goes away in PR 16. ### User impact **Zero.** The feature flag is undeclared 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 new picker section, no commands, no status-bar changes. - No log output on the default level (gate-off path uses `traceVerbose`). PR 5–15 will all land behind the same gate; PR 16 removes the gate and declares the public setting for real. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a329c5c commit 707f2a8

7 files changed

Lines changed: 380 additions & 0 deletions

File tree

‎src/extension.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ import { ProjectItem, PythonEnvTreeItem } from './features/views/treeViewItems';
9898
import { collectEnvironmentInfo, getEnvManagerAndPackageManagerConfigLevels, runPetInTerminalImpl } from './helpers';
9999
import { EnvironmentManagers, ProjectCreators, PythonProjectManager } from './internal.api';
100100
import { registerSystemPythonFeatures } from './managers/builtin/main';
101+
import { registerInlineScriptFeatures } from './managers/builtin/inlineScriptMain';
101102
import { SysPythonManager } from './managers/builtin/sysPythonManager';
102103
import {
103104
createNativePythonFinder,
@@ -656,6 +657,7 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
656657
'poetry',
657658
registerPoetryFeatures(nativeFinder, context.subscriptions, outputChannel, projectManager),
658659
),
660+
safeRegister('inlineScript', registerInlineScriptFeatures(context.subscriptions, outputChannel)),
659661
safeRegister('shellStartupVars', shellStartupVarsMgr.initialize()),
660662
]);
661663

‎src/helpers.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,15 @@ export function getUserConfiguredSetting<T>(section: string, key: string, scope?
144144
return undefined;
145145
}
146146

147+
/**
148+
* Whether the PEP 723 inline-script env support is enabled. Internal
149+
* undeclared flag (`python-envs.inlineScripts.enabled`); defaults to
150+
* false. Window reload required to take effect.
151+
*/
152+
export function isInlineScriptsFeatureEnabled(): boolean {
153+
return getConfiguration('python-envs').get<boolean>('inlineScripts.enabled', false);
154+
}
155+
147156
/**
148157
* Runs the Python Environment Tool (PET) in a terminal window, allowing users to
149158
* execute various PET commands like finding all Python environments or resolving
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import { Disposable, Event, EventEmitter, l10n, LogOutputChannel, MarkdownString, ThemeIcon } from 'vscode';
5+
import {
6+
DidChangeEnvironmentEventArgs,
7+
DidChangeEnvironmentsEventArgs,
8+
EnvironmentManager,
9+
GetEnvironmentScope,
10+
GetEnvironmentsScope,
11+
IconPath,
12+
PythonEnvironment,
13+
RefreshEnvironmentsScope,
14+
ResolveEnvironmentContext,
15+
SetEnvironmentScope,
16+
} from '../../api';
17+
18+
/**
19+
* Skeleton EnvironmentManager for PEP 723 inline-script envs. Every
20+
* method returns the empty / undefined / no-op equivalent; `create`,
21+
* `remove`, and `quickCreateConfig` are intentionally omitted so the
22+
* picker UI hides their entry points until later PRs land them.
23+
*/
24+
export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
25+
private readonly _onDidChangeEnvironments = new EventEmitter<DidChangeEnvironmentsEventArgs>();
26+
public readonly onDidChangeEnvironments: Event<DidChangeEnvironmentsEventArgs> =
27+
this._onDidChangeEnvironments.event;
28+
29+
private readonly _onDidChangeEnvironment = new EventEmitter<DidChangeEnvironmentEventArgs>();
30+
public readonly onDidChangeEnvironment: Event<DidChangeEnvironmentEventArgs> = this._onDidChangeEnvironment.event;
31+
32+
public readonly name = 'inline-script';
33+
public readonly displayName = l10n.t('Inline script environments');
34+
public readonly preferredPackageManagerId = 'ms-python.python:pip';
35+
public readonly description: string | undefined = undefined;
36+
public readonly tooltip: string | MarkdownString = new MarkdownString(
37+
l10n.t('Environments built from PEP 723 inline script metadata.'),
38+
true,
39+
);
40+
public readonly iconPath: IconPath = new ThemeIcon('file-code');
41+
42+
constructor(public readonly log: LogOutputChannel) {}
43+
44+
async refresh(_scope: RefreshEnvironmentsScope): Promise<void> {
45+
return;
46+
}
47+
48+
async getEnvironments(_scope: GetEnvironmentsScope): Promise<PythonEnvironment[]> {
49+
return [];
50+
}
51+
52+
async set(_scope: SetEnvironmentScope, _environment?: PythonEnvironment): Promise<void> {
53+
return;
54+
}
55+
56+
async get(_scope: GetEnvironmentScope): Promise<PythonEnvironment | undefined> {
57+
return undefined;
58+
}
59+
60+
async resolve(_context: ResolveEnvironmentContext): Promise<PythonEnvironment | undefined> {
61+
return undefined;
62+
}
63+
64+
dispose(): void {
65+
this._onDidChangeEnvironments.dispose();
66+
this._onDidChangeEnvironment.dispose();
67+
}
68+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import { Disposable, LogOutputChannel } from 'vscode';
5+
import { PythonEnvironmentApi } from '../../api';
6+
import { traceInfo, traceVerbose } from '../../common/logging';
7+
import { getPythonApi } from '../../features/pythonApi';
8+
import { isInlineScriptsFeatureEnabled } from '../../helpers';
9+
import { InlineScriptEnvManager } from './inlineScriptEnvManager';
10+
11+
/**
12+
* Register the inline-script env manager when the internal
13+
* `python-envs.inlineScripts.enabled` flag is true. The flag is
14+
* undeclared in `package.json`, so default users see nothing.
15+
*/
16+
export async function registerInlineScriptFeatures(disposables: Disposable[], log: LogOutputChannel): Promise<void> {
17+
if (!isInlineScriptsFeatureEnabled()) {
18+
traceVerbose('Inline-script env manager: skipping registration (internal flag is off)');
19+
return;
20+
}
21+
22+
const api: PythonEnvironmentApi = await getPythonApi();
23+
const mgr = new InlineScriptEnvManager(log);
24+
disposables.push(mgr, api.registerEnvironmentManager(mgr));
25+
traceInfo('Inline-script env manager: registered (internal flag is on)');
26+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import assert from 'assert';
5+
import * as sinon from 'sinon';
6+
import { WorkspaceConfiguration } from 'vscode';
7+
import * as workspaceApis from '../common/workspace.apis';
8+
import { isInlineScriptsFeatureEnabled } from '../helpers';
9+
10+
suite('isInlineScriptsFeatureEnabled', () => {
11+
let getConfigurationStub: sinon.SinonStub;
12+
let configGet: sinon.SinonStub;
13+
14+
setup(() => {
15+
configGet = sinon.stub();
16+
const fakeConfig = {
17+
get: configGet,
18+
has: sinon.stub(),
19+
inspect: sinon.stub(),
20+
update: sinon.stub(),
21+
} as unknown as WorkspaceConfiguration;
22+
getConfigurationStub = sinon.stub(workspaceApis, 'getConfiguration').returns(fakeConfig);
23+
});
24+
25+
teardown(() => {
26+
sinon.restore();
27+
});
28+
29+
test('returns false by default (no setting written)', () => {
30+
configGet.withArgs('inlineScripts.enabled', false).returns(false);
31+
assert.strictEqual(isInlineScriptsFeatureEnabled(), false);
32+
});
33+
34+
test('returns true when the user explicitly enables the setting', () => {
35+
configGet.withArgs('inlineScripts.enabled', false).returns(true);
36+
assert.strictEqual(isInlineScriptsFeatureEnabled(), true);
37+
});
38+
39+
test('reads from the python-envs section', () => {
40+
configGet.withArgs('inlineScripts.enabled', false).returns(false);
41+
isInlineScriptsFeatureEnabled();
42+
assert.ok(
43+
getConfigurationStub.calledWith('python-envs'),
44+
'expected getConfiguration("python-envs") to be called',
45+
);
46+
});
47+
});
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import assert from 'assert';
5+
import * as sinon from 'sinon';
6+
import { LogOutputChannel, Uri } from 'vscode';
7+
import { EnvironmentManager, PythonEnvironment } from '../../../api';
8+
import { InlineScriptEnvManager } from '../../../managers/builtin/inlineScriptEnvManager';
9+
10+
function makeFakeLog(): LogOutputChannel {
11+
return sinon.createStubInstance(
12+
class {
13+
info() {}
14+
warn() {}
15+
error() {}
16+
debug() {}
17+
trace() {}
18+
show() {}
19+
dispose() {}
20+
append() {}
21+
appendLine() {}
22+
replace() {}
23+
clear() {}
24+
hide() {}
25+
},
26+
) as unknown as LogOutputChannel;
27+
}
28+
29+
function makeEnv(): PythonEnvironment {
30+
return {
31+
envId: { id: 'fake', managerId: 'ms-python.python:inline-script' },
32+
name: 'fake',
33+
displayName: 'fake',
34+
displayPath: '/fake',
35+
version: '3.12.0',
36+
environmentPath: Uri.file('/fake'),
37+
execInfo: { run: { executable: '/fake' } },
38+
sysPrefix: '/fake',
39+
};
40+
}
41+
42+
suite('InlineScriptEnvManager (skeleton)', () => {
43+
let mgr: InlineScriptEnvManager;
44+
45+
setup(() => {
46+
mgr = new InlineScriptEnvManager(makeFakeLog());
47+
});
48+
49+
teardown(() => {
50+
mgr.dispose();
51+
sinon.restore();
52+
});
53+
54+
suite('static metadata', () => {
55+
test('name is "inline-script"', () => {
56+
assert.strictEqual(mgr.name, 'inline-script');
57+
});
58+
59+
test('displayName is set (for the picker section header)', () => {
60+
assert.ok(mgr.displayName);
61+
assert.ok(mgr.displayName.length > 0);
62+
});
63+
64+
test('preferredPackageManagerId is the standard pip manager id', () => {
65+
assert.strictEqual(mgr.preferredPackageManagerId, 'ms-python.python:pip');
66+
});
67+
68+
test('iconPath is defined (renders in the picker)', () => {
69+
assert.ok(mgr.iconPath);
70+
});
71+
72+
test('tooltip is defined (shown on hover in the picker)', () => {
73+
assert.ok(mgr.tooltip);
74+
});
75+
});
76+
77+
suite('skeleton method behavior', () => {
78+
test('getEnvironments("all") returns []', async () => {
79+
assert.deepStrictEqual(await mgr.getEnvironments('all'), []);
80+
});
81+
82+
test('getEnvironments("global") returns []', async () => {
83+
assert.deepStrictEqual(await mgr.getEnvironments('global'), []);
84+
});
85+
86+
test('getEnvironments(Uri) returns []', async () => {
87+
assert.deepStrictEqual(await mgr.getEnvironments(Uri.file('/tmp/script.py')), []);
88+
});
89+
90+
test('get(undefined) returns undefined', async () => {
91+
assert.strictEqual(await mgr.get(undefined), undefined);
92+
});
93+
94+
test('get(Uri) returns undefined', async () => {
95+
assert.strictEqual(await mgr.get(Uri.file('/tmp/script.py')), undefined);
96+
});
97+
98+
test('set(scope, env) is a no-op and does not throw', async () => {
99+
await assert.doesNotReject(mgr.set(Uri.file('/tmp/script.py'), makeEnv()));
100+
await assert.doesNotReject(mgr.set(undefined, undefined));
101+
});
102+
103+
test('refresh(scope) is a no-op and does not throw', async () => {
104+
await assert.doesNotReject(mgr.refresh(undefined));
105+
await assert.doesNotReject(mgr.refresh(Uri.file('/tmp/script.py')));
106+
});
107+
108+
test('resolve(Uri) returns undefined', async () => {
109+
assert.strictEqual(await mgr.resolve(Uri.file('/tmp/script.py')), undefined);
110+
});
111+
112+
test('does not implement optional create / remove / quickCreateConfig', () => {
113+
// Cast via the interface to probe optional methods (the concrete class type doesn't declare them).
114+
const asInterface: EnvironmentManager = mgr;
115+
assert.strictEqual(asInterface.create, undefined);
116+
assert.strictEqual(asInterface.remove, undefined);
117+
assert.strictEqual(asInterface.quickCreateConfig, undefined);
118+
});
119+
});
120+
121+
suite('events', () => {
122+
test('onDidChangeEnvironments is exposed and subscribable', () => {
123+
const disposable = mgr.onDidChangeEnvironments(() => undefined);
124+
assert.ok(disposable);
125+
disposable.dispose();
126+
});
127+
128+
test('onDidChangeEnvironment is exposed and subscribable', () => {
129+
const disposable = mgr.onDidChangeEnvironment(() => undefined);
130+
assert.ok(disposable);
131+
disposable.dispose();
132+
});
133+
134+
test('skeleton methods do not fire any events', async () => {
135+
const envsListener = sinon.spy();
136+
const envListener = sinon.spy();
137+
mgr.onDidChangeEnvironments(envsListener);
138+
mgr.onDidChangeEnvironment(envListener);
139+
140+
await mgr.getEnvironments('all');
141+
await mgr.get(undefined);
142+
await mgr.set(Uri.file('/tmp/script.py'), makeEnv());
143+
await mgr.refresh(undefined);
144+
await mgr.resolve(Uri.file('/tmp/script.py'));
145+
146+
assert.strictEqual(envsListener.callCount, 0, 'getEnvironments/refresh must not fire envs event');
147+
assert.strictEqual(envListener.callCount, 0, 'set must not fire env event in the skeleton');
148+
});
149+
});
150+
151+
suite('disposal', () => {
152+
test('dispose() does not throw', () => {
153+
assert.doesNotThrow(() => mgr.dispose());
154+
});
155+
156+
test('dispose() is idempotent', () => {
157+
mgr.dispose();
158+
assert.doesNotThrow(() => mgr.dispose());
159+
});
160+
});
161+
});

0 commit comments

Comments
 (0)