Skip to content

Commit c0f89be

Browse files
Add PEP 723 inline script environment creation (PEP 723 PR 5c/16) (#1656)
> Part of #1602 (PEP 723 inline script env support). Design doc: #1601. > **Split for review (3 PRs).** Reviewers flagged the original PR 5 as too large, so it is split into three PRs grouped by dependency layer: > - **5a — generic env-creation utilities — #1651.** Merged. > - **5b — inline-script cache + interpreter utilities — #1655.** Merged. > - **5c — `create()` happy path (manager + wiring) — this PR (#1656).** Rebased on `main`. > > #1651 and #1655 have merged and this branch has been rebased. The diff now contains only this PR's five files. ### Roadmap context This is the final slice of **PR 5 of 16** — the actual `create()` happy path. See #1651 for the full roadmap table. | Phase 2: Manager | PR | Status | |---|---|---| | | PR 4: `InlineScriptEnvManager` skeleton | merged (#1610) | | | PR 5a: generic env-creation utilities | merged (#1651) | | | PR 5b: inline-script cache + interpreter utilities | merged (#1655) | | | **PR 5c: `create()` happy path (manager + wiring)** | **this PR (#1656)** | | | PR 6: `create()` uv-install fallback | not started (needs 3, 5) | ### Why this PR `InlineScriptEnvManager.create()` was a deliberately empty no-op after PR 4. This PR implements its **happy path**: the case where the machine already has a base interpreter that satisfies the script's `requires-python`, so no uv Python install is required. Given a PEP 723 script, it builds — or reuses — a dependency-keyed virtual environment under the extension's global storage, following the pipx-style cache design from Q4 of #1601. The uv-install fallback (no compatible interpreter present) is deferred to PR 6. It composes the primitives from 5a (#1651) and the inline-script utilities from 5b (#1655); this PR adds only the manager and its wiring. ### What this PR does **Wires the manager's collaborators** (`extension.ts`, `inlineScriptMain.ts`): `registerInlineScriptFeatures` and the `InlineScriptEnvManager` constructor now receive the `NativePythonFinder`, the `PythonEnvironmentApi`, the base (system) environment manager, and `globalStorageUri`. **Implements `create(scope)`** (`inlineScriptEnvManager.ts`): - Accepts exactly one local `file:` URI (a bare `Uri` or single-element array). Anything else — `'global'`, a folder, or multiple URIs — logs a warning and returns `undefined`. - Reads PEP 723 metadata from the script; missing or invalid metadata returns `undefined`. - Merges `metadata.dependencies` with `options.additionalPackages`, trims each, and rejects empty entries. - Selects a base interpreter, computes the dependency + interpreter cache key, and de-duplicates concurrent in-process `create()` calls for the same key via a `pendingCreations` map. **Base-interpreter selection** (`selectBaseInterpreter`): starts from `getEnvironments('global')`, keeps only true base managers (system, pyenv, conda `base`), and excludes derived environments by rejecting a non-absolute `sysPrefix` or the presence of `pyvenv.cfg`. It then picks the newest compatible interpreter with `pickCompatibleInterpreter` and resolves the executable through `fs.realpath` so the cache key is canonical. If a candidate cannot be resolved it falls through to the next. **Create-or-reuse under a cross-process lock** (`createOrReuseEnvironment`): acquires a directory lock (5a), inspects the existing cache entry, and reuses / rebuilds / preserves accordingly, always releasing the lock in `finally`. **Fail-closed cache inspection** (`inspectCacheEntry`) returns `absent | stale | uncertain | reusable`: - Rejects symlinks and non-directories; verifies the entry is contained under the cache root (`resolveCacheEntryPath`). - Reads and validates the `.meta.json` sidecar and confirms the recorded base-interpreter path and version still match the selected base. - Confirms the base interpreter is still present on disk (`getBaseInterpreterStatus`). - Resolves the cached venv to a real `PythonEnvironment`, confirms it is genuinely ours via realpath containment (`inspectOwnedCacheEntry`), compares Python release segments, and re-checks `requires-python` with the existing `matchesPythonVersion`. - Only conclusive evidence marks an entry `stale` (rebuild); any doubt yields `uncertain`, and an uncertain entry is **preserved, never deleted**. A reused entry has its `lastUsedAt` refreshed. **Environment build** (`buildCacheEntry`): delegates to the existing `createWithProgress` venv flow with `trackUvEnvironment` set to `false` so cached script environments are not registered as workspace venvs. On success it writes the sidecar and re-validates that the built environment matches the requested release and is owned by this entry. On failure it removes the directory and returns empty. On **cancellation** it retains the lock so a half-built environment is not silently reused later. ### Tests - **`inlineScriptEnvManager.unit.test.ts`** — 40 tests across scope/metadata validation, base-interpreter selection, cache creation, cache reuse, transaction rollback, and events/disposal. - **`inlineScriptMain.unit.test.ts`** — updated for the new `registerInlineScriptFeatures` signature. On this rebased branch `npm run compile-tests` is clean and `npm run unittest` reports **1491 passing, 0 failing, 5 pending**. ### User impact **None on the default path.** The manager is still registered only when the undeclared `python-envs.inlineScripts.enabled` flag is on, so default users see nothing. `create()` is now a declared method (PR 4 omitted it), so with the flag on the inline manager can appear as a create target. But it acts only on a single local script URI and no-ops on every other scope, and **nothing in the extension routes a script URI to it yet**. Wiring the trigger is later work: routing in PR 9, and the "Set up env for this script" picker item and bulk command in PR 11/12. ### Merge order #1651 and #1655 have merged. This PR is the remaining final slice. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 39dcc6a3-0fbd-4f36-9d0f-68677de49c27
1 parent 58f231d commit c0f89be

10 files changed

Lines changed: 1246 additions & 118 deletions

File tree

‎src/common/constants.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export const ENVS_EXTENSION_ID = 'ms-python.vscode-python-envs';
44
export const PYTHON_EXTENSION_ID = 'ms-python.python';
55
export const CONDA_MANAGER_ID = `${PYTHON_EXTENSION_ID}:conda`;
66
export const INLINE_SCRIPT_MANAGER_ID = `${PYTHON_EXTENSION_ID}:inline-script`;
7+
export const PYENV_MANAGER_ID = `${PYTHON_EXTENSION_ID}:pyenv`;
78
export const JUPYTER_EXTENSION_ID = 'ms-toolsai.jupyter';
89
export const EXTENSION_ROOT_DIR = path.dirname(__dirname);
910
export const ISSUES_URL = 'https://github.com/microsoft/vscode-python-environments/issues';

‎src/common/inlineScriptCacheLayout.ts‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Uri } from 'vscode';
88
import type { PythonEnvironment } from '../api';
99
import { INLINE_SCRIPT_MANAGER_ID } from './constants';
1010
import { traceWarn } from './logging';
11+
import { isFileNotFoundError } from './utils/filesystem';
1112
import { normalizePath } from './utils/pathUtils';
1213
import { isWindows } from './utils/platformUtils';
1314
import { getVenvPythonPath } from './utils/virtualEnvironment';
@@ -273,10 +274,6 @@ function parsePyvenvHome(raw: string): string | undefined {
273274
return undefined;
274275
}
275276

