python-statemachine 3.2.1__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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: python-statemachine
3
- Version: 3.2.1
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:
@@ -1,12 +1,12 @@
1
- statemachine/__init__.py,sha256=roXmQG7d48QWxlQUM94bfLMVTiJ6iZnqnhQ-qkkFHv8,445
2
- statemachine/callbacks.py,sha256=LKX9GnDhN64yYh2EvDEYNJg0-N61Kmyd0aqTZKxujM4,14526
1
+ statemachine/__init__.py,sha256=KyQKpJwLYHOkVETTQ60dx3HBZPE2ltpSTS0rb0jH1vk,445
2
+ statemachine/callbacks.py,sha256=k0RfCmQlDGMaKE3ZHlot9hiJ0eHykL1dwKM4Oerj--o,14994
3
3
  statemachine/configuration.py,sha256=MblvaBwEOyooYc4Vzo-qeryF9sEmx9mdtZDY0Gf_uRU,5991
4
- statemachine/dispatcher.py,sha256=fyJKGhOdmHAzsBB7zfoU1hPAauoC7IEyBC2C-yYzBcw,8438
5
- statemachine/event.py,sha256=hYVvsNpsY0-OSebwDEpxdLXadoJS_G5pmDe34hRqnFk,7645
6
- statemachine/event_data.py,sha256=7_hoE0cp-0uW71Kk3erO3eYM6anTT5X3slGMcehIGIo,3111
7
- statemachine/events.py,sha256=11GyX8GYpsSwvW0v1b4zHUGhzKRoqNjQYtuDj8NvqOM,1212
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
8
  statemachine/exceptions.py,sha256=q1ZOqBmNv34mtHAzv4XHBV67BHldxtEpu3uoeTXiEYI,1317
9
- statemachine/factory.py,sha256=qIgDxBwa5xa0lgvTaV4QPqdYqqkXZCzlSq0sX4TjZvo,14640
9
+ statemachine/factory.py,sha256=AjDdbNst0Avf0M9ypohE_uBusZGSpIWOPz0aajLf1Wg,19230
10
10
  statemachine/graph.py,sha256=ngQ31YAetfQLQvdBU5cHwLQcD-wJL92axPJ2sUeCu1g,1999
11
11
  statemachine/i18n.py,sha256=NLvGseaORmQ0G-V_J8tkjoxh_piWMOm2CI6mBQpLamc,362
12
12
  statemachine/invoke.py,sha256=5B7XNvBFnQaKByoYM1VbRlJvJyEwXn1C0QKUCaRjmpU,24908
@@ -14,11 +14,11 @@ statemachine/mixins.py,sha256=8qxZZfBwVdFcr3oPFVWHGzgmAubH6VgQus5x3c6VEE0,1706
14
14
  statemachine/model.py,sha256=OylI3FjMiHpYyDl9mtK1zEJMeSvemaN4giQDonpc8kI,211
15
15
  statemachine/orderedset.py,sha256=a2FqEAAKja6E0Bu5O5Lzbd18V4hECrrCbinguIdzTjk,2794
16
16
  statemachine/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
- statemachine/registry.py,sha256=TmHfVU7HgaG43slG5lh8EL-8237Fm8r2UkwDj8u-t0c,700
17
+ statemachine/registry.py,sha256=-CYm8bdZN5ro9Pc806XPJ7ZfT3YrcSB9cOZ83AnTFdY,829
18
18
  statemachine/signature.py,sha256=-IiRYJScx4cDKny3ictQvHwQBbaCyxWpRKSgQksMT0E,9834
19
- statemachine/spec_parser.py,sha256=hc4dTr8gjKjg-xyG9UA8iTQ-t8EY6Df361fDv5QFsO8,16133
20
- statemachine/state.py,sha256=Do-Fw5ZUYQBAXjXmBIGzQ9hnmn7TrmdmpvP8z6Nqp6g,15436
21
- statemachine/statemachine.py,sha256=27TRypjsh3lwlAGZAwqrt-Cnka65uBoaxMWrU27gg6M,19941
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
22
  statemachine/states.py,sha256=NRHtATtb-2Ac2nwzSn5OfHwzOSxI2FLeNR8o1v84USs,4827
23
23
  statemachine/transition.py,sha256=JtGABHJ6inZrneI70bgu4ir2BZ2YQagcYX9l6TcsHSc,6912
24
24
  statemachine/transition_list.py,sha256=zZuGWB86fL2ux-orIE7CdeCcnhaS5p2njIy8h2SuyVY,4361
@@ -27,22 +27,23 @@ statemachine/utils.py,sha256=taCgrsRDcQQLweyq607AVZyI0NMukRP-HGpO8TiVNgk,1527
27
27
  statemachine/contrib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
28
28
  statemachine/contrib/timeout.py,sha256=VXkdLpxNeUKihkbtLv8XTWdG-meQlSFtxJfcxXgV8Uw,2251
29
29
  statemachine/contrib/weighted.py,sha256=lzcopcY0EzjJb-N4APJJc7AlgZIPtthTbBizKaDtOIk,6787
30
- statemachine/contrib/diagram/__init__.py,sha256=mvTluFDnhE2OzTeOhyU7aQebl79LIOsvOiNFAVIldww,7730
30
+ statemachine/contrib/diagram/__init__.py,sha256=8nnEIE_Lk4tX_sUzB8v0RqaJwHbbMgXYtfkWytNgfVU,8042
31
31
  statemachine/contrib/diagram/__main__.py,sha256=iPpiz09xKqtAjrhONS99OYp6R2dQ6Anbhw1qPIN8ELo,80
32
32
  statemachine/contrib/diagram/extract.py,sha256=0X9RsHFgQgxDGaz6GGbFnJacZyxRO7sV7PolpWiFbdw,9869
33
- statemachine/contrib/diagram/formatter.py,sha256=lYdBGO3VSZnM9OipyNsuA6StMEEjrRdnksm9MmzKXBA,4724
33
+ statemachine/contrib/diagram/formatter.py,sha256=4ed_i_fFsbDnBUZG9gRlaKyvTR0IWFuU65OGPc1VrZk,4745
34
+ statemachine/contrib/diagram/graphviz.py,sha256=G7-gij3uC_Lsy5ondUTcJud8mLvK4443SHRma5nSyUs,2342
34
35
  statemachine/contrib/diagram/model.py,sha256=pn7dzrt8U8TOn4A-Jg1Rc3b1UUlTgs75WfR2x-xtEuc,1451
35
- statemachine/contrib/diagram/sphinx_ext.py,sha256=HcEXU-aYz8da02XPkZKpF5HDF0XPBGGg9FCz8MtI1C8,10338
36
+ statemachine/contrib/diagram/sphinx_ext.py,sha256=c_TAZAh4hKVk_ldoDMDilQXO-SnVdku9QHfYm-w0q4o,11348
36
37
  statemachine/contrib/diagram/renderers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
37
38
  statemachine/contrib/diagram/renderers/dot.py,sha256=VkWMVP1DLl1YUEUZVmBVSNQVbAG516v3hQcK4PePWD8,19772
38
39
  statemachine/contrib/diagram/renderers/mermaid.py,sha256=3N1z0klonL768exBcWp3WLMuWUgCpRso2UhpdnvHYhY,13424
39
40
  statemachine/contrib/diagram/renderers/table.py,sha256=PLVfwTIZHtv7uK3smAfN1owAOEunkBRHyEkh5ygVcfw,3711
40
41
  statemachine/engines/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
41
- statemachine/engines/async_.py,sha256=luSBqLaemcLoaI8PGcpWV8L-VMyEQhOZ2Tl57bEPuP4,22991
42
- statemachine/engines/base.py,sha256=qVMe0s-L4aSXA4XfQ9MUnzGHgS1ifTZ7F6rSujTOqLE,38465
43
- statemachine/engines/sync.py,sha256=F_bt4z1KLLcFCP77E9SNWDJMWiMTexE0iOU2XAd_x5A,9153
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
44
45
  statemachine/io/__init__.py,sha256=BMA91t_PY0fmsjIfMeq8pQ6Cs4KS1gxyrZj_DtMntRA,2769
45
- statemachine/io/actions.py,sha256=wd0bgL63Rdi2a6RUkobeIvAYaNrNpJHVjZ7kP5P9blw,24377
46
+ statemachine/io/actions.py,sha256=rxObGx0xbXMooRVtGGaoeqmtTWl93AFQGv2gGAMCi4w,24549
46
47
  statemachine/io/builder.py,sha256=yNaQrtFfbs55uRd-loZ-Vs5061liwM-np-B_2ZuJO1I,8350
47
48
  statemachine/io/class_factory.py,sha256=H8zxsfrjLw7L_X2g2hoXb2CjpHxTftsoBmPBWIcen-4,9557
