python-statemachine 3.2.0__py3-none-any.whl → 3.2.2__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.2.0.dist-info → python_statemachine-3.2.2.dist-info}/METADATA +7 -6
- python_statemachine-3.2.2.dist-info/RECORD +73 -0
- {python_statemachine-3.2.0.dist-info → python_statemachine-3.2.2.dist-info}/WHEEL +1 -1
- statemachine/__init__.py +1 -1
- statemachine/callbacks.py +11 -0
- statemachine/contrib/diagram/__init__.py +15 -6
- statemachine/contrib/diagram/extract.py +3 -3
- statemachine/contrib/diagram/formatter.py +4 -2
- statemachine/contrib/diagram/graphviz.py +66 -0
- statemachine/contrib/diagram/sphinx_ext.py +28 -7
- statemachine/dispatcher.py +8 -3
- statemachine/engines/async_.py +224 -180
- statemachine/engines/base.py +257 -245
- statemachine/engines/sync.py +224 -108
- statemachine/event.py +29 -10
- statemachine/event_data.py +19 -3
- statemachine/events.py +5 -2
- statemachine/exceptions.py +1 -1
- statemachine/factory.py +126 -29
- statemachine/io/__init__.py +18 -11
- statemachine/io/actions.py +124 -15
- statemachine/io/evaluators.py +37 -9
- statemachine/io/invoke.py +12 -0
- statemachine/io/loader.py +17 -3
- statemachine/io/schemas/statechart.schema.json +8 -1
- statemachine/io/scxml/__init__.py +6 -3
- statemachine/io/scxml/processor.py +1 -1
- statemachine/io/scxml/reader.py +24 -9
- statemachine/io/system_variables.py +34 -19
- statemachine/locale/en/LC_MESSAGES/statemachine.po +58 -17
- statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po +66 -17
- statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po +66 -17
- statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po +60 -17
- statemachine/orderedset.py +12 -3
- statemachine/registry.py +6 -1
- statemachine/spec_parser.py +151 -21
- statemachine/state.py +47 -8
- statemachine/statemachine.py +42 -19
- python_statemachine-3.2.0.dist-info/RECORD +0 -72
- {python_statemachine-3.2.0.dist-info → python_statemachine-3.2.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: python-statemachine
|
|
3
|
-
Version: 3.2.
|
|
3
|
+
Version: 3.2.2
|
|
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>
|
|
@@ -18,6 +18,7 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.12
|
|
19
19
|
Classifier: Programming Language :: Python :: 3.13
|
|
20
20
|
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
21
22
|
Classifier: Topic :: Home Automation
|
|
22
23
|
Classifier: Topic :: Software Development :: Libraries
|
|
23
24
|
Requires-Python: >=3.10
|
|
@@ -37,15 +38,15 @@ Description-Content-Type: text/markdown
|
|
|
37
38
|
[](https://pypi.python.org/pypi/python-statemachine)
|
|
38
39
|
[](https://pepy.tech/project/python-statemachine)
|
|
39
40
|
[](https://pypi.python.org/pypi/python-statemachine)
|
|
40
|
-
[](https://codecov.io/gh/fgmacedo/python-statemachine)
|
|
41
42
|
[](https://python-statemachine.readthedocs.io/en/latest/?badge=latest)
|
|
42
|
-
[](https://github.com/fgmacedo/python-statemachine/compare/latest...main)
|
|
43
44
|
|
|
44
45
|
Expressive [statecharts](https://statecharts.dev/) and [FSMs](https://en.wikipedia.org/wiki/Finite-state_machine) for modern Python.
|
|
45
46
|
|
|
46
47
|
<div align="center">
|
|
47
48
|
|
|
48
|
-

|
|
49
50
|
|
|
50
51
|
</div>
|
|
51
52
|
|
|
@@ -128,7 +129,7 @@ Generate a diagram or get a text representation with f-strings:
|
|
|
128
129
|
sm._graph().write_png("traffic_light.png")
|
|
129
130
|
```
|
|
130
131
|
|
|
131
|
-

|
|
132
133
|
|
|
133
134
|
Parameters are injected into callbacks automatically — the library inspects the
|
|
134
135
|
signature and provides only the arguments each callback needs:
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
statemachine/__init__.py,sha256=KyQKpJwLYHOkVETTQ60dx3HBZPE2ltpSTS0rb0jH1vk,445
|
|
2
|
+
statemachine/callbacks.py,sha256=k0RfCmQlDGMaKE3ZHlot9hiJ0eHykL1dwKM4Oerj--o,14994
|
|
3
|
+
statemachine/configuration.py,sha256=MblvaBwEOyooYc4Vzo-qeryF9sEmx9mdtZDY0Gf_uRU,5991
|
|
4
|
+
statemachine/dispatcher.py,sha256=QeQ2CNWzHxeFDXiW9AtxrSD4WDUio9hauBgj0BLK1ps,8469
|
|
5
|
+
statemachine/event.py,sha256=vmcoBIyiRtBwX_7i4efKQKg6-3-3nIHDGM9ThAFp9-I,8361
|
|
6
|
+
statemachine/event_data.py,sha256=dh9nR1gi23fhlfjUizMbqOmVbZP1RlnAndrAhPHXXqg,3978
|
|
7
|
+
statemachine/events.py,sha256=cRDfZXF-7FiA6yEQtxmwMJl3mnbfkO6wagOpyaBakdY,1288
|
|
8
|
+
statemachine/exceptions.py,sha256=q1ZOqBmNv34mtHAzv4XHBV67BHldxtEpu3uoeTXiEYI,1317
|
|
9
|
+
statemachine/factory.py,sha256=AjDdbNst0Avf0M9ypohE_uBusZGSpIWOPz0aajLf1Wg,19230
|
|
10
|
+
statemachine/graph.py,sha256=ngQ31YAetfQLQvdBU5cHwLQcD-wJL92axPJ2sUeCu1g,1999
|
|
11
|
+
statemachine/i18n.py,sha256=NLvGseaORmQ0G-V_J8tkjoxh_piWMOm2CI6mBQpLamc,362
|
|
12
|
+
statemachine/invoke.py,sha256=5B7XNvBFnQaKByoYM1VbRlJvJyEwXn1C0QKUCaRjmpU,24908
|
|
13
|
+
statemachine/mixins.py,sha256=8qxZZfBwVdFcr3oPFVWHGzgmAubH6VgQus5x3c6VEE0,1706
|
|
14
|
+
statemachine/model.py,sha256=OylI3FjMiHpYyDl9mtK1zEJMeSvemaN4giQDonpc8kI,211
|
|
15
|
+
statemachine/orderedset.py,sha256=a2FqEAAKja6E0Bu5O5Lzbd18V4hECrrCbinguIdzTjk,2794
|
|
16
|
+
statemachine/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
+
statemachine/registry.py,sha256=-CYm8bdZN5ro9Pc806XPJ7ZfT3YrcSB9cOZ83AnTFdY,829
|
|
18
|
+
statemachine/signature.py,sha256=-IiRYJScx4cDKny3ictQvHwQBbaCyxWpRKSgQksMT0E,9834
|
|
19
|
+
statemachine/spec_parser.py,sha256=jxskD6Mxs--b5q_f3gAZAxD9morER7nK7My0UwvI0h4,18982
|
|
20
|
+
statemachine/state.py,sha256=vm42WSZ-gNSOFgk9L9sqUIA1YAkngPaxu-VS7qh0dyI,17349
|
|
21
|
+
statemachine/statemachine.py,sha256=yoLZh7Q4evCCOkKmaSNiozGDN27W1l_HPb58ricJ_Oo,21129
|
|
22
|
+
statemachine/states.py,sha256=NRHtATtb-2Ac2nwzSn5OfHwzOSxI2FLeNR8o1v84USs,4827
|
|
23
|
+
statemachine/transition.py,sha256=JtGABHJ6inZrneI70bgu4ir2BZ2YQagcYX9l6TcsHSc,6912
|
|
24
|
+
statemachine/transition_list.py,sha256=zZuGWB86fL2ux-orIE7CdeCcnhaS5p2njIy8h2SuyVY,4361
|
|
25
|
+
statemachine/transition_mixin.py,sha256=i9ZLDGPgE2zIkTn7IAEMjscn1gnI-HJMtxX-YG447tY,3002
|
|
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=lzcopcY0EzjJb-N4APJJc7AlgZIPtthTbBizKaDtOIk,6787
|
|
30
|
+
statemachine/contrib/diagram/__init__.py,sha256=8nnEIE_Lk4tX_sUzB8v0RqaJwHbbMgXYtfkWytNgfVU,8042
|
|
31
|
+
statemachine/contrib/diagram/__main__.py,sha256=iPpiz09xKqtAjrhONS99OYp6R2dQ6Anbhw1qPIN8ELo,80
|
|
32
|
+
statemachine/contrib/diagram/extract.py,sha256=0X9RsHFgQgxDGaz6GGbFnJacZyxRO7sV7PolpWiFbdw,9869
|
|
33
|
+
statemachine/contrib/diagram/formatter.py,sha256=4ed_i_fFsbDnBUZG9gRlaKyvTR0IWFuU65OGPc1VrZk,4745
|
|
34
|
+
statemachine/contrib/diagram/graphviz.py,sha256=G7-gij3uC_Lsy5ondUTcJud8mLvK4443SHRma5nSyUs,2342
|
|
35
|
+
statemachine/contrib/diagram/model.py,sha256=pn7dzrt8U8TOn4A-Jg1Rc3b1UUlTgs75WfR2x-xtEuc,1451
|
|
36
|
+
statemachine/contrib/diagram/sphinx_ext.py,sha256=c_TAZAh4hKVk_ldoDMDilQXO-SnVdku9QHfYm-w0q4o,11348
|
|
37
|
+
statemachine/contrib/diagram/renderers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
38
|
+
statemachine/contrib/diagram/renderers/dot.py,sha256=VkWMVP1DLl1YUEUZVmBVSNQVbAG516v3hQcK4PePWD8,19772
|
|
39
|
+
statemachine/contrib/diagram/renderers/mermaid.py,sha256=3N1z0klonL768exBcWp3WLMuWUgCpRso2UhpdnvHYhY,13424
|
|
40
|
+
statemachine/contrib/diagram/renderers/table.py,sha256=PLVfwTIZHtv7uK3smAfN1owAOEunkBRHyEkh5ygVcfw,3711
|
|
41
|
+
statemachine/engines/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
42
|
+
statemachine/engines/async_.py,sha256=VmN3jc6vQmMsGOYPAgSS03ZIKuTZzuUCK4eCgxwG7Rg,22641
|
|
43
|
+
statemachine/engines/base.py,sha256=PRTdKGxsZU1Fgw9hddoAdhmu9JSVG5XJpqt8srIu7ps,38233
|
|
44
|
+
statemachine/engines/sync.py,sha256=Z3hP_vqbi6BdoKvQBS92pjb0P8UdEhnzJwZyrklVxnw,12757
|
|
45
|
+
statemachine/io/__init__.py,sha256=BMA91t_PY0fmsjIfMeq8pQ6Cs4KS1gxyrZj_DtMntRA,2769
|
|
46
|
+
statemachine/io/actions.py,sha256=rxObGx0xbXMooRVtGGaoeqmtTWl93AFQGv2gGAMCi4w,24549
|
|
47
|
+
statemachine/io/builder.py,sha256=yNaQrtFfbs55uRd-loZ-Vs5061liwM-np-B_2ZuJO1I,8350
|
|
48
|
+
statemachine/io/class_factory.py,sha256=H8zxsfrjLw7L_X2g2hoXb2CjpHxTftsoBmPBWIcen-4,9557
|
|
49
|
+
statemachine/io/evaluators.py,sha256=A6g_o_8GuYMcSFOWJ1dsVRzr06ddikybCI-xAbY3vhY,12178
|
|
50
|
+
statemachine/io/interpreter.py,sha256=FQUXUlt1PSPzFHyixiLNXcN-7KYBM_i5RAFPRPLu6TE,5567
|
|
51
|
+
statemachine/io/invoke.py,sha256=iPYck_vP2vh7-bRXkMwOYHryrXCqWxNhLzFE8uiG6L8,10632
|
|
52
|
+
statemachine/io/loader.py,sha256=g_X6NgI2mudpwHN6EoDCaDv4HsryFpO93lExPo_lOdU,7054
|
|
53
|
+
statemachine/io/model.py,sha256=mTJvlbDJndX78pHfyDdEaqc9zTBWVA-OFA5xzVZbz6Y,8695
|
|
54
|
+
statemachine/io/native.py,sha256=HjIOKDQIGExLMglcm8VGbga71Y1Zw-pBbKYWP-y_oiY,13758
|
|
55
|
+
statemachine/io/ports.py,sha256=XoIAQfMujkEnH38t4iu766yiHycb89TNrA3owtBJU2U,2956
|
|
56
|
+
statemachine/io/system_variables.py,sha256=yYoAJTCm1uNIp63Dyw0wSUKDJbg1gQGgWKfvBt6LyGQ,7278
|
|
57
|
+
statemachine/io/validation.py,sha256=6qiDx0gMkPAehSxJu_mrW4nEF_sebHwa5Dr_njclRcw,1662
|
|
58
|
+
statemachine/io/json/__init__.py,sha256=tFWoOrD9RuKEqkOpI0XifNRP7rguSGY4k-RaSCV_GnM,38
|
|
59
|
+
statemachine/io/json/reader.py,sha256=40V5T-yB1i0Qg-MkK00KHRi7eDQQk4ajfzmKtKXylHs,789
|
|
60
|
+
statemachine/io/schemas/statechart.schema.json,sha256=ngxuIIegKLZWg8MUozQkktE8TbhUO57ZlzEbs7mKdRs,8132
|
|
61
|
+
statemachine/io/scxml/__init__.py,sha256=0CMGGfpOaindNGb6VOkMn7Z4nGqK3iJ3sMZcS531Kaw,972
|
|
62
|
+
statemachine/io/scxml/processor.py,sha256=wk6Tt7yTf2GxCwx5S3eT9NInMsObspPmpQFLTNkacSk,1532
|
|
63
|
+
statemachine/io/scxml/reader.py,sha256=gp6tglHXdNxnPa9jNoo9jvRqNUfIr3O_DjCQJQijbD4,17821
|
|
64
|
+
statemachine/io/yaml/__init__.py,sha256=oMapUmquV7hb5qIGMfjDZmDHNq2fqqjJw6LOgGNKeC4,79
|
|
65
|
+
statemachine/io/yaml/reader.py,sha256=mf2FaBttxQuCniSM-vHhSYDGaNLQdzRLoMUHO2kZ0xA,2208
|
|
66
|
+
statemachine/locale/en/LC_MESSAGES/statemachine.po,sha256=fv26VIkXR_Nn_Ki-CdW0_SPjBQqGdV6oLXqFhmUrOiI,6466
|
|
67
|
+
statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po,sha256=WQieKolnjtonMT2ld39F4MTgzjBirm7jsMnXWIcB2Qg,10512
|
|
68
|
+
statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po,sha256=j-sqfIZTSrS6we4oOCXLosv3DIqdy7NE7rFLIuHiHBk,7619
|
|
69
|
+
statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po,sha256=1KQiUND1iII4HXW20sheHWgkyeQ-EbWI0kcZGsdsuIg,7035
|
|
70
|
+
python_statemachine-3.2.2.dist-info/METADATA,sha256=AoxoXlVxhdLMJvO1XPPdSPkl4KabGJQl3AzvGJ4-9aI,12682
|
|
71
|
+
python_statemachine-3.2.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
72
|
+
python_statemachine-3.2.2.dist-info/licenses/LICENSE,sha256=zcP7TsJMqaFxuTvLRZPT7nJl3_ppjxR9Z76BE9pL5zc,1074
|
|
73
|
+
python_statemachine-3.2.2.dist-info/RECORD,,
|
statemachine/__init__.py
CHANGED
statemachine/callbacks.py
CHANGED
|
@@ -8,6 +8,8 @@ from enum import IntFlag
|
|
|
8
8
|
from enum import auto
|
|
9
9
|
from functools import partial
|
|
10
10
|
from inspect import isawaitable
|
|
11
|
+
from inspect import isfunction
|
|
12
|
+
from typing import Any
|
|
11
13
|
|
|
12
14
|
from .exceptions import AttrNotFound
|
|
13
15
|
from .i18n import _
|
|
@@ -18,6 +20,11 @@ def allways_true(*args, **kwargs):
|
|
|
18
20
|
return True
|
|
19
21
|
|
|
20
22
|
|
|
23
|
+
def is_unbounded_reference(func: Any) -> bool:
|
|
24
|
+
"""Whether ``func`` is a function that a `CallbackSpec` holds by object."""
|
|
25
|
+
return isfunction(func) and hasattr(func, "attr_name")
|
|
26
|
+
|
|
27
|
+
|
|
21
28
|
class CallbackPriority(IntEnum):
|
|
22
29
|
GENERIC = 0
|
|
23
30
|
INLINE = 10
|
|
@@ -103,6 +110,10 @@ class CallbackSpec:
|
|
|
103
110
|
and self.reference == SpecReference.NAME
|
|
104
111
|
)
|
|
105
112
|
|
|
113
|
+
self.lookup_name: str = self.attr_name
|
|
114
|
+
"""Attribute of the machine holding an unbounded callable. Differs from `attr_name`
|
|
115
|
+
when nested state bodies share the name and each function is stored apart."""
|
|
116
|
+
|
|
106
117
|
def __repr__(self):
|
|
107
118
|
return f"{type(self).__name__}({self.func!r}, is_convention={self.is_convention!r})"
|
|
108
119
|
|
|
@@ -5,6 +5,9 @@ from urllib.request import urlopen
|
|
|
5
5
|
|
|
6
6
|
from .extract import extract
|
|
7
7
|
from .formatter import formatter as formatter
|
|
8
|
+
from .graphviz import GraphvizError as GraphvizError
|
|
9
|
+
from .graphviz import GraphvizNotFoundError as GraphvizNotFoundError
|
|
10
|
+
from .graphviz import run_graphviz
|
|
8
11
|
|
|
9
12
|
if TYPE_CHECKING:
|
|
10
13
|
from .renderers.dot import DotRendererConfig
|
|
@@ -207,13 +210,19 @@ def write_image(qualname, out, events=None, fmt=None):
|
|
|
207
210
|
else:
|
|
208
211
|
with open(out, "w") as f:
|
|
209
212
|
f.write(text)
|
|
213
|
+
elif out == "-":
|
|
214
|
+
sys.stdout.buffer.write(_render_image(machine, "svg"))
|
|
210
215
|
else:
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
216
|
+
image = _render_image(machine, out.rsplit(".", 1)[1])
|
|
217
|
+
with open(out, "wb") as f:
|
|
218
|
+
f.write(image)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _render_image(machine, fmt: str) -> bytes:
|
|
222
|
+
dot_source: str = DotGraphMachine(machine).get_graph().to_string()
|
|
223
|
+
if fmt == "raw":
|
|
224
|
+
return dot_source.encode("utf-8")
|
|
225
|
+
return run_graphviz(dot_source, fmt)
|
|
217
226
|
|
|
218
227
|
|
|
219
228
|
def main(argv=None):
|
|
@@ -91,7 +91,7 @@ def _extract_state(
|
|
|
91
91
|
|
|
92
92
|
return DiagramState(
|
|
93
93
|
id=state.id,
|
|
94
|
-
name=state.name,
|
|
94
|
+
name=str(state.name),
|
|
95
95
|
type=state_type,
|
|
96
96
|
actions=actions,
|
|
97
97
|
children=children,
|
|
@@ -125,7 +125,7 @@ def _format_event_names(transition: "Transition") -> str:
|
|
|
125
125
|
continue
|
|
126
126
|
if eid not in seen_ids: # pragma: no branch
|
|
127
127
|
seen_ids.add(eid)
|
|
128
|
-
display.append(event.name if event.name else eid)
|
|
128
|
+
display.append(str(event.name) if event.name else eid)
|
|
129
129
|
|
|
130
130
|
return " ".join(display)
|
|
131
131
|
|
|
@@ -279,7 +279,7 @@ def extract(machine_or_class: "MachineRef") -> DiagramGraph:
|
|
|
279
279
|
_resolve_initial_states(states)
|
|
280
280
|
|
|
281
281
|
return DiagramGraph(
|
|
282
|
-
name=machine.name,
|
|
282
|
+
name=str(machine.name),
|
|
283
283
|
states=states,
|
|
284
284
|
transitions=transitions,
|
|
285
285
|
compound_state_ids=compound_ids,
|
|
@@ -19,6 +19,8 @@ A module-level :data:`formatter` instance is the single public entry point::
|
|
|
19
19
|
from collections.abc import Callable
|
|
20
20
|
from typing import TYPE_CHECKING
|
|
21
21
|
|
|
22
|
+
from .graphviz import run_graphviz
|
|
23
|
+
|
|
22
24
|
if TYPE_CHECKING:
|
|
23
25
|
from typing import TypeAlias
|
|
24
26
|
|
|
@@ -108,8 +110,8 @@ def _render_dot(machine_or_class: "MachineRef") -> str:
|
|
|
108
110
|
def _render_svg(machine_or_class: "MachineRef") -> str:
|
|
109
111
|
from statemachine.contrib.diagram import DotGraphMachine
|
|
110
112
|
|
|
111
|
-
|
|
112
|
-
return
|
|
113
|
+
dot_source = DotGraphMachine(machine_or_class).get_graph().to_string()
|
|
114
|
+
return run_graphviz(dot_source, "svg").decode("utf-8")
|
|
113
115
|
|
|
114
116
|
|
|
115
117
|
@formatter.register_format("mermaid")
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Run the Graphviz ``dot`` program and report its failures."""
|
|
2
|
+
|
|
3
|
+
import errno
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
from statemachine.exceptions import StateMachineError
|
|
7
|
+
|
|
8
|
+
GRAPHVIZ_DOWNLOAD_URL = "https://graphviz.org/download/"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GraphvizError(StateMachineError):
|
|
12
|
+
"""Raised when the Graphviz ``dot`` program cannot run or fails to render a graph."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GraphvizNotFoundError(GraphvizError, FileNotFoundError):
|
|
16
|
+
"""Raised when the Graphviz program is not found in ``PATH``.
|
|
17
|
+
|
|
18
|
+
Also a :class:`FileNotFoundError`, so ``except FileNotFoundError`` catches a missing
|
|
19
|
+
Graphviz.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, message: str):
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.errno = errno.ENOENT
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run_graphviz(dot_source: str, fmt: str, prog: str = "dot") -> bytes:
|
|
28
|
+
"""Render ``dot_source`` with Graphviz and return the output bytes.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
dot_source: Graph in the DOT language.
|
|
32
|
+
fmt: Graphviz output format, passed as ``-T<fmt>`` (``"svg"``, ``"png"``, ...).
|
|
33
|
+
prog: Graphviz layout program to run, looked up on ``PATH``.
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
GraphvizNotFoundError: If ``prog`` is not found in ``PATH``.
|
|
37
|
+
GraphvizError: If ``prog`` cannot be run or exits with a non-zero code.
|
|
38
|
+
The message carries what Graphviz wrote to stderr.
|
|
39
|
+
"""
|
|
40
|
+
command = [prog, f"-T{fmt}"]
|
|
41
|
+
try:
|
|
42
|
+
completed = subprocess.run(
|
|
43
|
+
command,
|
|
44
|
+
input=dot_source.encode("utf-8"),
|
|
45
|
+
capture_output=True,
|
|
46
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
47
|
+
)
|
|
48
|
+
except FileNotFoundError as err:
|
|
49
|
+
raise GraphvizNotFoundError(
|
|
50
|
+
f"{prog!r} not found in PATH. Install Graphviz from {GRAPHVIZ_DOWNLOAD_URL} "
|
|
51
|
+
"to generate diagrams."
|
|
52
|
+
) from err
|
|
53
|
+
except OSError as err:
|
|
54
|
+
raise GraphvizError(f"{prog!r} could not be run: {err}") from err
|
|
55
|
+
|
|
56
|
+
if completed.returncode != 0:
|
|
57
|
+
if completed.returncode < 0:
|
|
58
|
+
message = f"{' '.join(command)} was terminated by signal {-completed.returncode}."
|
|
59
|
+
else:
|
|
60
|
+
message = f"{' '.join(command)} failed with exit code {completed.returncode}."
|
|
61
|
+
stderr = completed.stderr.decode("utf-8", errors="replace").strip()
|
|
62
|
+
if stderr:
|
|
63
|
+
message = f"{message}\n{stderr}"
|
|
64
|
+
raise GraphvizError(message)
|
|
65
|
+
|
|
66
|
+
return completed.stdout
|
|
@@ -24,6 +24,7 @@ from typing import ClassVar
|
|
|
24
24
|
from docutils import nodes
|
|
25
25
|
from docutils.parsers.rst import directives
|
|
26
26
|
from sphinx.util.docutils import SphinxDirective
|
|
27
|
+
from sphinx.util.osutil import relative_uri
|
|
27
28
|
|
|
28
29
|
if TYPE_CHECKING:
|
|
29
30
|
from sphinx.application import Sphinx
|
|
@@ -118,7 +119,7 @@ class StateMachineDiagram(SphinxDirective):
|
|
|
118
119
|
svg_tag = svg_tag.replace("<svg ", f"<svg {svg_styles} ", 1)
|
|
119
120
|
|
|
120
121
|
alt_text = html_mod.escape(self.options.get("alt", qualname.rsplit(".", 1)[-1]))
|
|
121
|
-
target = self._resolve_target(svg_text)
|
|
122
|
+
target, zoom_filename = self._resolve_target(svg_text)
|
|
122
123
|
|
|
123
124
|
img_html = f'<div role="img" aria-label="{alt_text}">{svg_tag}</div>'
|
|
124
125
|
if target:
|
|
@@ -142,6 +143,7 @@ class StateMachineDiagram(SphinxDirective):
|
|
|
142
143
|
html = f"<div{class_attr}>{img_html}</div>"
|
|
143
144
|
|
|
144
145
|
raw_node = nodes.raw("", html, format="html")
|
|
146
|
+
raw_node["zoom_filename"] = zoom_filename
|
|
145
147
|
|
|
146
148
|
if "name" in self.options:
|
|
147
149
|
self.add_name(raw_node)
|
|
@@ -232,18 +234,21 @@ class StateMachineDiagram(SphinxDirective):
|
|
|
232
234
|
|
|
233
235
|
return f'style="{"; ".join(parts)}"'
|
|
234
236
|
|
|
235
|
-
def _resolve_target(self, svg_text: str) -> str:
|
|
236
|
-
"""Return
|
|
237
|
+
def _resolve_target(self, svg_text: str) -> tuple[str, str]:
|
|
238
|
+
"""Return ``(href, zoom_filename)`` for the wrapper ``<a>`` tag.
|
|
237
239
|
|
|
238
240
|
When ``:target:`` is given without a value (or as empty string), the
|
|
239
241
|
raw SVG is written to ``_images/`` and linked so the user can open
|
|
240
|
-
the full diagram in a new browser tab for zooming.
|
|
242
|
+
the full diagram in a new browser tab for zooming. The href is
|
|
243
|
+
root-relative at this point; ``_resolve_zoom_links`` rewrites it
|
|
244
|
+
relative to each page at write time, once the builder knows the
|
|
245
|
+
page's final URI.
|
|
241
246
|
"""
|
|
242
247
|
if "target" not in self.options:
|
|
243
|
-
return ""
|
|
248
|
+
return "", ""
|
|
244
249
|
target = (self.options["target"] or "").strip()
|
|
245
250
|
if target:
|
|
246
|
-
return target
|
|
251
|
+
return target, ""
|
|
247
252
|
|
|
248
253
|
# Auto-generate a standalone SVG file for zoom.
|
|
249
254
|
qualname = self.arguments[0]
|
|
@@ -258,7 +263,7 @@ class StateMachineDiagram(SphinxDirective):
|
|
|
258
263
|
with open(outpath, "w", encoding="utf-8") as f:
|
|
259
264
|
f.write(svg_text)
|
|
260
265
|
|
|
261
|
-
return f"
|
|
266
|
+
return f"_images/{filename}", filename
|
|
262
267
|
|
|
263
268
|
def _build_wrapper_classes(self) -> list[str]:
|
|
264
269
|
"""Build CSS class list for the outer wrapper element."""
|
|
@@ -275,6 +280,22 @@ def _split_length(value: str) -> tuple[float, str]:
|
|
|
275
280
|
return 0.0, value
|
|
276
281
|
|
|
277
282
|
|
|
283
|
+
def _resolve_zoom_links(app: "Sphinx", doctree: nodes.document, docname: str) -> None:
|
|
284
|
+
"""Rewrite auto-generated zoom hrefs relative to the page being written."""
|
|
285
|
+
builder = app.builder
|
|
286
|
+
if builder.format != "html":
|
|
287
|
+
return
|
|
288
|
+
page_uri = builder.get_target_uri(docname).split("#", 1)[0]
|
|
289
|
+
for node in doctree.findall(nodes.raw):
|
|
290
|
+
filename = node.get("zoom_filename")
|
|
291
|
+
if not filename:
|
|
292
|
+
continue
|
|
293
|
+
href = relative_uri(page_uri, f"_images/{filename}")
|
|
294
|
+
html = node.astext().replace(f'href="_images/{filename}"', f'href="{href}"')
|
|
295
|
+
node[0] = nodes.Text(html)
|
|
296
|
+
|
|
297
|
+
|
|
278
298
|
def setup(app: "Sphinx") -> dict[str, Any]:
|
|
279
299
|
app.add_directive("statemachine-diagram", StateMachineDiagram)
|
|
300
|
+
app.connect("doctree-resolved", _resolve_zoom_links)
|
|
280
301
|
return {"version": "0.1", "parallel_read_safe": True, "parallel_write_safe": True}
|
statemachine/dispatcher.py
CHANGED
|
@@ -14,6 +14,7 @@ from .event import Event
|
|
|
14
14
|
from .exceptions import InvalidDefinition
|
|
15
15
|
from .i18n import _
|
|
16
16
|
from .signature import SignatureAdapter
|
|
17
|
+
from .spec_parser import UnsupportedExpression
|
|
17
18
|
from .spec_parser import custom_and
|
|
18
19
|
from .spec_parser import operator_mapping
|
|
19
20
|
from .spec_parser import parse_boolean_expr
|
|
@@ -118,7 +119,10 @@ class Listeners:
|
|
|
118
119
|
|
|
119
120
|
try:
|
|
120
121
|
expression = parse_boolean_expr(spec.func, take_callback_partial, operator_mapping)
|
|
121
|
-
except SyntaxError as err:
|
|
122
|
+
except (SyntaxError, UnsupportedExpression) as err:
|
|
123
|
+
# ``UnsupportedExpression`` comes from the AST allowlist rejecting a node
|
|
124
|
+
# kind that cannot appear in a boolean expression (e.g. ``"user.age"``).
|
|
125
|
+
# Errors raised while resolving names are not parse errors and propagate.
|
|
122
126
|
raise InvalidDefinition(
|
|
123
127
|
_("Failed to parse boolean expression '{}'").format(spec.func)
|
|
124
128
|
) from err
|
|
@@ -157,14 +161,15 @@ class Listeners:
|
|
|
157
161
|
# if the attr is an unbounded method, we'll try to find the bounded method
|
|
158
162
|
# on the self
|
|
159
163
|
if not spec.is_bounded:
|
|
164
|
+
attr_name = spec.lookup_name
|
|
160
165
|
for listener in self.items:
|
|
161
|
-
func = getattr(listener.obj,
|
|
166
|
+
func = getattr(listener.obj, attr_name, None)
|
|
162
167
|
# ``getattr`` may return a non-method that happens to share the name
|
|
163
168
|
# (e.g. a model attribute named like a compiled guard); it is not the
|
|
164
169
|
# unbounded method we are rebinding, so skip it instead of accessing
|
|
165
170
|
# ``__func__`` (which would raise on a plain value).
|
|
166
171
|
if getattr(func, "__func__", None) is spec.func:
|
|
167
|
-
yield listener.build_key(
|
|
172
|
+
yield listener.build_key(attr_name), partial(callable_method, func)
|
|
168
173
|
return
|
|
169
174
|
|
|
170
175
|
yield f"{spec.attr_name}-{id(spec.func)}@None", partial(callable_method, spec.func)
|