From 2d654fd943c786d48c80e06c8e13af592b1bd26c Mon Sep 17 00:00:00 2001 From: stroblme Date: Sat, 29 Aug 2026 13:44:43 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_019Hra4ndWMCLU5F3KjUuVAc --- backend/fluksio/sdk/cli.py | 33 ++++++++++++++++++++++++++------- backend/tests/test_cli.py | 18 ++++++++++++++++++ 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/backend/fluksio/sdk/cli.py b/backend/fluksio/sdk/cli.py index e203360..9afcd49 100644 --- a/backend/fluksio/sdk/cli.py +++ b/backend/fluksio/sdk/cli.py @@ -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 `-- `; 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 diff --git a/backend/tests/test_cli.py b/backend/tests/test_cli.py index 4cad80f..9ef4c05 100644 --- a/backend/tests/test_cli.py +++ b/backend/tests/test_cli.py @@ -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: