Skip to content

Commit d44725a

Browse files
Add noninteractive, isolated Python environment tooling for agents (#1900)
# Add noninteractive, isolated Python environment tooling for agents ## Summary Add a private, versioned `__pythonTools` capability to the existing flat extension export so Python agent tools can configure environments, inspect interpreters/packages, and install packages without requiring extension-owned pickers or dialogs. The companion Python-extension change consumes this capability. The public API contract is unchanged, and host tool approval remains in force. ## Motivation Agent requests currently reuse interactive environment workflows, which can pause for interpreter, manager, package, or environment-creation choices. A selected global interpreter can also become the package-installation target. This change gives agents an explicit noninteractive path with isolation by default, actionable errors, and scoped results, without recording why an interpreter was selected. ## Behavior - Automatic configuration reuses a usable isolated environment or creates one for the project. A selected System/Pyenv base creates a venv using that interpreter; Conda base creates a project Conda environment with the same Python major/minor version. - An explicit `pythonPath` still selects that exact existing interpreter. Package tools reject global/base environments even when selected explicitly. - Queries do not create environments or change selection. Package installation does not implicitly configure an environment. - Configuration and installation require an open, trusted, local workspace. Results identify the effective resource, environment, and execution information. - Root selections survive reload, including when `python.defaultInterpreterPath` is configured. Nested targets persist exact project entries without replacing root defaults. - Existing Poetry/Pipenv project caches and enabled PEP 723 scripts have noninteractive discovery/setup paths. - Errors and partial creation results are returned explicitly. Same-scope operations are serialized; cancellation and timeouts await owned-process cleanup and report uncertainty instead of falsely claiming that a process stopped. - Failed human venv initialization invalidates partial discovery and readiness state, so subsequent public lookups retry discovery instead of remaining on a stale global fallback. Private discovery remains independent of human onboarding. ## Human-flow compatibility Built-in managers opt in through internal symbol capabilities and explicit operation flags. Normal public selection, creation, package, and execution entry points retain their interactive behavior, including public quick-create's `.venv-N` suffixing. This is not a claim that all shared code is untouched: - Venv collection events can arrive earlier relative to base-manager onboarding. - Public Conda command failures no longer generate the extra unhandled rejection caused by the old unused `finally()` promise. - Agent selections, settings, and package changes intentionally remain visible to subsequent human operations. ## Validation - Full ESLint and TypeScript checks pass. - Windows unit suite after rebasing onto current upstream `main`: **2,752 passing, 7 pending**. Six new failure-path assertions failed before the retry fix and now pass; successful initialization remains cached. The new upstream venv deletion, unresolved-selection recovery, refresh selection-race, uv bootstrap, and scoped Conda discovery tests also pass with the combined implementation. - Reviewer regression coverage verifies that private Conda execution preserves shell metacharacters as one argument with shell execution disabled, resolves Windows batch launchers to a real executable or fails actionably, and scopes both package queries and installation independently when two Poetry projects share one environment. - Linux private/public boundary and owned-process suites with the retry fix: **116 passing**. - Real VS Code comparisons against the current base verified manual selection, package install/remove, Run Python File, debugging, unittest discovery/execution, switching back to global Python, and public quick-create. - Real-host agent verification covered fresh/global setup, selected-base preservation, reload, multi-root, nested targets, Conda, Poetry/Pipenv, PEP 723, cancellation, no-workspace behavior, and invalid input. - Critical flows were repeated against freshly built production VSIX contents. Installed files were compared with the packages rather than inferred from unchanged version numbers. - An additional real VS Code before/after retry check used the loaded extension's actual venv manager, native discovery, a real newly created venv, public selection and Run Python File. A one-shot post-discovery base failure was injected only in the isolated test host. Both direct initialization and public-get retries recovered without manual manager refresh after the fix; the pre-fix build required refresh. ## Known findings before merge - Ordinary project `.python-version` / `pyproject.toml` interpreter constraints are not automatically resolved. Selected base interpreters and `defaultInterpreterPath` are honored; PEP 723 constraints are handled separately. - macOS/remote E2E, arbitrary third-party integrations, and real-chat model choices/approval combinations have not been exhaustively verified. ## Companion change Python consumer PR: [microsoft/vscode-python#26208](microsoft/vscode-python#26208). The consumer checks the private version/method shape and retains a compatibility route when the capability is unavailable. --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 4faeedf commit d44725a

56 files changed

Lines changed: 6090 additions & 566 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/instructions/testing-workflow.instructions.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ This guide covers the full testing lifecycle:
2020

2121
- Pip commands that return JSON must pass `--disable-pip-version-check`; the process helper combines stderr with stdout, so update notices can otherwise make valid JSON unparseable (1).
2222
- When a view subscribes to a newly added provider event, TypeMoq-based view tests must return a real `EventEmitter.event`; an unstubbed event yields an undefined disposable and fails during teardown (1).
23+
- Test agent selections across a full reload with `python.defaultInterpreterPath` set. An effective manager value equal to the extension default does not prove a workspace value was saved; tool-owned persistence must inspect `workspaceValue` or startup can restore the global interpreter (1).
2324

2425
### When to Use This Guide
2526

‎CONTRIBUTING.md‎

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,152 @@ The npm package under [`api/`](./api) is the public API facade other extensions
9898
- `src/api.ts`, `src/types.ts`, and `src/publicErrors.ts` are validated on every PR by the extension's own lint and TypeScript compile.
9999
- **Versioning and compatibility:** the published package version in [`api/package.json`](./api/package.json) is maintained independently of the extension version in [`package.json`](./package.json) — the two do not need to match. Compatibility is based on the API shape exported by the installed Python Environments extension at runtime. Package updates must preserve backwards-compatible contracts unless the API package version intentionally communicates a breaking change; consumers should treat newly added members as optional when they may run against older installed extension versions. Any PR that edits `src/api.ts`, `src/types.ts`, or `src/publicErrors.ts` must bump `api/package.json` (use the `skip api version` label to bypass) and add an entry to [`api/CHANGELOG.md`](./api/CHANGELOG.md) (use the `skip api changelog` label to bypass).
100100

101+
## Internal Python agent-tool bridge
102+
103+
The extension's existing **flat** export also provides `__pythonTools.version === 1`.
104+
This is an internal convention, not a security boundary or a supported public API.
105+
Its types live only in `src/internal/pythonToolsApi.ts`; do not copy them into
106+
`src/types.ts`, `src/api.ts`, or the published API package. Consumers must check the
107+
version and all three methods at runtime before using the private API. The Python
108+
consumer uses its previous public Environments integration when that capability
109+
is absent or incompatible; that compatibility route can still prompt. It never
110+
falls back after a private operation starts, including errors, partial results,
111+
timeouts, or cancellation. Environments-disabled users retain the legacy Python
112+
route.
113+
Explicit compatibility selection persists through the public Environments setter, not
114+
just the Python extension's cached interpreter path, so later package operations
115+
use the same environment. Reusing an already-selected environment is read-only;
116+
it does not reselect or wait for an interpreter-change event.
117+
118+
| Method | Request | Result |
119+
| --- | --- | --- |
120+
| `configureEnvironment(request, token)` | `{ resourcePath?: string, pythonPath?: string }` | Select an explicit interpreter exactly; otherwise reuse an isolated environment or create one for the project. Success includes `created`. |
121+
| `getEnvironment(request, token)` | `{ resourcePath?: string, includePackages?: boolean }` | Resolve the real selected environment, not the public API's short-timeout cache. `includePackages: true` returns `{ name, version?: string }[]`, including `[]`, or an error. |
122+
| `installPackages(request, token)` | `{ resourcePath?: string, packages: string[] }` | Install into an isolated environment. Does not create or change selection implicitly, or modify any global/base interpreter. |
123+
124+
Automatic configuration uses isolation by default, regardless of whether a global
125+
interpreter was selected automatically or by a person. Virtual environments are
126+
recognized by `pyvenv.cfg`; non-base Conda and inline-script environments are also
127+
isolated. Existing isolated environments outside the workspace remain reusable.
128+
When no isolated environment is selected, configuration can resolve Poetry/Pipenv
129+
caches through the native finder's project association. It does not infer ownership
130+
from the cache location or record selection history; ambiguous project matches
131+
require an explicit `pythonPath`. Read-only queries do not select these candidates.
132+
There is no selection-origin bookkeeping or persistence. Old origin records are
133+
ignored; ordinary current-interpreter selection and its persistence are unchanged.
134+
An explicit `pythonPath` selects that interpreter without creating an environment.
135+
This can select global Python for execution or queries, but does not authorize
136+
package installation into it: `ENVIRONMENT_NOT_ISOLATED` directs the caller to
137+
configure without `pythonPath` or select an existing isolated environment.
138+
139+
All methods require a VS Code `CancellationToken` and a trusted workspace.
140+
`resourcePath` accepts an absolute path or a `file:` URI within an open workspace.
141+
The Python tools default an omitted or empty path to the first workspace folder and
142+
pass it explicitly. They disclose the target in preparation/results and instruct agents
143+
to reuse the returned `resourcePath`. Editor focus does not change this default.
144+
At the direct private API boundary, omission selects the only workspace root;
145+
zero/multiple roots still produce actionable errors. The consumer default does not
146+
relax that backend validation.
147+
Other explicit input is never replaced by that default. Because the route is
148+
workspace scoped, read-only tools keep their previous Environments behaviour when
149+
no folder is open. Configuration and installation with a compatible private API
150+
report `NO_WORKSPACE` instead of opening a picker or modifying global Python.
151+
Nested projects and per-script selections retain their scope; a file is
152+
never used as an environment creation directory. There is no User/global settings
153+
fallback. An explicit `pythonPath` can switch away from an unavailable manager.
154+
New nested targets persist as exact project entries without changing root defaults;
155+
their automatic dependency discovery is restricted to that target directory.
156+
Tool selections at the workspace root persist an explicit workspace manager, even
157+
when it matches the extension default. This prevents `python.defaultInterpreterPath`
158+
from restoring global Python on reload without rewriting that interpreter setting.
159+
Ordinary human selections retain their existing settings-write behavior.
160+
161+
Results are `{ status: 'success', environment, resourcePath?, created?, packages? }`
162+
or `{ status: 'error', code, message, environment?, resourcePath? }`. Errors retain
163+
an already-created environment when subsequent package installation or selection
164+
fails; retries reuse a resolvable `.venv`/`.conda` rather than creating a suffixed
165+
directory. Selecting an existing environment does not reinstall project
166+
dependencies. A resolvable `.venv`/`.conda` is only reused when it actually contains
167+
the resolved isolated environment. A new subdirectory can reuse an isolated parent-project
168+
selection; `resourcePath` chooses the target, not a requirement to create a fresh
169+
environment.
170+
Register an independent project when it needs a separate selection.
171+
Reads and writes for the same effective target share a cancellable
172+
queue; waiting behind an operation does not use the discovery timeout. Different
173+
targets remain independent. Discovery timeouts report `NOT_READY`; a configured
174+
environment manager that never registers reports `UNSUPPORTED_MANAGER` naming the id;
175+
subprocess
176+
timeouts report `TIMEOUT`. Cancellation throws `CancellationError` after owned
177+
process cleanup. Cancellation is cooperative, not a rollback: completed writes or
178+
package changes are not undone. A completed action remains successful if a
179+
cancellation request arrives on its return. If cleanup fails,
180+
`PROCESS_TERMINATION_FAILED` takes precedence:
181+
the process may still be modifying files, so consumers must not blindly retry.
182+
Cleanup waits for process closure and owned POSIX group completion; an exited
183+
Windows parent cannot turn a failed tree termination into successful cancellation.
184+
If a Windows parent has already exited when cancellation starts, descendant
185+
cleanup cannot be confirmed; the tool reports that uncertainty instead of
186+
signalling a potentially reused PID.
187+
Only internal `toolExecution` calls enable these subprocess policies. Public
188+
progress tokens do not enable agent timeouts, input suppression, or process-tree
189+
cleanup semantics. Public package calls retain their existing execution defaults.
190+
191+
Internal symbol capabilities opt built-in managers into prompt-free operations;
192+
they are unrelated to the human UI's `quickCreateConfig`. Creation supports venv,
193+
Conda, and enabled PEP 723 inline scripts (the latter retain their existing cache
194+
ownership/metadata rules). System and Pyenv base interpreters route creation to
195+
venv; Conda base routes to an isolated Conda prefix. Poetry and Pipenv environments
196+
can be reused, but unsupported creation and contributed managers without this
197+
capability fail explicitly. Private package operations use the existing headless pip/uv, Conda, and
198+
Poetry helpers with the real token and strict, uncached package listing. Venv
199+
dependency validation fails rather than asking to continue. No tool installs a
200+
base Python automatically. Conda response descriptors clone `execInfo.activatedRun`
201+
to a standalone `conda run --prefix ... --no-capture-output ...` command; shared
202+
public descriptors are not changed.
203+
Venv creation uses the selected Python 3 as its base, falling back to the latest
204+
discovered Python 3 only when none is selected. Conda creation requests the selected
205+
Python major/minor version. This does not add a project version-constraint resolver
206+
for `pyproject.toml` or `.python-version`. To choose a particular base, select it
207+
first and then configure without `pythonPath`; the path parameter itself is an
208+
exact selection request, not a venv base-interpreter argument.
209+
Bare Conda commands configured through `python.condaPath` are resolved through
210+
PATH for private execution and returned command prefixes.
211+
Conda base is isolated even when inherited through `CONDA_PREFIX`, and failed
212+
Conda discovery does not authorize replacement creation. Public Conda refresh remains best-effort while
213+
the private readiness check retains the discovery error. Poetry verifies the
214+
requested project's actual environment prefix before package operations. Agent
215+
inventory uses `poetry run pip list --format=json`, including when Poetry supplies
216+
pip itself; the public lockfile-based `poetry show` path is unchanged.
217+
Private Poetry installs also apply the lockfile with `install --no-root`, since
218+
`add` alone skips already-declared dependencies missing from the environment.
219+
Direct installs into shared PEP 723
220+
caches return `IMMUTABLE_ENVIRONMENT`; edit script metadata and configure again.
221+
With the inline-script feature enabled, configuring a Python file with PEP 723
222+
metadata can create its cache on the first tool invocation, without first using a
223+
CodeLens or registering a script project manually. Invalid metadata fails rather
224+
than falling back to ordinary project creation. This opt-in configuration route
225+
does not change human or read-only routing, which still requires a validated
226+
association. Target the project directory or supply `pythonPath` for ordinary
227+
project/exact-interpreter configuration instead.
228+
An agent joining a human-owned inline cache build gets `ENVIRONMENT_BUSY` without
229+
waiting for or cancelling the human operation.
230+
231+
Private discovery is separate from public initialization and its onboarding.
232+
Agent reads never start or await installation or missing-manager questions, even
233+
when a concurrent public initialization is waiting for input. Ordinary startup,
234+
public API, and human workflows retain their prompts; independently triggered
235+
startup UI can still appear while a tool runs. There is no global "suppress UI"
236+
switch. Exact nested-project persistence is also opt-in to the private setter;
237+
ordinary manager-setting updates retain their existing defaults.
238+
Human venv initialization retries discovery and base preparation after a failure,
239+
including failures after discovery has completed. A partial initialization is not
240+
reused as a successful cache on the next public lookup; private discovery still
241+
does not wait for human onboarding.
242+
243+
Keep tests for both internal and human routes when changing these helpers. The
244+
focused suites are in `src/test/internal`, with shared creation/package and
245+
inline-script regressions under `src/test/managers`.
246+
101247
## Questions or Issues?
102248

103249
- **Questions**: Start a [discussion](https://github.com/microsoft/vscode-python/discussions/categories/q-a)

‎src/common/inlineScript/metadata.ts‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import * as tomljs from '@iarna/toml';
55
import * as fs from 'fs/promises';
6-
import { Uri } from 'vscode';
6+
import { l10n, Uri } from 'vscode';
77
import { traceVerbose, traceWarn } from '../logging';
88
import { PythonVersion } from '../pythonVersion';
99
import { PythonVersionSpecifier } from '../pythonVersionSpecifier';
@@ -654,8 +654,13 @@ export function sliceHeaderBytes(text: string): string {
654654
* - any I/O error (logged at `traceVerbose`);
655655
* - any of the malformed-metadata cases handled by
656656
* `readInlineScriptMetadata`.
657+
* @param uri The local script to read.
658+
* @param strict Throw on I/O errors or invalid metadata instead of treating them as absent.
657659
*/
658-
export async function readInlineScriptMetadataFromFile(uri: Uri): Promise<InlineScriptMetadata | undefined> {
660+
export async function readInlineScriptMetadataFromFile(
661+
uri: Uri,
662+
strict = false,
663+
): Promise<InlineScriptMetadata | undefined> {
659664
if (uri.scheme !== 'file') {
660665
traceVerbose(`inline script metadata: skipping non-file URI scheme '${uri.scheme}'`);
661666
return undefined;
@@ -671,11 +676,22 @@ export async function readInlineScriptMetadataFromFile(uri: Uri): Promise<Inline
671676
await handle.close();
672677
}
673678
} catch (err) {
679+
if (strict) {
680+
throw err;
681+
}
674682
traceVerbose(`inline script metadata: failed to read ${uri.fsPath}:`, err);
675683
return undefined;
676684
}
677685

678-
return readInlineScriptMetadata(text, uri.fsPath);
686+
const result = parseInlineScriptMetadata(text, uri.fsPath);
687+
if (
688+
strict &&
689+
(result.kind === 'invalid' ||
690+
(result.kind === 'parsed' && result.problems.some((problem) => problem.severity === 'error')))
691+
) {
692+
throw new Error(l10n.t('Fix the PEP 723 metadata in {0} before configuring its environment.', uri.fsPath));
693+
}
694+
return result.kind === 'parsed' ? result.metadata : undefined;
679695
}
680696

681697
/**

‎src/common/lockfile.apis.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as path from 'path';
88
export interface AcquireFileLockOptions {
99
readonly timeoutMs: number;
1010
readonly retryIntervalMs: number;
11+
readonly checkCancellation?: () => void;
1112
}
1213

1314
export interface AcquiredFileLock {
@@ -52,6 +53,7 @@ export async function acquireFileLock(filePath: string, options: AcquireFileLock
5253
const deadline = Date.now() + options.timeoutMs;
5354

5455
while (true) {
56+
options.checkCancellation?.();
5557
try {
5658
await fsapi.mkdir(lockPath);
5759
try {

0 commit comments

Comments
 (0)