Skip to content

Commit 4f37acb

Browse files
committed
Add --env/--interpreter selectors and matrix-keyed fan-out for interpreter-matrix actions (PRD-0003 AC8)
- env_selection.py: pure resolution of --env/--interpreter selectors plus each matrix env's default_interpreters policy into a concrete env subset, shared by prepare-envs and run. - prepare_envs_service: create_envs skips unselected matrix children, install_envs installs only the selected set; --env/--interpreter and per-env default_interpreters policy wired through the CLI and API. - matrix_runner.py/matrix_streaming.py/merge_helpers.py: WM-side per- interpreter-variant fan-out and result/streaming combination for matrixed actions, restricted by the resolved selection (run_selection.py). - fine_envs/fine_python_uv: CreateEnvsAction accepts env_names filtering and passes the matrix env's interpreter through to `uv venv --python`. - Add IWorkspaceActionRegistry (finecode/listWorkspaceActions) and extend resolveActionMeta with file locations for actions and handlers. - Adapt PyreflyLspService and TypeCheckPythonFilesAction to resolve imports against the "dev" env (falling back to "runtime"), and quiet LintInspectCodeBridgeHandler's "no matching project" warning for system-triggered calls.
1 parent dc09b64 commit 4f37acb

48 files changed

Lines changed: 3898 additions & 238 deletions

Some content is hidden

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

