Skip to content

Commit cdec8ef

Browse files
committed
Add sync_toolchains/check_toolchains actions and Python interpreter derivation (ADR-0053)
Implements the toolchain-axis derivation promised by the previous commit's requires-python backfill: fine_envs.sync_toolchains derives each env's toolchain axis from the project's declared support range and materializes it into the project definition file, dispatching per language to a subaction (fine_python_lang.sync_python_interpreters). check_toolchains re-derives with save=False and fails on drift, for wiring into precommit/CI (bridged via fine_git_hooks). The Python subaction expands requires-python against the set of interpreters uv can actually obtain (list_obtainable_toolchains / list_obtainable_python_interpreters, backed by fine_python_uv.UvListObtainablePythonInterpretersHandler), so an open upper bound is resolved against what is installable rather than rejected or left to silently backtrack. The axis is materialized rather than recomputed at read time so config resolution stays a pure read and a preset bump can't change a project's matrix without a diff — see docs/reference/actions.md and ADR-0053 for the full rationale on both actions and the derive/materialize/check shape.
1 parent c47a6ab commit cdec8ef

28 files changed

Lines changed: 2359 additions & 4 deletions

docs/guides/developing-finecode.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,16 @@ python -m finecode prepare-envs
352352

353353
To update lock files, run `lock_dependencies` locally or in a scheduled CI job and commit the result. For multi-platform projects, use a CI matrix to generate lock files on each target platform.
354354

355+
## `requires-python`: no upper bound
356+
357+
FineCode's own packages declare a **lower bound only** on `requires-python` (e.g. `>=3.11`), never an upper bound (`< 3.15`, `<= 3.14`).
358+
359+
An upper bound is a packaging anti-pattern for published packages: a resolver that cannot satisfy the cap **backtracks to an older release** of the package rather than failing cleanly, so a consumer on a newer Python silently gets a stale version instead of a clear "not supported yet" error. See [ADR-0053](../../../finecode_internal_docs/adr/0053-derived-interpreter-axis-is-materialized-into-config.md) for the full rationale.
360+
361+
The cap also has no remaining job now that the interpreter matrix exists. The set of Python versions an action is tested against is **derived from `requires-python` and bounded by what the provisioning toolchain (uv) can actually obtain** (ADR-0053, part 5), not by a hand-written ceiling. So removing the upper bound does not widen the test matrix to unreleased versions — the obtainable-versions ceiling does that job, on the developer's clock and in a reviewable diff.
362+
363+
If a genuinely newer Python breaks a package, fix it when that version exists — do not pre-emptively cap. New packages must follow this: declare `requires-python = ">=<min>"` with no upper component.
364+
355365
## JSON-RPC key naming convention
356366

357367
All JSON-RPC channels in FineCode use **camelCase** for message keys:

docs/reference/actions.md

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,129 @@ Install dependencies into a specific environment.
265265

266266
---
267267

