diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md index 8b95c6918e..a24b700a2d 100644 --- a/docs/reference/workflows.md +++ b/docs/reference/workflows.md @@ -21,6 +21,12 @@ Example: specify workflow run speckit -i spec="Build a kanban board with drag-and-drop task management" ``` +Inputs declared as `type: number` preserve integer values and integer strings +exactly, including 64-bit identifiers and nanosecond timestamps above `2**53`. +Decimal and exponent strings continue to use floating-point conversion; +whole-valued results are stored as integers. Booleans, non-numeric values, and +non-finite numbers are rejected. + With `--json`, a single machine-readable object is printed instead of formatted text (the default output is unchanged when the flag is omitted): ```bash diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py index 662d0d87b2..c29a68dbaf 100644 --- a/src/specify_cli/workflows/engine.py +++ b/src/specify_cli/workflows/engine.py @@ -1443,7 +1443,15 @@ def _coerce_input( msg = f"Input {name!r} expected a number, got {value!r}." raise ValueError(msg) try: - value = float(value) + # Preserve integers (including CLI strings) before float can + # round identifiers or timestamps above 2**53. + if isinstance(value, (str, int)): + try: + value = int(value) + except ValueError: + value = float(value) + else: + value = float(value) if value == int(value): value = int(value) except (ValueError, TypeError, OverflowError): diff --git a/tests/specify_cli/workflows/test_command_run.py b/tests/specify_cli/workflows/test_command_run.py index aac8ac6230..316af276b5 100644 --- a/tests/specify_cli/workflows/test_command_run.py +++ b/tests/specify_cli/workflows/test_command_run.py @@ -13,6 +13,65 @@ from tests.specify_cli.workflows.helpers import force_gate_stdin as _force_gate_stdin +class TestWorkflowRunNumberInputs: + @pytest.mark.parametrize("source", ["cli", "default", "enum-default"]) + def test_run_preserves_integer_input_in_file_and_state( + self, tmp_path, monkeypatch, source + ): + from typer.testing import CliRunner + + from specify_cli import app + + number = 1792234567890123456 + input_definition = {"type": "number"} + arguments = [] + if source == "cli": + input_definition["required"] = True + arguments = ["--input", f"record_id={number}"] + else: + input_definition["default"] = number + if source == "enum-default": + input_definition["enum"] = [number] + workflow = tmp_path / "workflow.yml" + workflow.write_text( + yaml.safe_dump( + { + "schema_version": "1.0", + "workflow": { + "id": "number-input", + "name": "Number input", + "version": "1.0.0", + }, + "inputs": {"record_id": input_definition}, + "steps": [ + { + "id": "write", + "type": "shell", + "run": "echo {{ inputs.record_id }} > record-id.txt", + } + ], + } + ), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke( + app, ["workflow", "run", str(workflow), "--json", *arguments] + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.stdout) + assert payload["status"] == "completed" + assert (tmp_path / "record-id.txt").read_text().strip() == str(number) + state_path = ( + tmp_path / ".specify" / "workflows" / "runs" / payload["run_id"] / "state.json" + ) + state = json.loads(state_path.read_text()) + assert state["inputs"]["record_id"] == number + assert isinstance(state["inputs"]["record_id"], int) + + class TestWorkflowRunGateOutcomeJson: """CLI-level tests: the --json payload surfaces gate pauses.""" diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 04298dc640..410faa13d8 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -7072,6 +7072,52 @@ def test_validate_workflow_rejects_bool_default_for_number_type(self): errors = validate_workflow(definition) assert any("invalid default" in e for e in errors), errors + @pytest.mark.parametrize( + "number", [2**53 + 1, 1792234567890123456, 2**63 - 1, -(2**63 - 1)] + ) + @pytest.mark.parametrize("as_string", [False, True]) + def test_number_input_preserves_integer_precision(self, number, as_string): + from specify_cli.workflows.engine import WorkflowEngine + + value = str(number) if as_string else number + resolved = WorkflowEngine._coerce_input("record_id", value, {"type": "number"}) + + assert resolved == number + assert isinstance(resolved, int) + + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("0", 0), (" +42 ", 42), ("-42", -42), + ("3.5", 3.5), ("5.0", 5), ("1e3", 1000), + ], + ) + def test_number_input_retains_existing_numeric_forms(self, value, expected): + from specify_cli.workflows.engine import WorkflowEngine + + resolved = WorkflowEngine._coerce_input("count", value, {"type": "number"}) + + assert resolved == expected + assert type(resolved) is type(expected) + + @pytest.mark.parametrize( + "value", [True, False, None, [], "invalid", "nan", "inf", "-inf"] + ) + def test_number_input_rejects_non_numbers_and_non_finite_values(self, value): + from specify_cli.workflows.engine import WorkflowEngine + + with pytest.raises(ValueError, match="expected a number"): + WorkflowEngine._coerce_input("count", value, {"type": "number"}) + + def test_number_input_checks_enum_without_rounding(self): + from specify_cli.workflows.engine import WorkflowEngine + + number = 2**53 + 1 + definition = {"type": "number", "enum": [number]} + assert WorkflowEngine._coerce_input("record_id", str(number), definition) == number + with pytest.raises(ValueError, match="not in allowed values"): + WorkflowEngine._coerce_input("record_id", str(number - 1), definition) + def test_coerce_number_input_rejects_infinity_cleanly(self): """An infinite float must surface as a clean ValueError (like NaN), not let ``int(inf)``'s OverflowError escape: ``int()`` of an infinity raises