48
49
  statemachine/io/evaluators.py,sha256=A6g_o_8GuYMcSFOWJ1dsVRzr06ddikybCI-xAbY3vhY,12178
@@ -56,17 +57,17 @@ statemachine/io/system_variables.py,sha256=yYoAJTCm1uNIp63Dyw0wSUKDJbg1gQGgWKfvB
56
57
  statemachine/io/validation.py,sha256=6qiDx0gMkPAehSxJu_mrW4nEF_sebHwa5Dr_njclRcw,1662
57
58
  statemachine/io/json/__init__.py,sha256=tFWoOrD9RuKEqkOpI0XifNRP7rguSGY4k-RaSCV_GnM,38
58
59
  statemachine/io/json/reader.py,sha256=40V5T-yB1i0Qg-MkK00KHRi7eDQQk4ajfzmKtKXylHs,789
59
- statemachine/io/schemas/statechart.schema.json,sha256=5yNIS4M7OV6GE2doAWMk_Jct-qDyb5kQ-Dq95Es9KbM,7862
60
+ statemachine/io/schemas/statechart.schema.json,sha256=ngxuIIegKLZWg8MUozQkktE8TbhUO57ZlzEbs7mKdRs,8132
60
61
  statemachine/io/scxml/__init__.py,sha256=0CMGGfpOaindNGb6VOkMn7Z4nGqK3iJ3sMZcS531Kaw,972
61
62
  statemachine/io/scxml/processor.py,sha256=wk6Tt7yTf2GxCwx5S3eT9NInMsObspPmpQFLTNkacSk,1532
62
63
  statemachine/io/scxml/reader.py,sha256=gp6tglHXdNxnPa9jNoo9jvRqNUfIr3O_DjCQJQijbD4,17821
63
64
  statemachine/io/yaml/__init__.py,sha256=oMapUmquV7hb5qIGMfjDZmDHNq2fqqjJw6LOgGNKeC4,79
64
65
  statemachine/io/yaml/reader.py,sha256=mf2FaBttxQuCniSM-vHhSYDGaNLQdzRLoMUHO2kZ0xA,2208
65
- statemachine/locale/en/LC_MESSAGES/statemachine.po,sha256=_HrrNLQK1krOb56x5Pf1S6LihwaPwhN9_T0PUvumRA0,5338
66
- statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po,sha256=O15jEhCNATAu6O2WhQ9RhQISB6Ueje2xbbTfEotnVSc,7948
67
- statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po,sha256=X6-fU4JfK7VPFCRuLQ-4J-YUMF9N_RwhZ1DdCUvn74Y,5814
68
- statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po,sha256=0_54wD7bl3RBMlYyokNFUdgMCx_1MbKzclxBUwmCNEs,5363
69
- python_statemachine-3.2.1.dist-info/METADATA,sha256=1IBKPLjl5g4Ui0PIp6lUfCF8zL8yJ9_KLcKhmtsrM-w,12642
70
- python_statemachine-3.2.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
71
- python_statemachine-3.2.1.dist-info/licenses/LICENSE,sha256=zcP7TsJMqaFxuTvLRZPT7nJl3_ppjxR9Z76BE9pL5zc,1074
72
- python_statemachine-3.2.1.dist-info/RECORD,,
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.31.0
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.1"
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):
@@ -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}
@@ -161,14 +161,15 @@ class Listeners:
161
161
  # if the attr is an unbounded method, we'll try to find the bounded method
162
162
  # on the self
163
163
  if not spec.is_bounded:
164
+ attr_name = spec.lookup_name
164
165
  for listener in self.items:
165
- func = getattr(listener.obj, spec.attr_name, None)
166
+ func = getattr(listener.obj, attr_name, None)
166
167
  # ``getattr`` may return a non-method that happens to share the name
167
168
  # (e.g. a model attribute named like a compiled guard); it is not the
168
169
  # unbounded method we are rebinding, so skip it instead of accessing
169
170
  # ``__func__`` (which would raise on a plain value).
170
171
  if getattr(func, "__func__", None) is spec.func:
171
- yield listener.build_key(spec.attr_name), partial(callable_method, func)
172
+ yield listener.build_key(attr_name), partial(callable_method, func)
172
173
  return
173
174
 
174
175
  yield f"{spec.attr_name}-{id(spec.func)}@None", partial(callable_method, spec.func)