Refuse an unknown run input by name before reading its value

`fluksio run --param lr=0.002` died with a bare JSONDecodeError: `--param`
is not a `run` flag, so it became an input named `param` whose value
`lr=0.002` was json-decoded. The name check ran after the coercion, so the
decode error always won. Names are now checked first, and a coercion error
is a SyncError naming the input and its type, the way `_ask_params` has
always done it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Hra4ndWMCLU5F3KjUuVAc
This commit is contained in:
2026-08-29 13:44:43 +02:00
co-authored by Claude Opus 5
parent 7e506b26c0
commit 2d654fd943
2 changed files with 44 additions and 7 deletions
+26 -7
View File
@@ -439,33 +439,52 @@ def _ask_params(definition: dict[str, Any]) -> dict[str, Any]:
def _params(definition: dict[str, Any], rest: list[str]) -> dict[str, Any]:
"""Turn `--lr 0.05` into a typed parameter, using the flow's own inputs."""
types = _input_types(definition)
params: dict[str, Any] = {}
# Collected as written and typed afterwards, so a name this flow does not
# have is refused by name rather than by whatever its value failed to parse
# as: `--param lr=0.002` is a sweep's spelling, and said so it reads as an
# input called `param` holding unparseable json.
raw: dict[str, str | bool] = {}
pending: str | None = None
for token in rest:
if token.startswith("--"):
if pending is not None:
# A flag with no value is a flag: `--resume` means true.
params[pending] = True
raw[pending] = True
name, sep, value = token[2:].partition("=")
# Only the name is spelled with dashes; a value may hold one, and
# `--lr=1e-4` is the case that says so.
pending = name.replace("-", "_")
if sep:
params[pending] = _coerce(value, types.get(pending, "json"))
raw[pending] = value
pending = None
continue
if pending is None:
raise SyncError(f"unexpected argument '{token}'")
params[pending] = _coerce(token, types.get(pending, "json"))
raw[pending] = token
pending = None
if pending is not None:
params[pending] = True
unknown = sorted(set(params) - set(types))
raw[pending] = True
unknown = sorted(set(raw) - set(types))
if unknown:
hint = ""
if unknown[0] == "param":
hint = (
" — one value is `--<name> <value>`; several is a sweep: "
"`fluksio sweep --param name=v1,v2`"
)
raise SyncError(
f"'{unknown[0]}' is not an input of this flow (it takes "
f"{', '.join(sorted(types)) or 'none'})"
f"{', '.join(sorted(types)) or 'none'}){hint}"
)
params: dict[str, Any] = {}
for name, value in raw.items():
if value is True:
params[name] = True
continue
try:
params[name] = _coerce(value, types[name])
except ValueError as exc:
raise SyncError(f"'{name}' takes {types[name]}: {exc}") from exc
return params
+18
View File
@@ -88,6 +88,24 @@ def test_run_arguments_are_typed_by_the_flow_they_are_for() -> None:
_params(definition, ["--nonesuch", "1"])
def test_a_sweeps_param_spelling_is_refused_by_name() -> None:
"""`run --param lr=0.002` is a name this flow has not got, and says so."""
import pytest
from fluksio.sdk import SyncError
from fluksio.sdk.cli import _params
definition = {"inputs": [{"spec": {"name": "lr", "dtype": "float"}}]}
# Not a JSONDecodeError over `lr=0.002`, which is what reading the value
# before the name used to give.
with pytest.raises(SyncError, match="sweep --param"):
_params(definition, ["--param", "lr=0.002"])
with pytest.raises(SyncError, match="'lr' takes float"):
_params(definition, ["--lr", "fast"])
def test_serve_uses_the_installation_the_directory_belongs_to(
tmp_path: Path, monkeypatch
) -> None: