Skip to content

PEP-723 support for built-in venv #185

Open
2 of 4 issues completed
Open

Description

Support for inline script metadata: https://peps.python.org/pep-0723/

What we need:

Activity

  1. changed the title [-]PEP723 style environment support for built-in `venv`[/-] [+]PEP-723 support for built-in `venv`[/+] on Feb 12, 2025
  2. StellaHuang95 commented on May 19, 2026

    @StellaHuang95
    Contributor

    Below are discussion items for this week's design review meeting.

    TL;DR

    Stages 1–2 of PEP 723 support (parse + detect) are implemented locally behind the python-envs.useInlineScriptMetadata setting.

    Stages 3–5 (env creation, rebuild on metadata change, telemetry / polish) are where most of the user-visible UX lives, and we want to align on six discussion items in the meeting before cutting more code.

    Implementation status

    Stage Description Status Tracking
    1 Parse # /// script blocks (requires-python, dependencies) ✅ Implemented (unmerged) #186
    2 File detection: lazy on open/save + opt-in workspace scan ✅ Implemented (unmerged) #186
    3 Environment creation from declared dependencies 🟡 Pending design — see Q1, Q2 #187
    4 Rebuild env on metadata change 🟡 Pending design — see Q4 #188
    5 Telemetry + polish (diagnostics, discoverability) 🟡 Pending design #189

    What's implemented so far

    When python-envs.useInlineScriptMetadata = true:

    1. Open or save any .py file. If it starts with a well-formed PEP 723 # /// script block, the file is auto-registered as a Python project, in its own row in the "Python Projects" tree view.
    2. Run Python Environments: Add Existing Projects → Inline Script Detector to bulk-discover scripts in the open workspace folders (capped at 500, with the usual exclusions).

    When the setting is false (the default while the feature is experimental) none of the above runs.


    Discussion items

    Q1 — What does the "Create Environment" UI/flow look like for the two detection paths?

    Context. We have two detection paths today (lazy on-open and the opt-in workspace scan). Both produce script projects with env = undefined. The next chunk of work has to materialize an env for each one. The question is how the user is invited to do that.

    Path 1 — Lazy on-open/save detection

    The user opens a single .py file that turns out to have an inline # /// script block. How is the env materialized?

    Option UI / user flow Pros Cons
    A Status bar entry A status-bar item appears next to the existing interpreter selector. Label reads something like "Set up script env". Clicking it shows a confirmation ("Create .venv next to foo.py and install pandas, numpy?") and on accept creates the .venv. The status bar then flips to the normal interpreter indicator. Predictable single entry point; matches the "Select Interpreter" idiom. Users who never look at the status bar miss it; doesn't help users who immediately try to install or run.
    B Information notification on first open On open, an information notification appears in the bottom-right Notifications area: "This script declares inline metadata. Create environment?" with action buttons [Create] [Not now] [Don't ask again]. Choosing Create runs the same flow as A. High discoverability; explicit consent via standard VS Code notification surface. Non-modal and easy to dismiss/miss; stacks in the Notifications area when bulk-opening tabs; invisible when notifications are silenced via "Do Not Disturb".
    C CodeLens above the metadata block A CodeLens row renders directly above the # /// script block, e.g. "▸ Create environment (pandas, numpy)". Clicking it triggers the same creation flow. Co-located with the metadata the user is already reading. CodeLens fatigue if every block adds a row; hidden when the block is folded; some users disable CodeLens globally.
    D Do nothing on open File-open registers the project only. Env creation requires the user to explicitly run the existing Python: Create Environment command. Zero surprise; consistent with how every other project is handled. Inline metadata gets no special treatment — defeats most of the discoverability rationale for PEP 723 in the IDE.

    Path 2 — Opt-in workspace scan

    After the user runs Add Existing Projects → Inline Script Detector and N script projects get registered in one pass, how do their envs get created?

    Option UI / user flow Pros Cons
    A Quick-pick of discovered scripts Scan completes → a multi-select quick-pick lists the N scripts (all checked by default); the user un-checks any they don't want envs for; "Create selected" materializes envs only for the chosen subset. Explicit per-script consent; user retains control over disk cost. Extra step adds friction; users who Esc out leave envs uncreated.

    Q2 — Which tools do we support? Just python -m venv, or uv / pipx / hatch too?

    Context. PEP 723 is tool-neutral by design. Several runners read the same # /// script block; each materializes and persists its own env differently. VS Code doesn't directly endorse any specific runner, but the env-creation stage has to pick one to materialize the env, or refuse and delegate. Conda is intentionally out of scope for v1 since PEP 723 dependencies are PyPI-shaped.

    How each tool supports PEP 723 today

    Sourced from each tool's official docs.

    Tool How a PEP 723 script is run Where the env lives Env reuse / cache key Lifetime / cleanup
    uv (docs) uv run script.py reads the # /// script block, resolves requires-python + dependencies, materializes an environment on-demand, then runs the script. Wheels, source distributions, Git checkouts, and built artifacts are content-addressed in uv's global cache. Default cache: $XDG_CACHE_HOME/uv on Unix (e.g. ~/.cache/uv); %LOCALAPPDATA%\uv\cache on Windows. The per-script env is assembled by hardlinking from this cache. Cache buckets are versioned and keyed by registry URL + HTTP caching headers (registry deps), full URL (direct deps), or fully-resolved commit hash (Git deps). When a <script>.lock is present, subsequent uv run --script reuses the locked resolution. Cache is append-only and persistent across runs and restarts. Cleaned manually with uv cache clean or uv cache prune. No automatic TTL.
    pipx (docs) pipx run script.py reads the # /// script block, creates a cached venv keyed to the script's dependency list with python -m venv, installs the declared deps via pip, then runs the script. A cached venv under pipx's data directory (default ~/.local/share/pipx/), separate from the venvs/ directory used by pipx install. Cache key is a hash of the package/spec list + Python version + pip arguments. Changing any declared dep produces a fresh env on next run. Cached run-envs expire after a few days (TTL); on next run pipx refetches and rebuilds.
    hatch (docs) hatch run /path/to/script.py reads the # /// script block, creates a dedicated Hatch environment whose name is derived from the script's absolute path (e.g. SyB4bPbL), installs the declared deps with the chosen installer (uv by default; configurable to pip via [tool.hatch] installer = "pip" inside the metadata), then runs. <HATCH_DATA_DIR>/env/virtual/<derived-name>/. Default data dir: %USERPROFILE%\AppData\Local\hatch on Windows, ~/Library/Application Support/hatch on macOS, $XDG_DATA_HOME/hatch on Linux. Override with --data-dir or HATCH_DATA_DIR. The script's absolute path determines the env name, so re-running the same script reuses the same env. Hatch checks/syncs declared deps on each run. Persistent until removed with hatch env remove or hatch env prune. No automatic TTL.

    Alternatives we considered

    Option UI / user flow Pros Cons
    A .venv only On env creation, the extension runs python -m venv .venv (optionally uv venv when the user already uses uv as their venv backend) next to the script, installs the metadata's dependencies via pip, then points the status-bar interpreter selector, Pylance, and the debugger at .venv/bin/python. The user sees a real folder on disk and treats it like any other venv. Simple, predictable, debuggable. Uses the existing venv manager surface verbatim. Plays nicely with Pylance. We don't benefit from uv's content-addressed cache (every script gets its own copy of e.g. numpy). Sibling .venv folders accumulate. Doesn't match the dominant single-file workflow (uv run --script).
    B Hybrid: .venv by default, uv-managed as an opt-in setting Default behaviour matches A. A setting (e.g. python-envs.inlineScript.backend = "venv" | "uv") flips the backend to a uv-native mode where the extension shells out to uv run --script / uv sync --script and lets uv build/locate its cached env (lives under %LOCALAPPDATA%\uv\cache on Windows, ~/.cache/uv on Unix — no folder next to the script). The choice can also be offered inside the Create Environment quick-pick. Status bar shows the resolved interpreter path inside uv's cache. Best of both. Users on uv-heavy workflows get uv semantics (caching, locking, requires-python handled automatically); everyone else gets the simple sibling-.venv model. Doesn't lock users into one backend. Two code paths means two test matrices and two failure surfaces. Tree view / status bar UI has to render both env shapes. uv's env path is opaque (cache-indexed); Pylance integration in uv mode is harder.
    C Discovery layer for externally-created envs Primary env is still .venv (option A). Additionally, the extension scans known external tool caches (uv's cache, pipx's run-cache, Hatch's data dir) for envs that match the open script. Discovered envs show up in the Select Interpreter quick-pick alongside .venv, letting the user point Pylance/debugger at the env uv (or pipx, or Hatch) already built if their real workflow is uv run --script outside VS Code. Author UX is preserved even when the user's real workflow lives outside VS Code — they get autocomplete/imports against the env uv built, without us having to rebuild it. Doesn't lock users into our .venv. Additive to A; doesn't conflict with B. Discovery is tool-specific and each tool's cache layout can change between versions. The discovered env isn't ours, so refreshing it has to round-trip through the owning tool.

    Q3 — How do we persist the script's env? Forever, on a TTL, or cached by metadata hash?

    Context. Sub-issue #187 uses the phrase "custom transient environments". "Transient" could mean short-lived (deleted on restart), content-addressed (one cache entry per unique metadata block), or just "separate from the workspace's main env, but otherwise normal". Whether "transient" was meant as "delete on restart" or "isolated from the workspace's main env" is one of the things this question needs to settle. A related sub-question for each option below is whether the env should appear in the Python Projects tree view alongside workspace envs, or stay hidden until the user clicks the script.

    Alternatives we considered

    Option UI / user flow Pros Cons
    A Sibling .venv, persistent The extension creates a .venv folder next to the script (e.g. foo.py → foo.py.venv/ or shared .venv/ per directory). Survives Code restarts and survives machine reboots; the user manages cleanup the same way they would any other venv. Env appears in the Python Projects tree view as a sibling of any workspace projects; status bar and Pylance point at it. Reads "transient" as relative to the workspace env, not short-lived. Predictable; user can see and inspect; matches the rest of the extension; survives Code restarts. Doesn't surprise users who came in expecting a normal venv. One .venv per script — disk usage scales with script count. No cross-script reuse. Visible clutter for users with many scripts in one directory. If users complain about clutter, the fix is either a cleanup command, a default TTL (→ option C), or a global cache (→ option D).
    B Sibling .venv, GC'd on Code startup (or on extension activation) Same .venv layout as A, but on each Code startup (or extension activation) the extension removes every script-owned .venv it created. Re-running the script triggers a fresh creation. Reads "transient" literally. "Transient" matches sub-issue wording literally. No long-lived clutter on disk. Every Code restart pays the full env-rebuild cost. Slow first-run after restart. Network usage spikes. Loss of debug breakpoints / installed dev tools.
    C Sibling .venv, TTL of N days unused Same .venv layout as A, plus a background watch/timer that deletes envs whose script hasn't been opened/run for N days. "Unused" needs a precise definition (last access time? last Run/F5? last activation in a terminal?). Clutter bounded; minimal disruption for active scripts. Adds a watch/timer service. "Unused" is fuzzy (does a pip list from the terminal count?).
    D Content-addressed cache, global Env is stored under extension-owned global storage (e.g. globalStorageUri/script-envs/<hash>/), keyed by a hash of the metadata block. Two scripts declaring the same deps share one env. Status bar shows the resolved interpreter path inside the cache; the env is not visible in the workspace folder. Mirrors how uv run --script reuses cached envs and is what the ecosystem norm looks like. Cross-script reuse (multiple scripts declaring the same deps share one env). No clutter near user files. Mirrors the ecosystem norm set by uv. Hidden envs are confusing ("where is my pandas installed?"). Harder for Pylance to find without explicit configuration. Cache invalidation on terminal pip install is a problem. uv deliberately hides these from end users; surfacing them in our tree view diverges from that UX.
    E Cache, but in workspace (under .vscode/) Env stored under .vscode/script-envs/<hash>/ per workspace folder. Persistent across restarts but not shared across machines or workspaces. Visible to user; per-workspace isolation; survives restarts but not across machines. Workspace-folder clutter. Multi-root semantics are awkward (which workspace folder owns it?).

    Q4 — Is the env read-only, or can the user pip install into it?

    Context. PEP 723 declares dependencies inline. If the user installs a package via VS Code's "Install Package" command, we have two choices: (a) treat the env as a normal venv and allow it (the metadata becomes stale relative to the env), or (b) treat the env as read-only / declarative and refuse, redirecting the user to edit the metadata first.

    This affects more than the explicit Install command — terminal pip install, notebook/interactive installs, and any other channel face the same question. The spec's text is silent on whether the env should reflect only the metadata, but its "self-contained scripts" spirit points toward env ≡ metadata.

    Alternatives we considered

    Option UI / user flow Pros Cons
    A Writable; no sync The script's env is a normal venv. "Install Package" and terminal pip install work as they do for any other project; the package lands in the env. The # /// script block is never edited by the extension. Matches how the extension treats pyproject.toml-based projects today (installs don't auto-edit pyproject.toml). Consistent with rest of extension. Doesn't surprise users mid-flow. Lets users add ad-hoc dev tools (ipython, pdbpp) without touching the script. Script env can diverge from script metadata silently. Other tools (uv run --script) won't see the extra deps; the script is no longer self-contained.
    B Writable; auto-sync to metadata block (TOML-aware writer) Same install UX as A, but after each successful install/uninstall the extension rewrites the # /// script block to add/remove the dep. Requires a comment-preserving TOML writer. Script stays self-contained, which is the whole point of PEP 723. TOML writers that preserve comments/formatting are non-trivial. Race conditions with user edits. We may rewrite the user's hand-formatted block.
    C Writable; non-modal "metadata is now stale" warning Installs work as in A. After each install, an information notification appears: "Your script's env now contains packages not declared in # /// script. Sync to metadata?" with action buttons. Halfway: user retains control, knows about the divergence. Warning fatigue if user installs many packages.
    D Read-only env; require metadata edit + recreate to add deps "Install Package" is disabled (or redirects to "Edit metadata"). Terminal pip install is not intercepted, but any change made there is wiped on the next metadata-driven resync. The only supported way to add a dep is to edit the # /// script block and let the extension rebuild. Strictly faithful to the spec. Strictly faithful to the spec's "scripts are self-contained" intent. The recommended IDE posture per the spec's spirit. High friction for iterative development. Beginners will hate it.

    Q5 — Who is the audience: writers (full IDE experience) or runners (one-shot execute)?

    Context. PEP 723 was designed in a tool-runner mindset: write a small script, drop a metadata block at the top, run with uv run --script foo.py. The IDE is incidental. But VS Code users come in two distinct flavours when they hit a .py file with inline metadata:

    • "Run this script" — they treat the file like a CLI tool. They want the env to materialize, they want to F5 / python foo.py, they're done. They don't need import resolution, refactor, breakpoints, or hover. Beginner-friendly; matches the spec's intent.
    • "Author this script" — they treat it like any other .py file: iterate, refactor, get Pylance completions, debug, write more code, add deps. The fact that the env-deps live in the file header is just a packaging convenience.

    A meaningful share of our audience is the author crowd, because VS Code is an IDE. But the spec's design centre is the runner crowd. This question forces us to be honest about which one our defaults serve. A third audience to keep in mind is publishers of single-file tools, who want to validate that their declared deps are minimal and correct before sharing.

    Cross-extension caveat. The green Run button and the python debug type (F5) are owned by the Microsoft Python extension (ms-python.python), not by us. We can create the script's env and surface it in our own UI (status bar, Projects view), but we can't unilaterally make Run/F5 launch with the script's env — that requires a coordinated change in ms-python.python. This caveat bites option B the hardest: "Optimise for runners" is largely about making one-click execution feel native, so without that coordination we either ship v1 with Run/F5 still pointing at the workspace interpreter (and document the gap) or block v1 on the cross-extension work.

    Alternatives we considered

    Option UI / user flow Pros Cons
    A Optimise for authors Every script gets a real, on-disk env. Pylance, the debugger, the status-bar interpreter selector, the Python Projects tree view, and terminal activation all light up the same way they do for a workspace project. No special "runner" affordances. Full IDE experience: completion, refactor, breakpoints, hover. People who write and iterate on the script over weeks get the best of VS Code. Heavier than the spec's "click and run" mental model. Disk and tree-view weight per script. Beginner who just wants to run once pays for IDE setup they don't need.
    B Optimise for runners Hide the env from the Python Projects tree view by default; no status-bar item; one-click "Run script" CodeLens at the top of the file; ephemeral env (e.g. Q3 option B — GC'd on restart). Closer to uv run --script semantics: open, click, done. Beginner-friendly; matches the spec's intent. Lightweight for one-shot users. Authors lose the discoverability of the env. Pylance/debugger story still has to work somewhere, so most of the "optimise for runners" cleanup is cosmetic UI hiding rather than real simplification. Also can't fully deliver one-click execution without a coordinated change in ms-python.python (see cross-extension caveat above).
  3. ranma42 commented on Jul 14, 2026

    @ranma42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions