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.
Files changed (40) hide show
  1. {python_statemachine-3.2.0.dist-info → python_statemachine-3.2.2.dist-info}/METADATA +7 -6
  2. python_statemachine-3.2.2.dist-info/RECORD +73 -0
  3. {python_statemachine-3.2.0.dist-info → python_statemachine-3.2.2.dist-info}/WHEEL +1 -1
  4. statemachine/__init__.py +1 -1
  5. statemachine/callbacks.py +11 -0
  6. statemachine/contrib/diagram/__init__.py +15 -6
  7. statemachine/contrib/diagram/extract.py +3 -3
  8. statemachine/contrib/diagram/formatter.py +4 -2
  9. statemachine/contrib/diagram/graphviz.py +66 -0
  10. statemachine/contrib/diagram/sphinx_ext.py +28 -7
  11. statemachine/dispatcher.py +8 -3
  12. statemachine/engines/async_.py +224 -180
  13. statemachine/engines/base.py +257 -245
  14. statemachine/engines/sync.py +224 -108
  15. statemachine/event.py +29 -10
  16. statemachine/event_data.py +19 -3
  17. statemachine/events.py +5 -2
  18. statemachine/exceptions.py +1 -1
  19. statemachine/factory.py +126 -29
  20. statemachine/io/__init__.py +18 -11
  21. statemachine/io/actions.py +124 -15
  22. statemachine/io/evaluators.py +37 -9
  23. statemachine/io/invoke.py +12 -0
  24. statemachine/io/loader.py +17 -3
  25. statemachine/io/schemas/statechart.schema.json +8 -1
  26. statemachine/io/scxml/__init__.py +6 -3
  27. statemachine/io/scxml/processor.py +1 -1
  28. statemachine/io/scxml/reader.py +24 -9
  29. statemachine/io/system_variables.py +34 -19
  30. statemachine/locale/en/LC_MESSAGES/statemachine.po +58 -17
  31. statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po +66 -17
  32. statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po +66 -17
  33. statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po +60 -17
  34. statemachine/orderedset.py +12 -3
  35. statemachine/registry.py +6 -1
  36. statemachine/spec_parser.py +151 -21
  37. statemachine/state.py +47 -8
  38. statemachine/statemachine.py +42 -19
  39. python_statemachine-3.2.0.dist-info/RECORD +0 -72
  40. {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.4
1
+ Metadata-Version: 2.5
2
2
  Name: python-statemachine
3
- Version: 3.2.0
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
  [![pypi](https://img.shields.io/pypi/v/python-statemachine.svg)](https://pypi.python.org/pypi/python-statemachine)
38
39
  [![downloads total](https://static.pepy.tech/badge/python-statemachine)](https://pepy.tech/project/python-statemachine)
39
40
  [![downloads](https://img.shields.io/pypi/dm/python-statemachine.svg)](https://pypi.python.org/pypi/python-statemachine)
40
- [![Coverage report](https://codecov.io/gh/fgmacedo/python-statemachine/branch/develop/graph/badge.svg)](https://codecov.io/gh/fgmacedo/python-statemachine)
41
+ [![Coverage report](https://codecov.io/gh/fgmacedo/python-statemachine/branch/main/graph/badge.svg)](https://codecov.io/gh/fgmacedo/python-statemachine)
41
42
  [![Documentation Status](https://readthedocs.org/projects/python-statemachine/badge/?version=latest)](https://python-statemachine.readthedocs.io/en/latest/?badge=latest)
42
- [![GitHub commits since last release (main)](https://img.shields.io/github/commits-since/fgmacedo/python-statemachine/main/develop)](https://github.com/fgmacedo/python-statemachine/compare/main...develop)
43
+ [![GitHub commits since last release](https://img.shields.io/github/commits-since/fgmacedo/python-statemachine/latest?sort=semver)](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
- ![](https://github.com/fgmacedo/python-statemachine/blob/develop/docs/images/python-statemachine.png?raw=true)
49
+ ![](https://github.com/fgmacedo/python-statemachine/blob/main/docs/images/python-statemachine.png?raw=true)
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
- ![](https://raw.githubusercontent.com/fgmacedo/python-statemachine/develop/docs/images/readme_trafficlightmachine.png)
132
+ ![](https://raw.githubusercontent.com/fgmacedo/python-statemachine/main/docs/images/readme_trafficlightmachine.png)
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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.30.1
2
+ Generator: hatchling 1.32.4
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
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.2.0"
11
+ __version__ = "3.2.2"
12
12
 
13
13
  __all__ = [
14
14
  "StateChart",
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
- graph = DotGraphMachine(machine).get_graph()
212
- if out == "-":
213
- sys.stdout.buffer.write(graph.create_svg()) # type: ignore[attr-defined]
214
- else:
215
- out_extension = out.rsplit(".", 1)[1]
216
- graph.write(out, format=out_extension)
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
- svg_bytes: bytes = DotGraphMachine(machine_or_class).get_graph().create_svg() # type: ignore[attr-defined]
112
- return svg_bytes.decode("utf-8")
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 the href for the wrapper ``<a>`` tag, if any.
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"/_images/{filename}"
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}
@@ -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, spec.attr_name, None)
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(spec.attr_name), partial(callable_method, func)
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)