diff --git a/.gitignore b/.gitignore index 22eb649a..ba144f94 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,6 @@ target/ docs/auto_examples/sg_execution_times.* docs/auto_examples/*.pickle docs/sg_execution_times.rst + +# Temporary files +tmp/ diff --git a/README.md b/README.md index 43f141a6..b7393b09 100644 --- a/README.md +++ b/README.md @@ -83,9 +83,10 @@ Generate a diagram or get a text representation with f-strings: >>> print(f"{sm:md}") | State | Event | Guard | Target | | ------ | ----- | ----- | ------ | -| Green | cycle | | Yellow | -| Yellow | cycle | | Red | -| Red | cycle | | Green | +| Green | Cycle | | Yellow | +| Yellow | Cycle | | Red | +| Red | Cycle | | Green | + ``` diff --git a/docs/diagram.md b/docs/diagram.md index ff3962df..d48443d3 100644 --- a/docs/diagram.md +++ b/docs/diagram.md @@ -126,9 +126,9 @@ stateDiagram-v2 state "Yellow" as yellow state "Red" as red [*] --> green - green --> yellow : cycle - yellow --> red : cycle - red --> green : cycle + green --> yellow : Cycle + yellow --> red : Cycle + red --> green : Cycle classDef active fill:#40E0D0,stroke:#333 green:::active @@ -137,9 +137,9 @@ stateDiagram-v2 >>> print(f"{sm:md}") | State | Event | Guard | Target | | ------ | ----- | ----- | ------ | -| Green | cycle | | Yellow | -| Yellow | cycle | | Red | -| Red | cycle | | Green | +| Green | Cycle | | Yellow | +| Yellow | Cycle | | Red | +| Red | Cycle | | Green | ``` @@ -154,9 +154,9 @@ stateDiagram-v2 state "Yellow" as yellow state "Red" as red [*] --> green - green --> yellow : cycle - yellow --> red : cycle - red --> green : cycle + green --> yellow : Cycle + yellow --> red : Cycle + red --> green : Cycle ``` @@ -191,9 +191,9 @@ stateDiagram-v2 state "Yellow" as yellow state "Red" as red [*] --> green - green --> yellow : cycle - yellow --> red : cycle - red --> green : cycle + green --> yellow : Cycle + yellow --> red : Cycle + red --> green : Cycle >>> formatter.supported_formats() @@ -294,9 +294,9 @@ A traffic light. | State | Event | Guard | Target | | ------ | ----- | ----- | ------ | -| Green | cycle | | Yellow | -| Yellow | cycle | | Red | -| Red | cycle | | Green | +| Green | Cycle | | Yellow | +| Yellow | Cycle | | Red | +| Red | Cycle | | Green | diff --git a/docs/events.md b/docs/events.md index 6db4c195..57351820 100644 --- a/docs/events.md +++ b/docs/events.md @@ -80,21 +80,31 @@ Every event has two string properties: - **`id`** — the programmatic identifier, derived from the class attribute name. Use this in `send()`, guards, and comparisons. -- **`name`** — a human-readable label for display purposes. Defaults to the `id` - when not explicitly set. +- **`name`** — a human-readable label for display purposes. Auto-generated from + the `id` by replacing `_` and `.` with spaces and capitalizing the first word. + You can override the automatic name by passing `name=` explicitly when + declaring the event: ```py >>> TrafficLight.cycle.id 'cycle' >>> TrafficLight.cycle.name -'cycle' +'Cycle' + +>>> class Example(StateChart): +... on = State(initial=True) +... off = State(final=True) +... shut_down = Event(on.to(off), name="Shut the system down") + +>>> Example.shut_down.name +'Shut the system down' ``` ```{tip} Always use `event.id` for programmatic checks. The `name` property is intended -for UI display and may change format in future versions. +for UI display and may differ from the `id`. ``` diff --git a/docs/images/readme_trafficlightmachine.png b/docs/images/readme_trafficlightmachine.png index 5685ec10..f5179c05 100644 Binary files a/docs/images/readme_trafficlightmachine.png and b/docs/images/readme_trafficlightmachine.png differ diff --git a/docs/releases/3.1.0.md b/docs/releases/3.1.0.md index e566ec62..ca9814ab 100644 --- a/docs/releases/3.1.0.md +++ b/docs/releases/3.1.0.md @@ -158,4 +158,11 @@ machine instance concurrently. This is now documented in the interpreted as the event `id`, leaving the extra transitions eventless (auto-firing). [#588](https://github.com/fgmacedo/python-statemachine/pull/588). +- `Event.name` is now auto-humanized from the `id` (e.g., `cycle` → `Cycle`, + `pick_up` → `Pick up`). Diagrams, Mermaid output, and text tables all display + the human-readable name. Explicit `name=` values are preserved. The same + `humanize_id()` helper is now shared by `Event` and `State`. + [#601](https://github.com/fgmacedo/python-statemachine/pull/601), + fixes [#600](https://github.com/fgmacedo/python-statemachine/issues/600). + ## Misc in 3.1.0 diff --git a/docs/tutorial.md b/docs/tutorial.md index d49526d7..3bfe1489 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -375,9 +375,9 @@ You can also get text representations of any state machine using Python's built- >>> print(f"{CoffeeOrder:md}") | State | Event | Guard | Target | | --------- | ------- | ----- | --------- | -| Pending | start | | Preparing | -| Preparing | finish | | Ready | -| Ready | pick_up | | Picked up | +| Pending | Start | | Preparing | +| Preparing | Finish | | Ready | +| Ready | Pick up | | Picked up | ``` @@ -394,9 +394,9 @@ stateDiagram-v2 state "Picked up" as picked_up [*] --> pending picked_up --> [*] - pending --> preparing : start - preparing --> ready : finish - ready --> picked_up : pick_up + pending --> preparing : Start + preparing --> ready : Finish + ready --> picked_up : Pick up ``` diff --git a/statemachine/contrib/diagram/extract.py b/statemachine/contrib/diagram/extract.py index 15a1f2da..45caf9e5 100644 --- a/statemachine/contrib/diagram/extract.py +++ b/statemachine/contrib/diagram/extract.py @@ -116,6 +116,7 @@ def _format_event_names(transition: "Transition") -> str: all_ids = {str(e) for e in events} + seen_ids: Set[str] = set() display: List[str] = [] for event in events: eid = str(event) @@ -123,8 +124,9 @@ def _format_event_names(transition: "Transition") -> str: # form ("done_invoke_X") is also registered on this transition. if "." in eid and eid.replace(".", "_") in all_ids: continue - if eid not in display: # pragma: no branch - display.append(eid) + if eid not in seen_ids: # pragma: no branch + seen_ids.add(eid) + display.append(event.name if event.name else eid) return " ".join(display) diff --git a/statemachine/event.py b/statemachine/event.py index 25a1de6c..f5986340 100644 --- a/statemachine/event.py +++ b/statemachine/event.py @@ -9,6 +9,7 @@ from .exceptions import InvalidDefinition from .i18n import _ from .transition_mixin import AddCallbacksMixin +from .utils import humanize_id if TYPE_CHECKING: from .statemachine import StateChart @@ -107,7 +108,7 @@ def __new__( if name: instance.name = name elif _has_real_id: - instance.name = str(id).replace("_", " ").capitalize() + instance.name = humanize_id(id) else: instance.name = "" if transitions: diff --git a/statemachine/events.py b/statemachine/events.py index 2fe2be01..16cd681e 100644 --- a/statemachine/events.py +++ b/statemachine/events.py @@ -30,7 +30,7 @@ def add(self, events): if isinstance(event, Event): self._items.append(event) else: - self._items.append(Event(id=event, name=event)) + self._items.append(Event(id=event)) return self diff --git a/statemachine/factory.py b/statemachine/factory.py index c29825f7..2e9b1c3b 100644 --- a/statemachine/factory.py +++ b/statemachine/factory.py @@ -310,7 +310,7 @@ def add_from_attributes(cls, attrs): # noqa: C901 cls.add_state(key, value) elif isinstance(value, (Transition, TransitionList)): event_id = _expand_event_id(key) - cls.add_event(event=Event(transitions=value, id=event_id, name=key)) + cls.add_event(event=Event(transitions=value, id=event_id)) elif isinstance(value, (Event,)): if value._has_real_id: event_id = value.id @@ -338,7 +338,7 @@ def _add_unbounded_callback(cls, attr_name, func): # machinery that is stored at ``func.attr_name`` setattr(cls, func.attr_name, func) if func.is_event: - cls.add_event(event=Event(func._transitions, id=attr_name, name=attr_name)) + cls.add_event(event=Event(func._transitions, id=attr_name)) def add_state(cls, id, state: State): state._set_id(id) diff --git a/statemachine/io/scxml/actions.py b/statemachine/io/scxml/actions.py index c4e46cca..b737cd2e 100644 --- a/statemachine/io/scxml/actions.py +++ b/statemachine/io/scxml/actions.py @@ -382,7 +382,7 @@ def create_raise_action_callable(action: RaiseAction) -> Callable: def raise_action(*args, **kwargs): machine: StateChart = kwargs["machine"] - Event(id=action.event, name=action.event, internal=True, _sm=machine).put() + Event(id=action.event, internal=True, _sm=machine).put() raise_action.action = action # type: ignore[attr-defined] return raise_action @@ -492,7 +492,7 @@ def send_action(*args, **kwargs): # noqa: C901 continue params_values[param.name] = _eval(param.expr, **kwargs) - Event(id=event, name=event, delay=delay, internal=internal, _sm=machine).put( + Event(id=event, delay=delay, internal=internal, _sm=machine).put( *content, send_id=send_id, **params_values, diff --git a/statemachine/state.py b/statemachine/state.py index e8aa572a..1d77bc9b 100644 --- a/statemachine/state.py +++ b/statemachine/state.py @@ -15,6 +15,7 @@ from .invoke import normalize_invoke_callbacks from .transition import Transition from .transition_list import TransitionList +from .utils import humanize_id if TYPE_CHECKING: from .statemachine import StateChart @@ -116,10 +117,8 @@ class State: Args: name: A human-readable representation of the state. Default is derived - from the name of the variable assigned to the state machine class. - The name is derived from the id using this logic:: - - name = id.replace("_", " ").capitalize() + from the name of the variable assigned to the state machine class, + by replacing ``_`` and ``.`` with spaces and capitalizing the first word. value: A specific value to the storage and retrieval of states. If specified, you can use It to map a more friendly representation to a low-level @@ -302,7 +301,7 @@ def _set_id(self, id: str) -> "State": if self.value is None: self.value = id if not self.name: - self.name = self._id.replace("_", " ").capitalize() + self.name = humanize_id(self._id) self._hash = hash((self.name, self._id)) return self diff --git a/statemachine/utils.py b/statemachine/utils.py index d3661888..ac1ddbb1 100644 --- a/statemachine/utils.py +++ b/statemachine/utils.py @@ -1,7 +1,10 @@ import asyncio +import re import threading from typing import Any +_SEPARATOR_RE = re.compile(r"[_.]") + _cached_loop = threading.local() """Loop that will be used when the SM is running in a synchronous context. One loop per thread.""" @@ -26,6 +29,21 @@ def ensure_iterable(obj): return [obj] +def humanize_id(id: str) -> str: + """Convert a machine identifier to a human-readable name. + + Splits on ``_`` and ``.`` separators and capitalizes the first word. + + >>> humanize_id("go") + 'Go' + >>> humanize_id("done_state_parent") + 'Done state parent' + >>> humanize_id("error.execution") + 'Error execution' + """ + return _SEPARATOR_RE.sub(" ", id).strip().capitalize() + + def run_async_from_sync(coroutine: "Any") -> "Any": """ Compatibility layer to run an async coroutine from a synchronous context. diff --git a/tests/test_callbacks.py b/tests/test_callbacks.py index 048c3477..e593c75b 100644 --- a/tests/test_callbacks.py +++ b/tests/test_callbacks.py @@ -320,7 +320,7 @@ def on_start(self): def test_issue_417_cannot_start(self, model_class, sm_class, mock_calls): model = model_class(0) sm = sm_class(model, 0) - with pytest.raises(sm.TransitionNotAllowed, match="Can't start when in Created"): + with pytest.raises(sm.TransitionNotAllowed, match="Can't Start when in Created"): sm.start() mock_calls.assert_not_called() diff --git a/tests/test_contrib_diagram.py b/tests/test_contrib_diagram.py index 2da3cdd8..cb03dc9a 100644 --- a/tests/test_contrib_diagram.py +++ b/tests/test_contrib_diagram.py @@ -12,6 +12,7 @@ from statemachine.contrib.diagram.model import ActionType from statemachine.contrib.diagram.model import StateType from statemachine.contrib.diagram.renderers.dot import DotRenderer +from statemachine.event import Event from statemachine import State from statemachine import StateChart @@ -176,7 +177,7 @@ def test_format_mermaid(self, tmp_path): content = out.read_text() assert "stateDiagram-v2" in content - assert "green --> yellow : cycle" in content + assert "green --> yellow : Cycle" in content def test_format_md(self, tmp_path): out = tmp_path / "sm.md" @@ -192,7 +193,7 @@ def test_format_md(self, tmp_path): content = out.read_text() assert "| State" in content - assert "cycle" in content + assert "Cycle" in content def test_format_rst(self, tmp_path): out = tmp_path / "sm.rst" @@ -208,7 +209,7 @@ def test_format_rst(self, tmp_path): content = out.read_text() assert "+---" in content - assert "cycle" in content + assert "Cycle" in content def test_format_mermaid_stdout(self, capsys): main( @@ -702,8 +703,8 @@ def test_resolve_initial_fallback(self): class TestFormatEventNames: """Tests for _format_event_names — alias filtering for diagram display.""" - def test_simple_event_unchanged(self): - """A plain event with no aliases is returned as-is.""" + def test_simple_event_uses_name(self): + """A plain event displays its human-readable name.""" class SM(StateChart): s1 = State(initial=True) @@ -711,7 +712,7 @@ class SM(StateChart): go = s1.to(s2) t = SM.s1.transitions[0] - assert _format_event_names(t) == "go" + assert _format_event_names(t) == "Go" def test_done_state_alias_filtered(self): """done_state_X registers both underscore and dot forms; only underscore is shown.""" @@ -727,7 +728,7 @@ class parent(State.Compound): t = next(t for t in SM.parent.transitions if t.event and "done_state" in t.event) result = _format_event_names(t) - assert result == "done_state_parent" + assert result == "Done state parent" assert "done.state" not in result def test_done_invoke_alias_filtered(self): @@ -740,7 +741,7 @@ class SM(StateChart): t = SM.s1.transitions[0] result = _format_event_names(t) - assert result == "done_invoke_child" + assert result == "Done invoke child" assert "done.invoke" not in result def test_error_alias_filtered(self): @@ -753,7 +754,7 @@ class SM(StateChart): t = SM.s1.transitions[0] result = _format_event_names(t) - assert result == "error_execution" + assert result == "Error execution" assert "error.execution" not in result def test_multiple_distinct_events_preserved(self): @@ -769,8 +770,8 @@ class SM(StateChart): t = SM.s1.transitions[0] t.add_event("also") result = _format_event_names(t) - assert "go" in result - assert "also" in result + assert "Go" in result + assert "Also" in result def test_eventless_transition_returns_empty(self): """A transition with no events returns an empty string.""" @@ -798,7 +799,22 @@ class SM(StateChart): from statemachine.transition import Transition t = Transition(source=SM.s1, target=SM.s2, event="custom.event") - assert _format_event_names(t) == "custom.event" + assert _format_event_names(t) == "Custom event" + + def test_explicit_event_name_displayed(self): + """An Event with an explicit name= shows the human-readable name.""" + + class SM(StateChart): + active = State(initial=True) + suspended = State(final=True) + + suspend = Event( + active.to(suspended), + name="Human Suspend", + ) + + t = SM.active.transitions[0] + assert _format_event_names(t) == "Human Suspend" class TestDotRendererEdgeCases: @@ -1355,7 +1371,7 @@ def test_format_mermaid_class(self): result = f"{TrafficLightMachine:mermaid}" assert "stateDiagram-v2" in result - assert "green --> yellow : cycle" in result + assert "green --> yellow : Cycle" in result def test_format_md_instance(self): from tests.examples.traffic_light_machine import TrafficLightMachine @@ -1363,7 +1379,7 @@ def test_format_md_instance(self): sm = TrafficLightMachine() result = f"{sm:md}" assert "| State" in result - assert "cycle" in result + assert "Cycle" in result def test_format_md_class(self): from tests.examples.traffic_light_machine import TrafficLightMachine diff --git a/tests/test_events.py b/tests/test_events.py index 4a0cb015..b4ce48ab 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -82,8 +82,8 @@ class StartMachine(StateChart): assert list(StartMachine.events) == ["launch_the_machine"] assert [e.id for e in StartMachine.events] == ["launch_the_machine"] - assert [e.name for e in StartMachine.events] == ["launch_the_machine"] - assert StartMachine.launch_the_machine.name == "launch_the_machine" + assert [e.name for e in StartMachine.events] == ["Launch the machine"] + assert StartMachine.launch_the_machine.name == "Launch the machine" assert str(StartMachine.launch_the_machine) == "launch_the_machine" assert StartMachine.launch_the_machine == StartMachine.launch_the_machine.id @@ -201,8 +201,8 @@ def on_cycle(self, event_data, event: str): assert sm.send("cycle") == "Running cycle from red to green" assert sm.cycle.name == "Loop" assert sm.slow_down.name == "Slow down" - assert sm.stop.name == "stop" - assert sm.go.name == "go" + assert sm.stop.name == "Stop" + assert sm.go.name == "Go" def test_multiple_ids_from_the_same_event_will_be_converted_to_multiple_events(self): class TrafficLightMachine(StateChart): diff --git a/tests/test_mermaid_renderer.py b/tests/test_mermaid_renderer.py index 32cf518b..ea3a7bc8 100644 --- a/tests/test_mermaid_renderer.py +++ b/tests/test_mermaid_renderer.py @@ -247,10 +247,10 @@ class parent(State.Compound, name="Parent"): result = MermaidGraphMachine(SM).get_mermaid() assert 'state "Parent" as parent {' in result assert "[*] --> child1" in result - assert "child1 --> child2 : go" in result + assert "child1 --> child2 : Go" in result assert "child2 --> [*]" in result - assert "start --> parent : enter" in result - assert "parent --> end : finish" in result + assert "start --> parent : Enter" in result + assert "parent --> end : Finish" in result def test_compound_no_duplicate_transitions(self): """Transitions inside compound states must not also appear at top level.""" @@ -265,8 +265,8 @@ class parent(State.Compound, name="Parent"): enter = start.to(parent) result = MermaidGraphMachine(SM).get_mermaid() - # "child1 --> child2 : go" should appear exactly once (inside compound) - assert result.count("child1 --> child2 : go") == 1 + # "child1 --> child2 : Go" should appear exactly once (inside compound) + assert result.count("child1 --> child2 : Go") == 1 def test_parallel_state(self): class SM(StateChart): @@ -310,7 +310,7 @@ class region2(State.Compound, name="Region2"): result = MermaidGraphMachine(SM).get_mermaid() # Inside parallel: compound endpoint redirected to initial child - assert "idle --> working : start" in result + assert "idle --> working : Start" in result assert "idle --> inner" not in result def test_compound_outside_parallel_not_redirected(self): @@ -326,8 +326,8 @@ class parent(State.Compound, name="Parent"): leave = parent.to(end) result = MermaidGraphMachine(SM).get_mermaid() - assert "start --> parent : enter" in result - assert "parent --> end : leave" in result + assert "start --> parent : Enter" in result + assert "parent --> end : Leave" in result def test_nested_compound(self): class SM(StateChart): @@ -689,9 +689,9 @@ def test_traffic_light(self): from tests.examples.traffic_light_machine import TrafficLightMachine result = MermaidGraphMachine(TrafficLightMachine).get_mermaid() - assert "green --> yellow : cycle" in result - assert "yellow --> red : cycle" in result - assert "red --> green : cycle" in result + assert "green --> yellow : Cycle" in result + assert "yellow --> red : Cycle" in result + assert "red --> green : Cycle" in result def test_traffic_light_with_events(self): from tests.examples.traffic_light_machine import TrafficLightMachine diff --git a/tests/test_multiple_destinations.py b/tests/test_multiple_destinations.py index 59f38494..96e63953 100644 --- a/tests/test_multiple_destinations.py +++ b/tests/test_multiple_destinations.py @@ -163,7 +163,7 @@ def on_validate(self, previous_configuration): assert machine.is_terminated - with pytest.raises(exceptions.TransitionNotAllowed, match="Can't validate when in Completed."): + with pytest.raises(exceptions.TransitionNotAllowed, match="Can't Validate when in Completed."): assert machine.validate() diff --git a/tests/test_statemachine.py b/tests/test_statemachine.py index 5bc7f85c..1946a083 100644 --- a/tests/test_statemachine.py +++ b/tests/test_statemachine.py @@ -27,9 +27,9 @@ def test_machine_should_be_at_start_state(campaign_machine): "closed", ] assert [t.name for t in campaign_machine.events] == [ - "add_job", - "produce", - "deliver", + "Add job", + "Produce", + "Deliver", ] assert model.state == "draft" @@ -160,11 +160,11 @@ def test_machine_should_list_allowed_events_in_the_current_state(campaign_machin machine = campaign_machine(model) assert model.state == "draft" - assert [t.name for t in machine.allowed_events] == ["add_job", "produce"] + assert [t.name for t in machine.allowed_events] == ["Add job", "Produce"] machine.produce() assert model.state == "producing" - assert [t.name for t in machine.allowed_events] == ["add_job", "deliver"] + assert [t.name for t in machine.allowed_events] == ["Add job", "Deliver"] deliver = machine.allowed_events[1] diff --git a/tests/test_transition_table.py b/tests/test_transition_table.py index 198cf495..fadf363e 100644 --- a/tests/test_transition_table.py +++ b/tests/test_transition_table.py @@ -157,7 +157,7 @@ def test_traffic_light_md(self): assert "Green" in result assert "Yellow" in result assert "Red" in result - assert "cycle" in result + assert "Cycle" in result def test_traffic_light_rst(self): from tests.examples.traffic_light_machine import TrafficLightMachine @@ -165,7 +165,7 @@ def test_traffic_light_rst(self): ir = extract(TrafficLightMachine) result = TransitionTableRenderer().render(ir, fmt="rst") assert "Green" in result - assert "cycle" in result + assert "Cycle" in result assert "+---" in result def test_compound_state_names(self): diff --git a/tests/test_transitions.py b/tests/test_transitions.py index 4f4f47e4..b71497f0 100644 --- a/tests/test_transitions.py +++ b/tests/test_transitions.py @@ -19,7 +19,7 @@ def test_transition_representation(campaign_machine): def test_list_machine_events(classic_traffic_light_machine): machine = classic_traffic_light_machine() transitions = [t.name for t in machine.events] - assert transitions == ["slowdown", "stop", "go"] + assert transitions == ["Slowdown", "Stop", "Go"] def test_list_state_transitions(classic_traffic_light_machine):