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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 48 additions & 23 deletions src/determa/state/definition.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,31 @@
"""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

from . import yaml12
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:
Expand All @@ -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(
[
Expand Down
77 changes: 77 additions & 0 deletions tests/test_library_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

from __future__ import annotations

import pytest

import determa.state as ds

GATE = """\
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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])
Loading