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.
Files changed (34) hide show
  1. {python_statemachine-3.0.0.dist-info → python_statemachine-3.1.1.dist-info}/METADATA +15 -8
  2. python_statemachine-3.1.1.dist-info/RECORD +58 -0
  3. statemachine/__init__.py +1 -1
  4. statemachine/configuration.py +170 -0
  5. statemachine/contrib/diagram/__init__.py +237 -0
  6. statemachine/contrib/diagram/__main__.py +6 -0
  7. statemachine/contrib/diagram/extract.py +288 -0
  8. statemachine/contrib/diagram/formatter.py +137 -0
  9. statemachine/contrib/diagram/model.py +63 -0
  10. statemachine/contrib/diagram/renderers/__init__.py +0 -0
  11. statemachine/contrib/diagram/renderers/dot.py +532 -0
  12. statemachine/contrib/diagram/renderers/mermaid.py +348 -0
  13. statemachine/contrib/diagram/renderers/table.py +105 -0
  14. statemachine/contrib/diagram/sphinx_ext.py +280 -0
  15. statemachine/engines/async_.py +28 -32
  16. statemachine/engines/base.py +51 -43
  17. statemachine/engines/sync.py +7 -12
  18. statemachine/event.py +11 -1
  19. statemachine/event_data.py +2 -2
  20. statemachine/events.py +1 -1
  21. statemachine/factory.py +39 -2
  22. statemachine/invoke.py +61 -23
  23. statemachine/io/scxml/actions.py +5 -4
  24. statemachine/locale/en/LC_MESSAGES/statemachine.po +54 -27
  25. statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po +56 -31
  26. statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po +54 -27
  27. statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po +54 -29
  28. statemachine/state.py +38 -91
  29. statemachine/statemachine.py +63 -80
  30. statemachine/utils.py +18 -0
  31. python_statemachine-3.0.0.dist-info/RECORD +0 -48
  32. statemachine/contrib/diagram.py +0 -370
  33. {python_statemachine-3.0.0.dist-info → python_statemachine-3.1.1.dist-info}/WHEEL +0 -0
  34. {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.0.0
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>=2.0.0; extra == 'diagrams'
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
- >>> # This example will only run on automated tests if dot is present
112
- >>> getfixture("requires_dot_installed")
113
- >>> img_path = "docs/images/readme_trafficlightmachine.png"
114
- >>> sm._graph().write_png(img_path)
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
  ![](https://raw.githubusercontent.com/fgmacedo/python-statemachine/develop/docs/images/readme_trafficlightmachine.png)
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** — from the CLI, at runtime, or in Jupyter notebooks
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
@@ -8,7 +8,7 @@ from .statemachine import TModel
8
8
 
9
9
  __author__ = """Fernando Macedo"""
10
10
  __email__ = "fgmacedo@gmail.com"
11
- __version__ = "3.0.0"
11
+ __version__ = "3.1.1"
12
12
 
13
13
  __all__ = [
14
14
  "StateChart",
@@ -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)
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from . import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())