268+
## `sync_toolchains`
269+
270+
Derive each environment's toolchain axis from the project's declared support range and write it into the project definition file.
271+
272+
- **Source:** `fine_envs.SyncToolchainsAction`
273+
274+
**Payload fields:**
275+
276+
| Field | Type | Default | Description |
277+
|---|---|---|---|
278+
| `project_def_path` | `Path \| None` | `None` | Project definition file declaring the envs. `None` means the current project. |
279+
| `save` | `bool` | `True` | Write the derived axis to the file. `False` derives and reports without writing. |
280+
281+
**Result fields:**
282+
283+
| Field | Type | Description |
284+
|---|---|---|
285+
| `axes` | `list[EnvToolchainAxis]` | Per env: `declared`, `derived`, and whether it `changed` |
286+
| `saved` | `bool` | Whether a derived axis was written |
287+
288+
A **toolchain** is the implementation-and-version a project is executed against; in Python it is an [interpreter](../glossary.md#interpreter). Every ecosystem declares its support range somewhere (`requires-python`, `engines`, `required_ruby_version`), and a language handler expands that range into toolchain identities. The action dispatches on project language to the matching subaction.
289+
290+
The axis is *materialized* — written to the file rather than recomputed on each run — so that config resolution stays a pure read of already-declared data. See [ADR-0053](../adr/0053-derived-interpreter-axis-is-materialized-into-config.md) for why, and note the consequence: the axis is wholly generated, so extra toolchains are configured as *inputs to the source* (`extra_interpreters`) rather than hand-added to its output.
291+
292+
---
293+
294+
## `check_toolchains`
295+
296+
Check whether each environment's materialized toolchain axis still matches what the source derives. Fails with a non-zero return code on drift.
297+
298+
- **Source:** `fine_envs.CheckToolchainsAction`
299+
300+
**Payload fields:**
301+
302+
| Field | Type | Default | Description |
303+
|---|---|---|---|
304+
| `project_def_path` | `Path \| None` | `None` | Project definition file declaring the envs. `None` means the current project. |
305+
306+
**Result fields:**
307+
308+
| Field | Type | Description |
309+
|---|---|---|
310+
| `stale_axes` | `list[EnvToolchainAxis]` | Envs whose declared axis differs from the derived one |
311+
312+
A generated, committed axis can go stale — the support range changes, or the source learns about a newer toolchain. That is the same staleness a lock file has, and it is caught the same way: re-derive and compare. Wire this into `precommit` and CI. Runs `sync_toolchains` with `save = False` and reports what would change.
313+
314+
---
315+
316+
## `sync_python_interpreters`
317+
318+
Derive an environment's Python interpreter axis from `requires-python`. Language-specific subaction of `sync_toolchains`.
319+
320+
- **Source:** `fine_python_lang.SyncPythonInterpretersAction`
321+
- **Handler:** `fine_python_package_info.SyncPythonInterpretersPyHandler`
322+
- **Preset:** `fine_python_envs`
323+
324+
**Payload fields:** same as `sync_toolchains`. **Result fields:** same as `sync_toolchains`.
325+
326+
**Handler config:**
327+
328+
| Field | Type | Default | Description |
329+
|---|---|---|---|
330+
| `envs` | `list[str]` | `[]` | Envs whose interpreter axis is derived. Empty means none — the action is a no-op. An env either derives its axis or has one pinned, never both. |
331+
| `max_supported_python` | `str \| None` | `None` | Cap the newest CPython to derive. `None` means no cap beyond what is obtainable. |
332+
| `extra_interpreters` | `list[str]` | `[]` | Interpreters beyond the derived CPython rows, e.g. `["pypy@3.11"]`. |
333+
334+
`requires-python` is a *specifier*, not an enumeration, so it is expanded against the set of **obtainable** interpreters (see `list_obtainable_toolchains` below). An open upper bound (`>=3.11`) — the correct form for a published package — is bounded by that set rather than rejected. The result therefore depends on something outside the specifier, which is exactly why it is persisted.
335+
336+
`requires-python` constrains version only and carries no implementation, so the derived axis is CPython-only. PyPy and friends are configured via `extra_interpreters`.
337+
338+
Matrices stay opt-in: with no `envs` configured, nothing is derived and every action keeps running in a single environment with an unchanged result shape.
339+
340+
The derived axis is written into the **project's own** definition file, and project config beats preset config. So if a preset pins `interpreters` for an env that is also listed in `envs`, the derived axis is materialized over it and the run warns once, since the pin stops having any effect. To keep the preset's axis instead, drop that env from `envs` in your own config. Deriving into your own file is also the only way to override a pinned axis at all, because config layering can replace a key but never unset one.
341+
342+
Materializing once per project rather than sharing one axis from a preset is deliberate: the axis derives from `requires-python`, which is per-project, and a project that states its own axis cannot have its matrix changed by a preset bump without a diff. See [ADR-0053](../adr/0053-derived-interpreter-axis-is-materialized-into-config.md) and `SyncPythonInterpretersPyHandler`'s docstring.
343+
344+
---
345+
346+
## `list_obtainable_toolchains`
347+
348+
List the toolchains the environment provisioner is able to obtain.
349+
350+
- **Source:** `fine_envs.ListObtainableToolchainsAction`
351+
352+
**Payload fields:**
353+
354+
| Field | Type | Default | Description |
355+
|---|---|---|---|
356+
| `include_prereleases` | `bool` | `False` | Include prerelease toolchains (e.g. a Python beta). |
357+
358+
**Result fields:**
359+
360+
| Field | Type | Description |
361+
|---|---|---|
362+
| `toolchains` | `list[str]` | Canonical identities, e.g. `cpython@3.13` — no patch level, variant, or platform tag |
363+
364+
**"Obtainable" is deliberately not "installed".** This reports what the *provisioner* can get — a property of a locked dependency — not what happens to be present on this machine. Only the former may feed a derived matrix axis: an axis sourced from local installs would differ between developers on the same commit. Whether a toolchain is available *here* is a separate question, and would be a separate action.
365+
366+
The provisioner is the authority because deriving a version it cannot obtain yields an axis whose environments cannot be created. This is what `sync_toolchains` expands `requires-python` against.
367+
368+
---
369+
370+
## `list_obtainable_python_interpreters`
371+
372+
Language-specific subaction of `list_obtainable_toolchains`. Backed by uv.
373+
374+
- **Source:** `fine_python_lang.ListObtainablePythonInterpretersAction`
375+
- **Handler:** `fine_python_uv.UvListObtainablePythonInterpretersHandler`
376+
- **Preset:** `fine_python_envs`
377+
378+
**Payload and result fields:** same as `list_obtainable_toolchains`.
379+
380+
**Handler config:**
381+
382+
| Field | Type | Default | Description |
383+
|---|---|---|---|
384+
| `variant` | `str` | `"default"` | Build variant to report. `freethreaded` builds are a separate variant the `(implementation, version)` identity cannot express. |
385+
386+
Runs `uv python list --only-downloads`, which reports uv's own manifest rather than the machine's installed Pythons. uv's listing is far finer-grained than a matrix axis — patch levels, prereleases, freethreaded variants, platform tags — and all of that is collapsed to one identity per implementation and minor version. Prereleases are excluded by default, so a released beta (`cpython-3.15.0b1`) never enters an axis.
387+
388+
---
389+
390+
268391
## `dump_config`
269392

270393
Dump the resolved configuration for a source artifact that includes FineCode configuration.

extensions/fine_python_lang/fine_python_lang/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@
2323
LockPythonDependenciesRunContext,
2424
LockPythonDependenciesRunPayload,
2525
)
26+
from fine_python_lang.list_obtainable_python_interpreters_action import (
27+
ListObtainablePythonInterpretersAction,
28+
ListObtainablePythonInterpretersRunContext,
29+
ListObtainablePythonInterpretersRunPayload,
30+
)
31+
from fine_python_lang.sync_python_interpreters_action import (
32+
SyncPythonInterpretersAction,
33+
SyncPythonInterpretersRunContext,
34+
SyncPythonInterpretersRunPayload,
35+
)
2636
from fine_python_lang.text_document_prepare_call_hierarchy_python_action import (
2737
TextDocumentPrepareCallHierarchyPythonAction,
2838
)
@@ -74,6 +84,12 @@
7484
"LockPythonDependenciesAction",
7585
"LockPythonDependenciesRunContext",
7686
"LockPythonDependenciesRunPayload",
87+
"ListObtainablePythonInterpretersAction",
88+
"ListObtainablePythonInterpretersRunContext",
89+
"ListObtainablePythonInterpretersRunPayload",
90+
"SyncPythonInterpretersAction",
91+
"SyncPythonInterpretersRunContext",
92+
"SyncPythonInterpretersRunPayload",
7793
"TextDocumentHoverPythonAction",
7894
"TextDocumentDefinitionPythonAction",
7995
"TextDocumentReferencesPythonAction",
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# docs: docs/reference/actions.md
2+
import dataclasses
3+
4+
from finecode_extension_api import code_action
5+
from fine_envs.list_obtainable_toolchains_action import (
6+
ListObtainableToolchainsAction,
7+
ListObtainableToolchainsRunPayload,
8+
ListObtainableToolchainsRunResult,
9+
)
10+
11+
12+
@dataclasses.dataclass
13+
class ListObtainablePythonInterpretersRunPayload(ListObtainableToolchainsRunPayload):
14+
"""Same payload as the parent."""
15+
16+
17+
class ListObtainablePythonInterpretersRunContext(
18+
code_action.RunActionContext[ListObtainablePythonInterpretersRunPayload]
19+
): ...
20+
21+
22+
class ListObtainablePythonInterpretersAction(
23+
code_action.Action[
24+
ListObtainablePythonInterpretersRunPayload,
25+
ListObtainablePythonInterpretersRunContext,
26+
ListObtainableToolchainsRunResult,
27+
]
28+
):
29+
"""List the Python interpreters the environment provisioner can obtain.
30+
31+
Returns canonical ``<implementation>@<minor version>`` identities across every
32+
implementation the provisioner offers, stable releases only by default.
33+
"""
34+
35+
DESCRIPTION = "List the Python interpreters the environment provisioner can obtain."
36+
PAYLOAD_TYPE = ListObtainablePythonInterpretersRunPayload
37+
RUN_CONTEXT_TYPE = ListObtainablePythonInterpretersRunContext
38+
RESULT_TYPE = ListObtainableToolchainsRunResult
39+
LANGUAGE = "python"
40+
PARENT_ACTION = ListObtainableToolchainsAction
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# docs: docs/reference/actions.md
2+
import dataclasses
3+
4+
from finecode_extension_api import code_action
5+
from fine_envs.sync_toolchains_action import (
6+
SyncToolchainsAction,
7+
SyncToolchainsRunPayload,
8+
SyncToolchainsRunResult,
9+
)
10+
11+
12+
@dataclasses.dataclass
13+
class SyncPythonInterpretersRunPayload(SyncToolchainsRunPayload):
14+
"""Same payload as the parent.
15+
16+
The Python source takes no extra caller-facing parameters: which envs derive an
17+
axis, and how the derivation is tuned, is derivation intent and lives in handler
18+
config (ADR-0053).
19+
"""
20+
21+
22+
class SyncPythonInterpretersRunContext(
23+
code_action.RunActionContext[SyncPythonInterpretersRunPayload]
24+
): ...
25+
26+
27+
class SyncPythonInterpretersAction(
28+
code_action.Action[
29+
SyncPythonInterpretersRunPayload,
30+
SyncPythonInterpretersRunContext,
31+
SyncToolchainsRunResult,
32+
]
33+
):
34+
"""Derive an env's Python interpreter axis.
35+
36+
The Python materialization of ``sync_toolchains``: a toolchain here is an
37+
interpreter, identified by implementation and version together (``cpython@3.12``).
38+
39+
Where the supported versions are declared, and how they expand into identities, is
40+
the handler's business.
41+
"""
42+
43+
DESCRIPTION = (
44+
"Derive an env's Python interpreter axis from the project's declared support."
45+
)
46+
PAYLOAD_TYPE = SyncPythonInterpretersRunPayload
47+
RUN_CONTEXT_TYPE = SyncPythonInterpretersRunContext
48+
RESULT_TYPE = SyncToolchainsRunResult
49+
LANGUAGE = "python"
50+
PARENT_ACTION = SyncToolchainsAction

extensions/fine_python_lang/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ dependencies = [
1616
"fine_check_imports~=0.1.0a0",
1717
"fine_semantic_tokens~=0.1.0a0",
1818
"fine_inlay_hints~=0.1.0a0",
19+
"fine_envs~=0.1.0a0",
1920
]
2021

2122
[dependency-groups]

extensions/fine_python_package_info/fine_python_package_info/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
from .publish_artifact_to_registry_py_handler import \
1010
PublishArtifactToRegistryPyHandler
1111
from .py_package_layout_info_provider import PyPackageLayoutInfoProvider
12+
from .sync_python_interpreters_handler import SyncPythonInterpretersHandler
1213

1314
__all__ = [
1415
"BuildArtifactPyHandler",
16+
"SyncPythonInterpretersHandler",
1517
"GetDistArtifactVersionPyHandler",
1618
"PyPackageLayoutInfoProvider",
1719
"GetSrcArtifactVersionPyHandler",

0 commit comments

Comments
 (0)