python-statemachine 3.0.0__py3-none-any.whl → 3.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_statemachine-3.0.0.dist-info → python_statemachine-3.1.1.dist-info}/METADATA +15 -8
- python_statemachine-3.1.1.dist-info/RECORD +58 -0
- statemachine/__init__.py +1 -1
- statemachine/configuration.py +170 -0
- statemachine/contrib/diagram/__init__.py +237 -0
- statemachine/contrib/diagram/__main__.py +6 -0
- statemachine/contrib/diagram/extract.py +288 -0
- statemachine/contrib/diagram/formatter.py +137 -0
- statemachine/contrib/diagram/model.py +63 -0
- statemachine/contrib/diagram/renderers/__init__.py +0 -0
- statemachine/contrib/diagram/renderers/dot.py +532 -0
- statemachine/contrib/diagram/renderers/mermaid.py +348 -0
- statemachine/contrib/diagram/renderers/table.py +105 -0
- statemachine/contrib/diagram/sphinx_ext.py +280 -0
- statemachine/engines/async_.py +28 -32
- statemachine/engines/base.py +51 -43
- statemachine/engines/sync.py +7 -12
- statemachine/event.py +11 -1
- statemachine/event_data.py +2 -2
- statemachine/events.py +1 -1
- statemachine/factory.py +39 -2
- statemachine/invoke.py +61 -23
- statemachine/io/scxml/actions.py +5 -4
- statemachine/locale/en/LC_MESSAGES/statemachine.po +54 -27
- statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po +56 -31
- statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po +54 -27
- statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po +54 -29
- statemachine/state.py +38 -91
- statemachine/statemachine.py +63 -80
- statemachine/utils.py +18 -0
- python_statemachine-3.0.0.dist-info/RECORD +0 -48
- statemachine/contrib/diagram.py +0 -370
- {python_statemachine-3.0.0.dist-info → python_statemachine-3.1.1.dist-info}/WHEEL +0 -0
- {python_statemachine-3.0.0.dist-info → python_statemachine-3.1.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-statemachine
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.1.1
|
|
4
4
|
Summary: Python Finite State Machines made easy.
|
|
5
5
|
Project-URL: homepage, https://github.com/fgmacedo/python-statemachine
|
|
6
6
|
Author-email: Fernando Macedo <fgmacedo@gmail.com>
|
|
@@ -23,7 +23,7 @@ Classifier: Topic :: Home Automation
|
|
|
23
23
|
Classifier: Topic :: Software Development :: Libraries
|
|
24
24
|
Requires-Python: >=3.9
|
|
25
25
|
Provides-Extra: diagrams
|
|
26
|
-
Requires-Dist: pydot>=
|
|
26
|
+
Requires-Dist: pydot>=4.0.1; extra == 'diagrams'
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
28
28
|
|
|
29
29
|
# Python StateMachine
|
|
@@ -105,16 +105,23 @@ True
|
|
|
105
105
|
|
|
106
106
|
```
|
|
107
107
|
|
|
108
|
-
Generate a diagram:
|
|
108
|
+
Generate a diagram or get a text representation with f-strings:
|
|
109
109
|
|
|
110
110
|
```py
|
|
111
|
-
>>>
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
111
|
+
>>> print(f"{sm:md}")
|
|
112
|
+
| State | Event | Guard | Target |
|
|
113
|
+
| ------ | ----- | ----- | ------ |
|
|
114
|
+
| Green | Cycle | | Yellow |
|
|
115
|
+
| Yellow | Cycle | | Red |
|
|
116
|
+
| Red | Cycle | | Green |
|
|
117
|
+
<BLANKLINE>
|
|
115
118
|
|
|
116
119
|
```
|
|
117
120
|
|
|
121
|
+
```python
|
|
122
|
+
sm._graph().write_png("traffic_light.png")
|
|
123
|
+
```
|
|
124
|
+
|
|
118
125
|

|
|
119
126
|
|
|
120
127
|
Parameters are injected into callbacks automatically — the library inspects the
|
|
@@ -373,7 +380,7 @@ There's a lot more to explore:
|
|
|
373
380
|
- **`prepare_event`** callback — inject custom data into all callbacks
|
|
374
381
|
- **Observer pattern** — register external listeners to watch events and state changes
|
|
375
382
|
- **Django integration** — auto-discover state machines in Django apps with `MachineMixin`
|
|
376
|
-
- **Diagram generation** —
|
|
383
|
+
- **Diagram generation** — via f-strings (`f"{sm:mermaid}"`), CLI, Sphinx directive, or Jupyter
|
|
377
384
|
- **Dictionary-based definitions** — create state machines from data structures
|
|
378
385
|
- **Internationalization** — error messages in multiple languages
|
|
379
386
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
statemachine/__init__.py,sha256=6QmaIOvUXefWn7Ur1IFrqnM8ehtcwtnPrvRE44ELq4w,445
|
|
2
|
+
statemachine/callbacks.py,sha256=mY_RAl86240vG2C94ij6AdD7CgsIM8mr0wvpSAMAPv0,14644
|
|
3
|
+
statemachine/configuration.py,sha256=5tTx4-_cXnu6apxdvUfhfhDe6zym9BJynqQ-lrIxnls,6018
|
|
4
|
+
statemachine/dispatcher.py,sha256=xvA1Kf1xGQivHTpaGm_HC22vqAmE_tTm9LQwyVLRWLE,7841
|
|
5
|
+
statemachine/event.py,sha256=d_Ky5ph30Os305BceN4x-rftZH-h8G1U5ei34-66lnI,7669
|
|
6
|
+
statemachine/event_data.py,sha256=7_hoE0cp-0uW71Kk3erO3eYM6anTT5X3slGMcehIGIo,3111
|
|
7
|
+
statemachine/events.py,sha256=11GyX8GYpsSwvW0v1b4zHUGhzKRoqNjQYtuDj8NvqOM,1212
|
|
8
|
+
statemachine/exceptions.py,sha256=gXFvDsEtIxKdcGrRG9mwDgXqFQDXfw8v66_VXaQgGr4,1305
|
|
9
|
+
statemachine/factory.py,sha256=HQc2Hn1irg96Rkygn2E21YAxFV5PRYiXYh4tTKVWBbc,14706
|
|
10
|
+
statemachine/graph.py,sha256=r-8L7jXab1_VLtVu-Ig8bpe4Szo-r6WDFTdbfcepyZM,1981
|
|
11
|
+
statemachine/i18n.py,sha256=NLvGseaORmQ0G-V_J8tkjoxh_piWMOm2CI6mBQpLamc,362
|
|
12
|
+
statemachine/invoke.py,sha256=ydYBzP5pi67r9GYmDd6fT169MZkLM4lgpYcbuSyX3Ok,24874
|
|
13
|
+
statemachine/mixins.py,sha256=8qxZZfBwVdFcr3oPFVWHGzgmAubH6VgQus5x3c6VEE0,1706
|
|
14
|
+
statemachine/model.py,sha256=OylI3FjMiHpYyDl9mtK1zEJMeSvemaN4giQDonpc8kI,211
|
|
15
|
+
statemachine/orderedset.py,sha256=PjbegcS7WPJAzC7jQ411_JrFJYEz6mmLRsAZ7aI9BWc,2543
|
|
16
|
+
statemachine/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
+
statemachine/registry.py,sha256=onT8U5QgYlCSJxgxvi_TY1c1oDetHykOwnyiFk5qoN0,756
|
|
18
|
+
statemachine/signature.py,sha256=N8Vt4X3YOA-NB8oidPAbYZxBgqZm6GYAiuHxBWb1l1s,9922
|
|
19
|
+
statemachine/spec_parser.py,sha256=Io0J5D9s-nu2YdgUlr2cSethGfCS0pSLNmxMCXdwMqQ,8307
|
|
20
|
+
statemachine/state.py,sha256=Sb25t3tAyUfUEP9B8FS7wf_kPaCugVnHeGP0OAOqZWo,15441
|
|
21
|
+
statemachine/statemachine.py,sha256=K2f-MwRkvpnq_9vs7NlqeTkCaeX1eepSZl7XPAUF3V4,19977
|
|
22
|
+
statemachine/states.py,sha256=kWo7y95ikXxNmcf_-J2UoMIwd3NaIdxpHMfoHr3cQ_Y,4934
|
|
23
|
+
statemachine/transition.py,sha256=x9hhxIEyQ7tqsvaXkHg1bVO5hMAjemgHfZhyuypHoOY,6936
|
|
24
|
+
statemachine/transition_list.py,sha256=6NAyOGEgoGOzTYiLfPM21CBVI1tPO63ZIwbaZlqOPI4,4376
|
|
25
|
+
statemachine/transition_mixin.py,sha256=XGGd2J4DnFrqIpeej3QFDOuqI0CAiYwnIb8-_9DpemA,2993
|
|
26
|
+
statemachine/utils.py,sha256=taCgrsRDcQQLweyq607AVZyI0NMukRP-HGpO8TiVNgk,1527
|
|
27
|
+
statemachine/contrib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
28
|
+
statemachine/contrib/timeout.py,sha256=VXkdLpxNeUKihkbtLv8XTWdG-meQlSFtxJfcxXgV8Uw,2251
|
|
29
|
+
statemachine/contrib/weighted.py,sha256=jcKkm25df6NnzBzuss1-ewDEasZ_CyME03xgJXrwcso,6866
|
|
30
|
+
statemachine/contrib/diagram/__init__.py,sha256=NaVy7LWly8fTJNxjaXoRlWdYQ7cwbuNQPN0BMW6ATrI,7532
|
|
31
|
+
statemachine/contrib/diagram/__main__.py,sha256=iPpiz09xKqtAjrhONS99OYp6R2dQ6Anbhw1qPIN8ELo,80
|
|
32
|
+
statemachine/contrib/diagram/extract.py,sha256=RmjsTpOkbQxwYS_m5BS1VWisef5vHtTRmmwe_Dq3wMk,9891
|
|
33
|
+
statemachine/contrib/diagram/formatter.py,sha256=SeNKdt5jzKIDNUPHD0xZQ0cb_zsQbwrucoZtMVXL0og,4758
|
|
34
|
+
statemachine/contrib/diagram/model.py,sha256=ZspysRy8WbJn4VDYkjoYe0mtao2dT9i6T005ehW5BxA,1498
|
|
35
|
+
statemachine/contrib/diagram/sphinx_ext.py,sha256=HcEXU-aYz8da02XPkZKpF5HDF0XPBGGg9FCz8MtI1C8,10338
|
|
36
|
+
statemachine/contrib/diagram/renderers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
37
|
+
statemachine/contrib/diagram/renderers/dot.py,sha256=jzKBXJ_m9olnQLXu8SXbFgtdvbFyt5Ukr5uZNjXU0X8,19913
|
|
38
|
+
statemachine/contrib/diagram/renderers/mermaid.py,sha256=jY6cUW9A_P8xLHISrSijx3GlpIl_Gnj3SgayspYq7DI,13526
|
|
39
|
+
statemachine/contrib/diagram/renderers/table.py,sha256=iIdWnVgV7_PY8piSC-WWbEixZSksqXTOujAk6jrO7FQ,3736
|
|
40
|
+
statemachine/engines/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
41
|
+
statemachine/engines/async_.py,sha256=Wtm3GRm2y9gWTevdPkJgnpY7RzZ97HVewWtwgDenoeU,22552
|
|
42
|
+
statemachine/engines/base.py,sha256=bVPYyl_AWWzfjFWiLnDiofdOyb40pojr2qu--C4MHvQ,37993
|
|
43
|
+
statemachine/engines/sync.py,sha256=F_bt4z1KLLcFCP77E9SNWDJMWiMTexE0iOU2XAd_x5A,9153
|
|
44
|
+
statemachine/io/__init__.py,sha256=1J_oVSWrWmx9bLrGU-YoWyO-YYbO6T9Z6vUAnerKe3Q,8739
|
|
45
|
+
statemachine/io/scxml/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
46
|
+
statemachine/io/scxml/actions.py,sha256=M1fAsuf0gJlnpMWXgoxCwpOk2MQSh8wBvtupp38T9n8,22491
|
|
47
|
+
statemachine/io/scxml/invoke.py,sha256=raRStlMBOfxto6KIkCbi3jJ3UQ3QfuT0ezn4B48A670,8443
|
|
48
|
+
statemachine/io/scxml/parser.py,sha256=zggZwu0rHZNWDaJTHD2XC3MrCy_bi4CNHStfWE9NHgU,16582
|
|
49
|
+
statemachine/io/scxml/processor.py,sha256=_hjW4VTuzbPK2q5a7dp2kYMBcbRBZFbjz-wJKSZLI8I,9467
|
|
50
|
+
statemachine/io/scxml/schema.py,sha256=xNpW_nkS2Z_hye5p1WLr8VhuM85Nic2HqRLCnoFN3ss,3927
|
|
51
|
+
statemachine/locale/en/LC_MESSAGES/statemachine.po,sha256=MZpydlIX__YjJZw2-zBEVNjuUqLEt6AYYWfrq8Rff90,5338
|
|
52
|
+
statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po,sha256=LLaDkTU0GZaataEWl35ap1SDgsJkdEr-0_u8gg5q7kI,7948
|
|
53
|
+
statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po,sha256=z-phNrS8soB26B67yz3f6eYB-KpcXX0MZEBWJqsztgg,5814
|
|
54
|
+
statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po,sha256=nxhwoPj23VjI_4O3r3hKjoc3H5rGox7gbdY-wzWFm6A,5369
|
|
55
|
+
python_statemachine-3.1.1.dist-info/METADATA,sha256=EdRvT7EegYFkRrnnT5PR6yPD4oksBzb9112dPFO1a4w,12249
|
|
56
|
+
python_statemachine-3.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
57
|
+
python_statemachine-3.1.1.dist-info/licenses/LICENSE,sha256=zcP7TsJMqaFxuTvLRZPT7nJl3_ppjxR9Z76BE9pL5zc,1074
|
|
58
|
+
python_statemachine-3.1.1.dist-info/RECORD,,
|
statemachine/__init__.py
CHANGED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING
|
|
2
|
+
from typing import Any
|
|
3
|
+
from typing import Dict
|
|
4
|
+
from typing import Mapping
|
|
5
|
+
from typing import MutableSet
|
|
6
|
+
|
|
7
|
+
from .exceptions import InvalidStateValue
|
|
8
|
+
from .i18n import _
|
|
9
|
+
from .orderedset import OrderedSet
|
|
10
|
+
|
|
11
|
+
_SENTINEL = object()
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .state import State
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Configuration:
|
|
18
|
+
"""Encapsulates the dual representation of the active state configuration.
|
|
19
|
+
|
|
20
|
+
Internally, ``current_state_value`` is either a scalar (single active state)
|
|
21
|
+
or an ``OrderedSet`` (parallel regions). This class hides that detail behind
|
|
22
|
+
a uniform interface for reading, mutating, and caching the resolved
|
|
23
|
+
``OrderedSet[State]``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
__slots__ = (
|
|
27
|
+
"_instance_states",
|
|
28
|
+
"_model",
|
|
29
|
+
"_state_field",
|
|
30
|
+
"_states_map",
|
|
31
|
+
"_cached",
|
|
32
|
+
"_cached_value",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
instance_states: "Mapping[str, State]",
|
|
38
|
+
model: Any,
|
|
39
|
+
state_field: str,
|
|
40
|
+
states_map: "Dict[Any, State]",
|
|
41
|
+
):
|
|
42
|
+
self._instance_states = instance_states
|
|
43
|
+
self._model = model
|
|
44
|
+
self._state_field = state_field
|
|
45
|
+
self._states_map = states_map
|
|
46
|
+
self._cached: "OrderedSet[State] | None" = None
|
|
47
|
+
self._cached_value: Any = _SENTINEL
|
|
48
|
+
|
|
49
|
+
# -- Raw value (persisted on the model) ------------------------------------
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def value(self) -> Any:
|
|
53
|
+
"""The raw state value stored on the model (scalar or ``OrderedSet``)."""
|
|
54
|
+
return getattr(self._model, self._state_field, None)
|
|
55
|
+
|
|
56
|
+
@value.setter
|
|
57
|
+
def value(self, val: Any):
|
|
58
|
+
if val is None:
|
|
59
|
+
self._write_to_model(OrderedSet())
|
|
60
|
+
elif isinstance(val, MutableSet):
|
|
61
|
+
self._write_to_model(OrderedSet(val) if not isinstance(val, OrderedSet) else val)
|
|
62
|
+
else:
|
|
63
|
+
self._write_to_model(OrderedSet([val]))
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def values(self) -> OrderedSet[Any]:
|
|
67
|
+
"""The set of raw state values currently active."""
|
|
68
|
+
return self._read_from_model()
|
|
69
|
+
|
|
70
|
+
# -- Resolved states -------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def states(self) -> "OrderedSet[State]":
|
|
74
|
+
"""The set of currently active :class:`State` instances (cached)."""
|
|
75
|
+
raw = self.value
|
|
76
|
+
# Snapshot the cache fields locally — another thread may call
|
|
77
|
+
# ``_invalidate()`` between the freshness check and the return,
|
|
78
|
+
# so reading ``self._cached`` twice would risk returning ``None``.
|
|
79
|
+
cached = self._cached
|
|
80
|
+
cached_value = self._cached_value
|
|
81
|
+
if cached is not None and cached_value is raw:
|
|
82
|
+
return cached
|
|
83
|
+
if raw is None:
|
|
84
|
+
return OrderedSet()
|
|
85
|
+
|
|
86
|
+
# Normalize inline (avoid second getattr via _read_from_model)
|
|
87
|
+
values = raw if isinstance(raw, MutableSet) else (raw,)
|
|
88
|
+
result = OrderedSet(self._instance_states[self._states_map[v].id] for v in values)
|
|
89
|
+
self._cached = result
|
|
90
|
+
self._cached_value = raw
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
@states.setter
|
|
94
|
+
def states(self, new_configuration: "OrderedSet[State]"):
|
|
95
|
+
self._write_to_model(OrderedSet(s.value for s in new_configuration))
|
|
96
|
+
|
|
97
|
+
# -- Incremental mutation (used by the engine) -----------------------------
|
|
98
|
+
|
|
99
|
+
def add(self, state: "State"):
|
|
100
|
+
"""Add *state* to the configuration (copy-on-write for thread safety)."""
|
|
101
|
+
# Copy so we never mutate the OrderedSet still held by concurrent
|
|
102
|
+
# readers or by the cache identity check. ``_read_from_model`` may
|
|
103
|
+
# return the same ref stored on the model.
|
|
104
|
+
values = OrderedSet(self._read_from_model())
|
|
105
|
+
values.add(state.value)
|
|
106
|
+
self._write_to_model(values)
|
|
107
|
+
|
|
108
|
+
def discard(self, state: "State"):
|
|
109
|
+
"""Remove *state* from the configuration (copy-on-write for thread safety)."""
|
|
110
|
+
values = OrderedSet(self._read_from_model())
|
|
111
|
+
values.discard(state.value)
|
|
112
|
+
self._write_to_model(values)
|
|
113
|
+
|
|
114
|
+
# -- Deprecated v2 compat --------------------------------------------------
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def current_state(self) -> "State | OrderedSet[State]":
|
|
118
|
+
"""Resolve the current state with validation.
|
|
119
|
+
|
|
120
|
+
Unlike ``states`` (which returns an empty set for ``None``), this
|
|
121
|
+
raises ``InvalidStateValue`` when the value is ``None`` or not
|
|
122
|
+
found in ``states_map`` — matching the v2 ``current_state`` contract.
|
|
123
|
+
"""
|
|
124
|
+
csv = self.value
|
|
125
|
+
if csv is None:
|
|
126
|
+
raise InvalidStateValue(
|
|
127
|
+
csv,
|
|
128
|
+
_(
|
|
129
|
+
"There's no current state set. In async code, "
|
|
130
|
+
"did you activate the initial state? "
|
|
131
|
+
"(e.g., `await sm.activate_initial_state()`)"
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
try:
|
|
135
|
+
config = self.states
|
|
136
|
+
if len(config) == 1:
|
|
137
|
+
return next(iter(config))
|
|
138
|
+
return config
|
|
139
|
+
except KeyError as err:
|
|
140
|
+
raise InvalidStateValue(csv) from err
|
|
141
|
+
|
|
142
|
+
# -- Internal: model boundary ----------------------------------------------
|
|
143
|
+
|
|
144
|
+
def _read_from_model(self) -> OrderedSet:
|
|
145
|
+
"""Normalize: model value → always ``OrderedSet``."""
|
|
146
|
+
raw = self.value
|
|
147
|
+
if raw is None:
|
|
148
|
+
return OrderedSet()
|
|
149
|
+
if isinstance(raw, OrderedSet):
|
|
150
|
+
return raw
|
|
151
|
+
if isinstance(raw, MutableSet):
|
|
152
|
+
return OrderedSet(raw)
|
|
153
|
+
return OrderedSet([raw])
|
|
154
|
+
|
|
155
|
+
def _write_to_model(self, values: OrderedSet):
|
|
156
|
+
"""Denormalize: ``OrderedSet`` → ``None | scalar | OrderedSet`` for model."""
|
|
157
|
+
self._invalidate()
|
|
158
|
+
if len(values) == 0:
|
|
159
|
+
raw = None
|
|
160
|
+
elif len(values) == 1:
|
|
161
|
+
raw = next(iter(values))
|
|
162
|
+
else:
|
|
163
|
+
raw = values
|
|
164
|
+
if raw is not None and not isinstance(raw, MutableSet) and raw not in self._states_map:
|
|
165
|
+
raise InvalidStateValue(raw)
|
|
166
|
+
setattr(self._model, self._state_field, raw)
|
|
167
|
+
|
|
168
|
+
def _invalidate(self):
|
|
169
|
+
self._cached = None
|
|
170
|
+
self._cached_value = _SENTINEL
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
from urllib.parse import quote
|
|
3
|
+
from urllib.request import urlopen
|
|
4
|
+
|
|
5
|
+
from .extract import extract
|
|
6
|
+
from .formatter import formatter as formatter
|
|
7
|
+
from .renderers.dot import DotRenderer
|
|
8
|
+
from .renderers.dot import DotRendererConfig
|
|
9
|
+
from .renderers.mermaid import MermaidRenderer
|
|
10
|
+
from .renderers.mermaid import MermaidRendererConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DotGraphMachine:
|
|
14
|
+
"""Backwards-compatible facade that uses the extract + render pipeline.
|
|
15
|
+
|
|
16
|
+
Maintains the same public API and class-level customization attributes
|
|
17
|
+
as the original monolithic DotGraphMachine.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
graph_rankdir = "LR"
|
|
21
|
+
"""
|
|
22
|
+
Direction of the graph. Defaults to "LR" (option "TB" for top bottom)
|
|
23
|
+
http://www.graphviz.org/doc/info/attrs.html#d:rankdir
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
font_name = "Helvetica"
|
|
27
|
+
"""Graph font face name"""
|
|
28
|
+
|
|
29
|
+
state_font_size = "10"
|
|
30
|
+
"""State font size"""
|
|
31
|
+
|
|
32
|
+
state_active_penwidth = 2
|
|
33
|
+
"""Active state external line width"""
|
|
34
|
+
|
|
35
|
+
state_active_fillcolor = "turquoise"
|
|
36
|
+
|
|
37
|
+
transition_font_size = "9"
|
|
38
|
+
"""Transition font size"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, machine):
|
|
41
|
+
self.machine = machine
|
|
42
|
+
|
|
43
|
+
def _build_config(self) -> DotRendererConfig:
|
|
44
|
+
return DotRendererConfig(
|
|
45
|
+
graph_rankdir=self.graph_rankdir,
|
|
46
|
+
font_name=self.font_name,
|
|
47
|
+
state_font_size=self.state_font_size,
|
|
48
|
+
state_active_penwidth=self.state_active_penwidth,
|
|
49
|
+
state_active_fillcolor=self.state_active_fillcolor,
|
|
50
|
+
transition_font_size=self.transition_font_size,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def get_graph(self):
|
|
54
|
+
ir = extract(self.machine)
|
|
55
|
+
renderer = DotRenderer(config=self._build_config())
|
|
56
|
+
return renderer.render(ir)
|
|
57
|
+
|
|
58
|
+
def __call__(self):
|
|
59
|
+
return self.get_graph()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class MermaidGraphMachine:
|
|
63
|
+
"""Facade for generating Mermaid stateDiagram-v2 source from a state machine."""
|
|
64
|
+
|
|
65
|
+
direction = "LR"
|
|
66
|
+
active_fill = "#40E0D0"
|
|
67
|
+
active_stroke = "#333"
|
|
68
|
+
|
|
69
|
+
def __init__(self, machine):
|
|
70
|
+
self.machine = machine
|
|
71
|
+
|
|
72
|
+
def _build_config(self) -> MermaidRendererConfig:
|
|
73
|
+
return MermaidRendererConfig(
|
|
74
|
+
direction=self.direction,
|
|
75
|
+
active_fill=self.active_fill,
|
|
76
|
+
active_stroke=self.active_stroke,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def get_mermaid(self) -> str:
|
|
80
|
+
ir = extract(self.machine)
|
|
81
|
+
renderer = MermaidRenderer(config=self._build_config())
|
|
82
|
+
return renderer.render(ir)
|
|
83
|
+
|
|
84
|
+
def __call__(self) -> str:
|
|
85
|
+
return self.get_mermaid()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def quickchart_write_svg(sm, path: str):
|
|
89
|
+
"""
|
|
90
|
+
If the default dependency of GraphViz installed locally doesn't work for you. As an option,
|
|
91
|
+
you can generate the image online from the output of the `dot` language,
|
|
92
|
+
using one of the many services available.
|
|
93
|
+
|
|
94
|
+
To get the **dot** representation of your state machine is as easy as follows:
|
|
95
|
+
|
|
96
|
+
>>> from tests.examples.order_control_machine import OrderControl
|
|
97
|
+
>>> sm = OrderControl()
|
|
98
|
+
>>> print(sm._graph().to_string()) # doctest: +ELLIPSIS
|
|
99
|
+
digraph OrderControl {
|
|
100
|
+
...
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
To give you an example, we included this method that will serialize the dot, request the graph
|
|
104
|
+
to https://quickchart.io, and persist the result locally as an ``.svg`` file.
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
.. warning::
|
|
108
|
+
Quickchart is an external graph service that supports many formats to generate diagrams.
|
|
109
|
+
|
|
110
|
+
By using this method, you should trust http://quickchart.io.
|
|
111
|
+
|
|
112
|
+
Please read https://quickchart.io/documentation/faq/ for more information.
|
|
113
|
+
|
|
114
|
+
>>> quickchart_write_svg(sm, "docs/images/oc_machine_processing.svg") # doctest: +SKIP
|
|
115
|
+
|
|
116
|
+
"""
|
|
117
|
+
dot_representation = sm._graph().to_string()
|
|
118
|
+
|
|
119
|
+
url = f"https://quickchart.io/graphviz?graph={quote(dot_representation)}"
|
|
120
|
+
|
|
121
|
+
response = urlopen(url)
|
|
122
|
+
data = response.read()
|
|
123
|
+
|
|
124
|
+
with open(path, "wb") as f:
|
|
125
|
+
f.write(data)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _find_sm_class(module):
|
|
129
|
+
"""Find the first StateChart subclass defined in a module."""
|
|
130
|
+
import inspect
|
|
131
|
+
|
|
132
|
+
from statemachine.statemachine import StateChart
|
|
133
|
+
|
|
134
|
+
for _name, obj in inspect.getmembers(module, inspect.isclass):
|
|
135
|
+
if (
|
|
136
|
+
issubclass(obj, StateChart)
|
|
137
|
+
and obj is not StateChart
|
|
138
|
+
and obj.__module__ == module.__name__
|
|
139
|
+
):
|
|
140
|
+
return obj
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def import_sm(qualname):
|
|
145
|
+
from statemachine.statemachine import StateChart
|
|
146
|
+
|
|
147
|
+
module_name, class_name = qualname.rsplit(".", 1)
|
|
148
|
+
module = importlib.import_module(module_name)
|
|
149
|
+
smclass = getattr(module, class_name, None)
|
|
150
|
+
if smclass is not None and isinstance(smclass, type) and issubclass(smclass, StateChart):
|
|
151
|
+
return smclass
|
|
152
|
+
|
|
153
|
+
# qualname may be a module path without a class name — try importing
|
|
154
|
+
# the whole path as a module and find the first StateChart subclass.
|
|
155
|
+
try:
|
|
156
|
+
module = importlib.import_module(qualname)
|
|
157
|
+
except ImportError as err:
|
|
158
|
+
raise ValueError(f"{class_name} is not a subclass of StateMachine") from err
|
|
159
|
+
|
|
160
|
+
smclass = _find_sm_class(module)
|
|
161
|
+
if smclass is None:
|
|
162
|
+
raise ValueError(f"No StateMachine subclass found in module {qualname!r}")
|
|
163
|
+
|
|
164
|
+
return smclass
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def write_image(qualname, out, events=None, fmt=None):
|
|
168
|
+
"""
|
|
169
|
+
Given a `qualname`, that is the fully qualified dotted path to a StateMachine
|
|
170
|
+
classes, imports the class and generates a dot graph using the `pydot` lib.
|
|
171
|
+
Writes the graph representation to the filename 'out' that will
|
|
172
|
+
open/create and truncate such file and write on it a representation of
|
|
173
|
+
the graph defined by the statemachine, in the format specified by
|
|
174
|
+
the extension contained in the out path (out.ext).
|
|
175
|
+
|
|
176
|
+
If `events` is provided, the machine is instantiated and each event is sent
|
|
177
|
+
before rendering, so the diagram highlights the current active state.
|
|
178
|
+
|
|
179
|
+
If `fmt` is provided, it overrides the output format (any registered text
|
|
180
|
+
format such as ``"mermaid"``, ``"dot"``, ``"md"``, ``"rst"``).
|
|
181
|
+
Use ``out="-"`` to write to stdout.
|
|
182
|
+
"""
|
|
183
|
+
import sys
|
|
184
|
+
|
|
185
|
+
smclass = import_sm(qualname)
|
|
186
|
+
|
|
187
|
+
if events:
|
|
188
|
+
machine = smclass()
|
|
189
|
+
for event_name in events:
|
|
190
|
+
machine.send(event_name)
|
|
191
|
+
else:
|
|
192
|
+
machine = smclass
|
|
193
|
+
|
|
194
|
+
if fmt is not None:
|
|
195
|
+
text = formatter.render(machine, fmt)
|
|
196
|
+
if out == "-":
|
|
197
|
+
sys.stdout.write(text)
|
|
198
|
+
else:
|
|
199
|
+
with open(out, "w") as f:
|
|
200
|
+
f.write(text)
|
|
201
|
+
else:
|
|
202
|
+
graph = DotGraphMachine(machine).get_graph()
|
|
203
|
+
if out == "-":
|
|
204
|
+
sys.stdout.buffer.write(graph.create_svg()) # type: ignore[attr-defined]
|
|
205
|
+
else:
|
|
206
|
+
out_extension = out.rsplit(".", 1)[1]
|
|
207
|
+
graph.write(out, format=out_extension)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def main(argv=None):
|
|
211
|
+
import argparse
|
|
212
|
+
|
|
213
|
+
parser = argparse.ArgumentParser(
|
|
214
|
+
usage="%(prog)s [OPTION] <class_path> <out>",
|
|
215
|
+
description="Generate diagrams for StateMachine classes.",
|
|
216
|
+
)
|
|
217
|
+
parser.add_argument(
|
|
218
|
+
"class_path", help="A fully-qualified dotted path to the StateMachine class."
|
|
219
|
+
)
|
|
220
|
+
parser.add_argument(
|
|
221
|
+
"out",
|
|
222
|
+
help="File to generate the image using extension as the output format.",
|
|
223
|
+
)
|
|
224
|
+
parser.add_argument(
|
|
225
|
+
"--events",
|
|
226
|
+
nargs="+",
|
|
227
|
+
help="Instantiate the machine and send these events before rendering.",
|
|
228
|
+
)
|
|
229
|
+
parser.add_argument(
|
|
230
|
+
"--format",
|
|
231
|
+
choices=formatter.supported_formats(),
|
|
232
|
+
default=None,
|
|
233
|
+
help="Output as text format instead of Graphviz image.",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
args = parser.parse_args(argv)
|
|
237
|
+
write_image(qualname=args.class_path, out=args.out, events=args.events, fmt=args.format)
|