diff --git a/README.md b/README.md index 6bdc34f..9bed56d 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,34 @@ snaps = host.snapshot_all() # persist / round-trip (§8) host.restore_all(snaps) ``` +`load_definitions` also accepts a **native mapping** (or a list of them for a +multi-document machine) instead of YAML text, so a host can build machines in code +without serializing — through the same `validate()` path: + +```python +import determa.state as ds + +gate = { + "id": "gate", + "events": {"coin": {"payload": {"amount": {"type": "int", "required": True}}}}, + "top": { + "esvs": {"fare": {"type": "int", "external": True}}, + "initial": {"transition_to": "locked"}, + "states": { + "locked": {"on_events": {"coin": {"transition_to": "unlocked", + "guard": "event.payload.amount >= fare"}}}, + "unlocked": {"on_events": {"push": {"transition_to": "locked"}}}, + }, + }, +} + +defs = ds.load_definitions(gate) # dict, not a YAML string +host = ds.Host() +host.register_all(defs) +inst = host.create_root(host.machines["gate"], "g1", external={"fare": 50}) +host.run_to_quiescence() +``` + The public surface is everything exported from the `determa.state` package (`determa.state.__all__`): `Host`, `Instance`, `Definition`, `Machine`, `Status`, `Event`, `load_definitions` / `load_definition`, `validate` / `collect_errors`, and the diff --git a/src/determa/state/definition.py b/src/determa/state/definition.py index 6550a1e..bdf7785 100644 --- a/src/determa/state/definition.py +++ b/src/determa/state/definition.py @@ -1,14 +1,20 @@ -"""Loading machine definitions from YAML text (SPEC §4). +"""Loading machine definitions from YAML text or native mappings (SPEC §2, §4). A machine file is one or more ``---``-separated documents; the first is the root definition (SPEC §9). Each document is validated (structure + reserved names) before a :class:`Definition` is produced. Later build steps resolve the raw document into a navigable state model; step 1 keeps the validated raw mapping as the single source of structure. + +Hosts that build machines in code can pass a native mapping (``dict``) — or a +sequence of them for a multi-document machine — instead of serializing to a +YAML string. The same ``validate()`` path runs either way, so a hand-built +machine is held to the same contract as a file-loaded one. """ from __future__ import annotations +from collections.abc import Mapping, Sequence from dataclasses import dataclass from typing import Any, cast @@ -16,6 +22,10 @@ from .errors import ErrorRecord, ValidationError from .validator import validate +#: A machine definition as YAML text, a single mapping, or a sequence of mappings +#: (one per ``---`` document; the first is the root, SPEC §9). +DefinitionSource = str | Mapping[str, Any] | Sequence[Mapping[str, Any]] + @dataclass(frozen=True) class Definition: @@ -32,32 +42,47 @@ def top(self) -> dict[str, Any]: return cast(dict[str, Any], self.raw["top"]) -def load_definitions(text: str) -> list[Definition]: - """Parse and validate every document in a (possibly multi-doc) machine file.""" - docs = yaml12.load_all(text) +def _doc_to_definition(doc: Any, index: int) -> Definition: + """Validate one document (mapping) and wrap it as a :class:`Definition`.""" + if not isinstance(doc, Mapping): + raise ValidationError( + [ErrorRecord(path=f"doc[{index}]", message="a machine definition must be a mapping")] + ) + raw = dict(doc) # normalize any Mapping to a plain dict (and defensive copy) + validate(raw) + return Definition( + id=raw["id"], + version=raw.get("version", 1), + format=raw.get("format", 1), + raw=raw, + ) + + +def load_definitions(source: DefinitionSource) -> list[Definition]: + """Parse and validate every document in a machine file or native mapping(s). + + ``source`` is YAML text (``str``), a single native mapping (``dict``), or a + sequence of mappings (multi-document). Each document runs through the same + :func:`validate` path, so building a machine in code is held to the same + contract as loading one from a YAML file. + """ + if isinstance(source, str): + docs: list[Any] = list(yaml12.load_all(source)) + elif isinstance(source, Mapping): + docs = [source] + else: + docs = list(source) if not docs: raise ValidationError([ErrorRecord(path="(root)", message="no document")]) - defs: list[Definition] = [] - for doc in docs: - if not isinstance(doc, dict): - raise ValidationError( - [ErrorRecord(path="(root)", message="a machine definition must be a mapping")] - ) - validate(doc) - defs.append( - Definition( - id=doc["id"], - version=doc.get("version", 1), - format=doc.get("format", 1), - raw=doc, - ) - ) - return defs + return [_doc_to_definition(doc, i) for i, doc in enumerate(docs)] + +def load_definition(source: DefinitionSource) -> Definition: + """Load a single-definition machine (from text or a native mapping). -def load_definition(text: str) -> Definition: - """Load a single-definition machine file (error if more than one document).""" - defs = load_definitions(text) + Errors if the source carries more than one document. + """ + defs = load_definitions(source) if len(defs) != 1: raise ValidationError( [ diff --git a/tests/test_library_api.py b/tests/test_library_api.py index f10c175..a42aa73 100644 --- a/tests/test_library_api.py +++ b/tests/test_library_api.py @@ -7,6 +7,8 @@ from __future__ import annotations +import pytest + import determa.state as ds GATE = """\ @@ -46,6 +48,31 @@ b: {} """ +# The GATE machine above, built as a native mapping — no YAML string serialized. +# Hosts can construct machines in code this way (same validate() path). +GATE_DICT = { + "id": "gate", + "events": { + "coin": {"payload": {"amount": {"type": "int", "required": True}}}, + "push": {}, + }, + "top": { + "esvs": {"fare": {"type": "int", "external": True}}, + "initial": {"transition_to": "locked"}, + "states": { + "locked": { + "on_events": { + "coin": { + "transition_to": "unlocked", + "guard": "event.payload.amount >= fare", + } + } + }, + "unlocked": {"on_events": {"push": {"transition_to": "locked"}}}, + }, + }, +} + def test_minimum_capability_set_via_public_api() -> None: # 1. load + validate a definition (raises ValidationError if invalid). @@ -133,3 +160,53 @@ def test_meta_is_validation_only_model_data_not_runtime_state() -> None: assert host.deliver("m1", "go") is True host.run_to_quiescence() assert inst.active_leaf_names() == ["b"] + + +def test_build_a_machine_in_code_from_a_mapping() -> None: + # load_definitions accepts a native dict (not just YAML text); the same + # validate() path runs, so the machine is held to the same contract. + defs = ds.load_definitions(GATE_DICT) + assert len(defs) == 1 + assert defs[0].id == "gate" + assert ds.collect_errors(defs[0].raw) == [] + + # registered + driven end-to-end with no YAML string ever involved. + host = ds.Host() + host.register_all(defs) + inst = host.create_root(host.machines["gate"], "g1", external={"fare": 50}) + host.run_to_quiescence() + assert inst.active_leaf_names() == ["locked"] + + assert host.deliver("g1", "coin", {"amount": 100}) is True + host.run_to_quiescence() + assert inst.active_leaf_names() == ["unlocked"] + + +def test_load_definition_singular_accepts_a_mapping() -> None: + single = ds.load_definition(GATE_DICT) + assert single.id == "gate" + + +def test_multi_document_machine_from_a_list_of_mappings() -> None: + child = { + "id": "child", + "top": {"initial": {"transition_to": "on"}, "states": {"on": {}}}, + } + root = { + "id": "root", + "top": {"initial": {"transition_to": "idle"}, "states": {"idle": {}}}, + } + defs = ds.load_definitions([root, child]) + assert [d.id for d in defs] == ["root", "child"] + + +def test_native_mapping_runs_through_the_same_validation() -> None: + # a structurally invalid mapping is rejected just like an invalid YAML file. + bad = {"id": "x"} # missing required "top" + with pytest.raises(ds.ValidationError): + ds.load_definitions(bad) + + # a non-mapping document is rejected. + ok = {"id": "ok", "top": {"initial": {"transition_to": "a"}, "states": {"a": {}}}} + with pytest.raises(ds.ValidationError): + ds.load_definitions([ok, 42])