276-
function isFileNotFoundError(err: unknown): boolean {
277-
return typeof err === 'object' && err !== null && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT';
278-
}
279-
280277
function isDescendantPath(rootPath: string, candidatePath: string): boolean {
281278
const relative = path.relative(rootPath, candidatePath);
282279
return (

‎src/common/utils/filesystem.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
export function isFileNotFoundError(error: unknown): error is NodeJS.ErrnoException {
5+
return (
6+
typeof error === 'object' &&
7+
error !== null &&
8+
'code' in error &&
9+
(error as NodeJS.ErrnoException).code === 'ENOENT'
10+
);
11+
}

‎src/extension.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -657,7 +657,16 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
657657
'poetry',
658658
registerPoetryFeatures(nativeFinder, context.subscriptions, outputChannel, projectManager),
659659
),
660-
safeRegister('inlineScript', registerInlineScriptFeatures(context.subscriptions, outputChannel)),
660+
safeRegister(
661+
'inlineScript',
662+
registerInlineScriptFeatures(
663+
nativeFinder,
664+
context.subscriptions,
665+
outputChannel,
666+
sysMgr,
667+
context.globalStorageUri,
668+
),
669+
),
661670
safeRegister('shellStartupVars', shellStartupVarsMgr.initialize()),
662671
]);
663672

0 commit comments

Comments
 (0)