docs/cli.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ python -m finecode run [options] <action> [<action> ...] [payload] [--config.<ke
6161
| `--no-env-config` | Ignore `FINECODE_CONFIG_*` environment variables |
6262
| `--no-save-results` | Do not write action results to the cache directory |
6363
| `--dev-env=<env>` | Override the detected dev environment. One of: `ai`, `ci`, `cli`, `ide`, `precommit` (default: auto-detected — see [Dev environment detection](#dev-environment-detection)) |
64+
| `--env=<name>` | For a matrixed action (ADR-0047), restrict execution to the named interpreter environment(s) — a matrix base selects all of its children, a concrete child selects only itself. Repeatable. Non-matrix envs are unaffected. See [Preparing Environments — filtering by environment name](guides/preparing-environments.md#filtering-by-environment-name). |
65+
| `--interpreter=<impl>@<version>` | For a matrixed action, restrict execution to the named interpreter(s) across every matrix env the action touches. Repeatable; a bare version means `cpython`. See [Preparing Environments — filtering by interpreter](guides/preparing-environments.md#filtering-by-interpreter). |
66+
67+
`--env` and `--interpreter` on `run` use the same selector semantics as `prepare-envs` (ADR-0050): they compose by intersection, and a matrix env's config-declared `default_interpreters` policy (see [Preparing Environments — default interpreter subset](guides/preparing-environments.md#default-interpreter-subset)) applies as the default when neither is given — so a plain `run` can execute only a local subset of a matrix (e.g. the newest interpreter) while CI still runs the full axis, mirroring `prepare-envs`.
6468

6569
WAL environment variable and storage settings are shared with `start-wm-server` — see [`start-wm-server`](#start-wm-server) for details.
6670

@@ -111,6 +115,12 @@ python -m finecode --workdir=./finecode_extension_api run lint
111115

112116
# Override ruff line length
113117
python -m finecode run lint --config.ruff.line_length=120
118+
119+
# Run a matrixed action's "testing" env only for its cpython@3.11 child
120+
python -m finecode run run_tests --env=testing@cpython-3.11
121+
122+
# Run every matrix env's 3.12 interpreter
123+
python -m finecode run run_tests --interpreter=3.12
114124
```
115125

116126
---

docs/wm-er-protocol.md

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -242,18 +242,21 @@ protocol even though the runner never became reachable. (Regression-tested in
242242

243243
- `finecodeRunner/resolveActionMeta`
244244
- Params: `{}` (no params)
245-
- Result: complete map of `{ "<configSource>": { "canonical_source": string, "runs_concurrently": bool, "scope": string, "parentActionSource": string | null, "language": string | null }, ... }` for every
246-
action whose class can be imported in this env. Actions that fail to import are
247-
omitted entirely.
248-
Example: `{ "myext.LintAction": { "canonical_source": "myext.actions.lint.LintAction", "runs_concurrently": true, "scope": "project", "parentActionSource": null, "language": null } }`
245+
- Result: `{ "actions": { "<configSource>": { "canonical_source": string, "runs_concurrently": bool, "scope": string, "parentActionSource": string | null, "language": string | null, "fileLoc": string | null }, ... }, "handlerLocations": { "<handlerSource>": string | null, ... } }`.
246+
`actions` covers every action whose class can be imported in this env; actions
247+
that fail to import are omitted entirely. `handlerLocations` covers every handler
248+
registered in this env. `fileLoc` is `"<path>:<lineno>"` of the class's source
249+
(relative to the project dir when inside it, else absolute), or `null` when it
250+
could not be resolved (e.g. a dynamically built class).
251+
Example: `{ "actions": { "myext.LintAction": { "canonical_source": "myext.actions.lint.LintAction", "runs_concurrently": true, "scope": "project", "parentActionSource": null, "language": null, "fileLoc": "myext/actions/lint.py:10" } }, "handlerLocations": { "myext.LintHandler": "myext/lint_handler.py:20" } }`
249252
- Called by the WM after `finecodeRunner/updateConfig` completes to store all action
250-
metadata on its `Action` domain objects before the runner is considered ready. The
251-
WM uses `canonical_source` as the primary identifier in all subsequent action
252-
lookups. `parentActionSource` and `language` are used to serve
253-
`finecode/getActionsForParent` requests (see ER→WM section). Fields absent
254-
from the response (import failure) remain `None` until another runner for the same
255-
project resolves them; if still unresolved when requested, resolution is retried
256-
on demand (see `finecode/getActionsForParent` below).
253+
and handler metadata on its `Action`/`ActionHandler` domain objects before the
254+
runner is considered ready. The WM uses `canonical_source` as the primary
255+
identifier in all subsequent action lookups. `parentActionSource` and `language`
256+
are used to serve `finecode/getActionsForParent` requests (see ER→WM section).
257+
Fields absent from the response (import failure) remain `None` until another
258+
runner for the same project resolves them; if still unresolved when requested,
259+
resolution is retried on demand (see `finecode/getActionsForParent` below).
257260

258261
- `actions/resolveSource`
259262
- Params: `{ "source": string }` — an arbitrary import-path alias to resolve.

extensions/fine_python_lang/fine_python_lang/type_check_python_files_action.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,27 @@ class TypeCheckPythonFilesAction(
1414
DiagnosticFilesRunResult,
1515
]
1616
):
17-
"""Type-check Python source files and report type errors."""
17+
"""Type-check Python source files and report type errors.
18+
19+
Handler recommendation — env for import resolution:
20+
A type checker needs one environment to resolve the imports of the code it
21+
checks. Test files often pull in dev-only dependencies (e.g. pytest) that are
22+
absent from the "runtime" env, so resolving everything against "runtime" makes
23+
those imports unresolvable. Handlers should therefore resolve imports against
24+
the "dev" env (a superset of "runtime": project deps + dev-only deps), falling
25+
back to "runtime" when no "dev" env exists. Falling back only loses symbols,
26+
never adds false ones.
27+
28+
The trade-off is that dev-only deps then resolve from source files too, so the
29+
type checker no longer flags a dev dependency imported from source. That
30+
boundary is intentionally delegated to a dependency-hygiene tool (e.g. deptry),
31+
which must be wired into the same gate so the boundary is still enforced.
32+
33+
Note: "dev"/"runtime" are naming conventions — env labels are arbitrary — so
34+
this env selection could be made configurable in the future, either per handler
35+
or at the action level (one resolution-env policy shared by all of the action's
36+
handlers).
37+
"""
1838

1939
DESCRIPTION = "Type-check Python source files and report type errors."
2040
PAYLOAD_TYPE = DiagnosticFilesRunPayload

extensions/fine_python_pyrefly/fine_python_pyrefly/pyrefly_lsp_service.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,32 @@ def __init__(
7878
client_capabilities=_PYREFLY_CLIENT_CAPABILITIES,
7979
)
8080
# pyrefly's own environment/interpreter auto-detection does not know about
81-
# FineCode's per-project "runtime" env, so without this it resolves imports
82-
# against the wrong (or no) site-packages. Applied here so every feature
83-
# (hover, definition, inlay hints, ...) gets it, not just type checking.
81+
# FineCode's per-project envs, so without this it resolves imports against the
82+
# wrong (or no) site-packages. Applied here so every feature (hover, definition,
83+
# inlay hints, ...) gets it, not just type checking.
84+
#
85+
# A single LSP server can only be configured with one resolution env at startup
86+
# (it cannot switch per file), so the broadest env is used: "dev" is a superset
87+
# of "runtime" (project deps + dev-only deps such as pytest), which lets test
88+
# files resolve their imports too. Falling back to "runtime" when "dev" does not
89+
# exist only loses symbols, never adds false ones.
90+
#
91+
# Trade-off: dev-only deps become resolvable from source files too, so pyrefly no
92+
# longer flags a dev dependency imported from source. That boundary is enforced
93+
# separately by a dependency-hygiene tool (e.g. deptry). "dev"/"runtime" are
94+
# naming conventions (env labels are arbitrary); this could be made configurable
95+
# in the future, per handler or at the action level.
8496
self._pyrefly_settings: dict[str, Any] = {}
85-
venv_dir = extension_runner_info_provider.get_venv_dir_path_of_env("runtime")
97+
resolution_env = "dev"
98+
venv_dir = extension_runner_info_provider.get_venv_dir_path_of_env(
99+
resolution_env
100+
)
101+
if not venv_dir.exists():
102+
resolution_env = "runtime"
103+
venv_dir = extension_runner_info_provider.get_venv_dir_path_of_env(
104+
resolution_env
105+
)
106+
logger.debug(f"pyrefly resolves imports against the '{resolution_env}' env")
86107
interpreter_path = extension_runner_info_provider.get_venv_python_interpreter(
87108
venv_dir
88109
)

extensions/fine_python_uv/src/fine_python_uv/create_env_handler.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ def __init__(
3838
async def _is_valid_virtualenv(self, venv_dir_path: Path) -> bool:
3939
# A valid venv must contain pyvenv.cfg and a runnable interpreter that
4040
# reports a virtualenv prefix relationship.
41+
#
42+
# NOTE: this probe does not check that the
43+
# existing venv's interpreter actually matches `env_info.interpreter`. It could
44+
# be extended to probe the venv python's `platform.python_implementation()` +
45+
# version and treat a mismatch as invalid, so that changing an env's
46+
# interpreter rebuilds a now-stale venv instead of silently keeping the old one.
4147
pyvenv_cfg = venv_dir_path / "pyvenv.cfg"
4248
if not pyvenv_cfg.exists():
4349
return False
@@ -93,7 +99,10 @@ async def run(
9399

94100
uv_executable = get_uv_executable()
95101
# venv can exist but be invalid, use '--clear' to recreate it
96-
cmd = f'"{uv_executable}" venv --clear "{venv_dir_path}"'
102+
python_flag = (
103+
f' --python "{env_info.interpreter}"' if env_info.interpreter else ""
104+
)
105+
cmd = f'"{uv_executable}" venv --clear{python_flag} "{venv_dir_path}"'
97106
self.logger.debug(f"Running uv: {cmd}")
98107
process = await self.command_runner.run(cmd, cwd=dump_dir)
99108
await process.wait_for_end()

extensions/fine_python_uv/tests/__init__.py

Whitespace-only changes.
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
from __future__ import annotations
2+
3+
import pathlib
4+
from typing import Any
5+
6+
from fine_envs import create_env_action, create_envs_action
7+
from finecode_extension_api.interfaces import (
8+
icommandrunner,
9+
ifilemanager,
10+
ilogger,
11+
iprojectactionrunner,
12+
iprojectinfoprovider,
13+
)
14+
from finecode_extension_api.resource_uri import path_to_resource_uri
15+
from finecode_extension_runner.testing import NoOpLogger, run_handler
16+
17+
from fine_python_uv.create_env_handler import UvCreateEnvHandler
18+
19+
20+
class _FakeProcess:
21+
def get_exit_code(self) -> int | None:
22+
return 0
23+
24+
def get_output(self) -> str:
25+
return ""
26+
27+
def get_error_output(self) -> str:
28+
return ""
29+
30+
def write_to_stdin(self, value: str) -> None:
31+
pass
32+
33+
def close_stdin(self) -> None:
34+
pass
35+
36+
async def wait_for_end(self, timeout: float | None = None) -> None:
37+
pass
38+
39+
40+
class _FakeCommandRunner:
41+
"""Captures every command string it is asked to run, instead of executing it."""
42+
43+
def __init__(self) -> None:
44+
self.commands: list[str] = []
45+
46+
async def run(
47+
self,
48+
cmd: str,
49+
cwd: pathlib.Path | None = None,
50+
env: dict[str, str] | None = None,
51+
) -> _FakeProcess:
52+
self.commands.append(cmd)
53+
return _FakeProcess()
54+
55+
def run_sync(
56+
self,
57+
cmd: str,
58+
cwd: pathlib.Path | None = None,
59+
env: dict[str, str] | None = None,
60+
) -> _FakeProcess:
61+
raise NotImplementedError
62+
63+
64+
class _FakeFileManager:
65+
async def get_content(self, file_path: pathlib.Path) -> str:
66+
raise NotImplementedError
67+
68+
async def get_file_version(self, file_path: pathlib.Path) -> str:
69+
raise NotImplementedError
70+
71+
async def save_file(self, file_path: pathlib.Path, file_content: str) -> None:
72+
pass
73+
74+
async def create_dir(
75+
self, dir_path: pathlib.Path, create_parents: bool = True, exist_ok: bool = True
76+
) -> None:
77+
pass
78+
79+
async def remove_dir(self, dir_path: pathlib.Path) -> None:
80+
pass
81+
82+
83+
class _FakeProjectActionRunner:
84+
"""No-ops `run_action` so the handler's config-dump step doesn't need a real
85+
DumpConfigAction handler registered in the test session."""
86+
87+
async def get_actions_for_parent(self, parent_action_type: type) -> dict[str, Any]:
88+
raise NotImplementedError
89+
90+
async def run_action(
91+
self,
92+
action_type: Any,
93+
payload: Any,
94+
meta: Any,
95+
caller_kwargs: Any = None,
96+
) -> None:
97+
return None
98+
99+
def run_action_iter(
100+
self,
101+
action_type: Any,
102+
payload: Any,
103+
meta: Any,
104+
caller_kwargs: Any = None,
105+
) -> Any:
106+
raise NotImplementedError
107+
108+
109+
class _FakeProjectInfoProvider:
110+
"""Only `get_project_raw_config` is exercised (by the config-dump step)."""
111+
112+
def get_current_project_dir_path(self) -> pathlib.Path:
113+
raise NotImplementedError
114+
115+
def get_current_project_def_path(self) -> pathlib.Path:
116+
raise NotImplementedError
117+
118+
async def get_current_project_package_name(self) -> str:
119+
raise NotImplementedError
120+
121+
async def get_project_raw_config(self, project_def_path: pathlib.Path) -> dict[str, Any]:
122+
return {}
123+
124+
async def get_current_project_raw_config(self) -> dict[str, Any]:
125+
raise NotImplementedError
126+
127+
def get_current_project_raw_config_version(self) -> int:
128+
raise NotImplementedError
129+
130+
async def get_workspace_editable_packages(self) -> dict[str, pathlib.Path]:
131+
raise NotImplementedError
132+
133+
134+
def _service_overrides(command_runner: _FakeCommandRunner) -> dict[type, Any]:
135+
return {
136+
icommandrunner.ICommandRunner: command_runner,
137+
ilogger.ILogger: NoOpLogger(),
138+
ifilemanager.IFileManager: _FakeFileManager(),
139+
iprojectactionrunner.IProjectActionRunner: _FakeProjectActionRunner(),
140+
iprojectinfoprovider.IProjectInfoProvider: _FakeProjectInfoProvider(),
141+
}
142+
143+
144+
async def test_uv_venv_command_includes_python_flag_when_interpreter_is_set(
145+
tmp_path: pathlib.Path,
146+
) -> None:
147+
command_runner = _FakeCommandRunner()
148+
# venv_dir_path must not exist so `_is_valid_virtualenv` returns False and the
149+
# create path (which builds the `uv venv` command) actually runs.
150+
venv_dir_path = tmp_path / "venvs" / "testing"
151+
project_def_path = tmp_path / "pyproject.toml"
152+
env_info = create_envs_action.EnvInfo(
153+
name="testing@cpython-3.11",
154+
venv_dir_path=path_to_resource_uri(venv_dir_path),
155+
project_def_path=path_to_resource_uri(project_def_path),
156+
interpreter="cpython@3.11",
157+
)
158+
payload = create_env_action.CreateEnvRunPayload(env=env_info, recreate=False)
159+
160+
result = await run_handler(
161+
UvCreateEnvHandler,
162+
payload,
163+
action_cls=create_env_action.CreateEnvAction,
164+
project_dir=tmp_path,
165+
service_overrides=_service_overrides(command_runner),
166+
)
167+
168+
assert result is not None
169+
assert result.errors == []
170+
venv_commands = [cmd for cmd in command_runner.commands if " venv " in cmd]
171+
assert len(venv_commands) == 1
172+
assert '--python "cpython@3.11"' in venv_commands[0]
173+
174+
175+
async def test_uv_venv_command_omits_python_flag_when_interpreter_is_none(
176+
tmp_path: pathlib.Path,
177+
) -> None:
178+
command_runner = _FakeCommandRunner()
179+
venv_dir_path = tmp_path / "venvs" / "dev"
180+
project_def_path = tmp_path / "pyproject.toml"
181+
env_info = create_envs_action.EnvInfo(
182+
name="dev",
183+
venv_dir_path=path_to_resource_uri(venv_dir_path),
184+
project_def_path=path_to_resource_uri(project_def_path),
185+
interpreter=None,
186+
)
187+
payload = create_env_action.CreateEnvRunPayload(env=env_info, recreate=False)
188+
189+
result = await run_handler(
190+
UvCreateEnvHandler,
191+
payload,
192+
action_cls=create_env_action.CreateEnvAction,
193+
project_dir=tmp_path,
194+
service_overrides=_service_overrides(command_runner),
195+
)
196+
197+
assert result is not None
198+
assert result.errors == []
199+
venv_commands = [cmd for cmd in command_runner.commands if " venv " in cmd]
200+
assert len(venv_commands) == 1
201+
assert "--python" not in venv_commands[0]
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from __future__ import annotations
2+
3+
import dataclasses
4+
import typing
5+
6+
from finecode_extension_api import service
7+
8+
__all__ = ["HandlerInfo", "ActionInfo", "IWorkspaceActionRegistry"]
9+
10+
11+
@dataclasses.dataclass(frozen=True)
12+
class HandlerInfo:
13+
name: str
14+
source: str
15+
env: str
16+
file_loc: str | None
17+
18+
19+
@dataclasses.dataclass(frozen=True)
20+
class ActionInfo:
21+
name: str
22+
source: str
23+
canonical_source: str | None
24+
scope: str
25+
project: str
26+
language: str | None
27+
parent_action_source: str | None
28+
file_loc: str | None
29+
handlers: list[HandlerInfo]
30+
31+
32+
class IWorkspaceActionRegistry(service.Service, typing.Protocol):
33+
"""Read-only access to the workspace action and handler registry."""
34+
35+
async def list_actions(self) -> list[ActionInfo]: ...

0 commit comments

Comments
 (0)