Skip to content

Add inline-script interpreter selection helpers (PEP 723 PR 3/16)#1636

Open
StellaHuang95 wants to merge 3 commits into
microsoft:mainfrom
StellaHuang95:pep723-pr3-interpreter-selection
Open

Add inline-script interpreter selection helpers (PEP 723 PR 3/16)#1636
StellaHuang95 wants to merge 3 commits into
microsoft:mainfrom
StellaHuang95:pep723-pr3-interpreter-selection

Conversation

@StellaHuang95

@StellaHuang95 StellaHuang95 commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Part of #1602 (PEP 723 inline script env support). Design doc: #1601.

Roadmap context

This is PR 3 of 16 in the PEP 723 inline-script roadmap. The full plan lives in #1602.

Phase PR Status
Phase 1: Foundation PR 1: cache key hash utility in review (#1634)
PR 2: cache layout + meta.json sidecar in review (#1635)
PR 3: requires-python to interpreter selection this PR
Phase 2: Manager PR 4: InlineScriptEnvManager skeleton merged (#1610)
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 with get, set, and Memento in progress (needs 4)
PR 8: activation-time discovery not started (needs 2, 4, 7)
Phase 3: Routing PR 9: route PEP 723 scripts to the inline manager not started (needs 4, 7)
PR 10: per-script project registration not started (needs 9)
Phase 3.5: Cross-repo PRs 17-19: Pylance and vscode-python integration not started
Phase 4: UX PR 11: picker item; PR 12: bulk command not started
Phase 5: Lifecycle and polish PR 13: clear cache; PR 14: TTL; PR 15: telemetry; PR 16: status bar not started

PRs 1-3 are independent Phase 1 utilities that PR 5 will compose when it implements the environment creation path.

Why this PR

Before the inline-script manager can create an environment, it needs to answer two questions from Q4 of #1601:

  1. Which installed base interpreter is the newest one compatible with the script's requires-python declaration?
  2. If none is compatible, what lower-bound version can the existing uv installation flow request?

Both decisions are pure functions over an environment list and a PEP 440 specifier. Keeping them outside the manager makes the filtering, ranking, and fallback rules independently testable without environment discovery, UI, filesystem access, or process execution.

What this PR does

Adds src/common/inlineScriptInterpreter.ts with two exported helpers.

pickCompatibleInterpreter(installed, requiresPython)

Filters the supplied environments and returns the newest usable Python 3 interpreter satisfying the script's constraint.

A candidate is rejected when:

  • env.error is set.
  • env.version is missing, empty, or does not begin with numeric release segments.
  • The interpreter is not Python 3.
  • The version does not satisfy requiresPython according to the existing matchesPythonVersion helper.

An undefined, empty, or whitespace-only constraint is treated as unconstrained. If no candidate qualifies, the function returns undefined so the creation flow can offer the uv fallback.

Candidates are ranked by numeric release segments in descending order. A leading v is tolerated, and prerelease, development, and local suffixes are ignored for ranking. Ties preserve input order. The input array is copied before sorting and is never mutated.

Examples:

  • No constraint across Python 3.10, 3.11, and 3.12 selects 3.12.
  • >=3.11,<3.13 selects the newest interpreter within that range.
  • ==3.12.* uses the existing wildcard-matching semantics.
  • ~=3.12.4 honors the compatible-release upper bound.
  • If only Python 2, broken environments, or incompatible versions are available, the result is undefined.

Base-interpreter caller contract

installed must contain base interpreters only: system Python, pyenv-installed Python, uv-installed Python, or conda base. Derived environments such as venvs, named conda environments, Poetry environments, and Pipenv environments must not be used as venv bases.

api.getEnvironments('global') is the intended source. The helper still defensively rejects errored, versionless, unparseable, and non-Python-3 entries.

extractLowerBoundVersion(requiresPython)

Extracts the tightest usable lower bound for a future uv python install <version> request.

Supported floor-producing forms include:

  • >=3.13 to 3.13
  • ~=3.12.4 to 3.12.4
  • ==3.12.7 to 3.12.7
  • ==3.12.* to 3.12
  • >=3.11,>=3.12,<3.14 to 3.12

When multiple lower bounds are present, the numerically greatest one is returned.

Recognized operators that do not provide a safe integer floor (>, <, <=, !=, and ===) are skipped without warning. Malformed clauses, invalid wildcard placement, and ~= values without at least major and minor segments are skipped with traceWarn.

A skipped clause does not discard a valid floor from another clause. For example, >=3.11,<3.13 returns 3.11, while an upper-bound-only spec returns undefined.

The extracted value is an installation hint, not a full compatibility result. PR 6 will re-run matchesPythonVersion after uv installs an interpreter.

Tests

Adds src/test/common/inlineScriptInterpreter.unit.test.ts with 34 focused unit tests covering:

  • Empty input and no-compatible-interpreter results.
  • Unconstrained and constrained newest-version selection.
  • Multi-clause, wildcard, and compatible-release constraints.
  • Errored, versionless, unparseable, and Python 2 environments.
  • Empty-string constraints and leading v prefixes.
  • Stable ties and non-mutating input behavior.
  • Ranking versions with prerelease, development, and local suffixes.
  • Lower-bound extraction for >=, ==, wildcard ==, and ~=.
  • Tightest-floor selection across multiple clauses.
  • Upper-bound-only and unsupported operators.
  • Malformed clauses, wildcard misuse, and warning behavior.

Required checks pass on this branch:

  • npm run lint
  • npm run compile-tests
  • npm run unittest: 1418 passing, 0 failing, 4 pending

User impact

Zero.

Nothing in the extension calls these helpers yet. This PR discovers no interpreters, installs no Python versions, creates no environments, and adds no settings, commands, UI, filesystem access, or activation behavior.

PR 5 will call pickCompatibleInterpreter for the normal creation path. PR 6 will call extractLowerBoundVersion when no compatible installed interpreter exists and the user is offered a uv installation.

Design context

This implements Q4 step 1 from #1601: select the newest installed Python satisfying requires-python, and derive a lower-bound version for the uv fallback when no installed interpreter qualifies.

It reuses the existing matchesPythonVersion implementation rather than introducing a second PEP 440 matcher.

Explicit installation consent

Following review feedback, the uv fallback now has an explicit, informed-consent contract for inline scripts:

  • The modal explains the script's requires-python constraint and the exact Python version requested.
  • If uv is missing, both the message and action disclose that uv and Python will be installed.
  • No prompt is offered when a compatible concrete install version cannot be derived.
  • Script-controlled prompt text is flattened, bounded, and stripped of control characters; install versions must be numeric release segments.
  • Dismissal performs no installation and remains retryable; inline-script setup does not inherit the global "Don't ask again" suppression state.
  • Approval forwards the displayed version to uv python install, and installed-version lookup matches release-segment boundaries.

The inline-script manager still has no creation call site in this PR, so this establishes and tests the consent API without changing current user behavior. PR 6 will invoke it from the uv fallback.

@StellaHuang95 StellaHuang95 added the feature-request Request for new features or functionality label Jul 15, 2026
Pure helpers that pick the right installed Python interpreter for a
PEP 723 script (given its requires-python specifier) and extract the
lower-bound version to hand to `uv python install` when no compatible
interpreter is installed. Third foundation PR -- pure utility, no
behavior change on its own.

What is in:

- src/common/inlineScriptInterpreter.ts
  - pickCompatibleInterpreter(installed, requiresPython) -- filters
    out errored envs, version-unparseable envs, Python 2 (and Python
    4+ until the code is revisited), and when constrained, anything
    that does not satisfy requires-python. Sorts by version
    descending and returns the head. Stable sort preserves input
    order for ties so callers can express a preference (e.g. "system
    Pythons first, then uv-managed"). Empty-string requiresPython is
    normalized to "no constraint" rather than "matches nothing".
    Docstring spells out the caller contract: only base interpreters
    (system, pyenv, uv, conda base), not derived envs.
  - extractLowerBoundVersion(requiresPython) -- extracts the floor
    version string suitable for `uv python install <version>`:
      ">=3.13"       -> "3.13"
      ">=3.11,<3.13" -> "3.11" (tightest lower bound)
      "==3.12.*"     -> "3.12"
      "~=3.12.4"     -> "3.12.4"
    Returns undefined (and the caller falls back to uv defaults) for
    upper-bound-only specs, the `>` operator (no clean integer
    floor), `===` (opaque shape), illegal `~=X` without a minor
    segment, illegal wildcards on `>=` / `~=`, and unparseable
    input. Every rejection mirrors matchesPythonVersion so a value
    we hand to uv is one the picker will then accept post-install.

- src/test/common/inlineScriptInterpreter.unit.test.ts -- 34 unit
  tests covering: empty input, multi-clause specs, errored envs,
  Python-2 filtering, ties (stable sort), wildcard specs (==X.* in
  picker), implicit upper bound of `~=X.Y.Z`, empty-string
  constraint, pre/dev/local-suffix version ranking, lower-bound
  extraction across all operators, mixed-clause cases, illegal
  shape rejection (`~=3`, `>=3.*`, `~=3.12.*`).

Re-uses matchesPythonVersion and the lessons of PEP 440 shape
validation from inlineScriptMetadata.ts rather than re-implementing
the matcher.

Design context

Implements the "pick a compatible interpreter" half of Q4 step 1
from pep723_design_questions.md, plus the lower-bound extraction
needed to wire up step 1`s fallback to promptInstallPythonViaUv in a
later PR. The actual call sites (creation flow, fallback flow,
re-verify after install) all live in a later PR.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@StellaHuang95
StellaHuang95 force-pushed the pep723-pr3-interpreter-selection branch from 91ea3fc to af51bb4 Compare July 15, 2026 22:16
@StellaHuang95

Copy link
Copy Markdown
Contributor Author

One feedback from Brett is to ask for user's permission to install Python using UV if none is compatible.

@StellaHuang95

Copy link
Copy Markdown
Contributor Author

Addressed in 3cc949e. The inline-script uv fallback now requires an explicit modal approval, identifies the requirement and exact Python version, discloses when uv itself will also be installed, and forwards only the approved numeric version. If no concrete compatible version can be derived, it does not offer an install. Dismissal is side-effect-free and retryable, and tests cover consent copy, cancellation, validation/sanitization, telemetry, and the actual uv python install <version> task arguments.

@brettcannon brettcannon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand where a "v" prefix would show up?

}

function parseReleaseSegments(version: string): number[] | undefined {
const m = version.match(/^v?(\d+(?:\.\d+)*)/i);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why concern for v?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same reason I mentioned below, a valid version specifier can have a leading v

Comment thread src/common/inlineScriptInterpreter.ts Outdated
}

function parseLeadingMajor(version: string): number | undefined {
const m = version.match(/^\s*v?(\d+)/i);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the concern for v?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@brettcannon So my understanding is that the pep723 specs says "requires-python: A string that specifies the Python version(s) with which the script is compatible. The value of this field MUST be a valid version specifier." (See here).

And if I look for what is a valid version specifier, it says it can have preceding v character. See here

@StellaHuang95 StellaHuang95 Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but I guess you're right, since this function is parsing existing python version not a declaration, actual CPython version can't have a leading v. Let me update it, but I think we should keep the other one.

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

Labels

feature-request Request for new features or functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants