Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/reference/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion src/specify_cli/workflows/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
59 changes: 59 additions & 0 deletions tests/specify_cli/workflows/test_command_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
46 changes: 46 additions & 0 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading