Skip to content

Commit 8750736

Browse files
committed
refactor: extract diagram module into package with IR + renderer separation
Refactor `statemachine/contrib/diagram.py` (single file) into a package with separated concerns: - `model.py`: intermediate representation (DiagramGraph, DiagramState, DiagramTransition) as pure dataclasses - `extract.py`: state machine → IR extraction logic - `renderers/dot.py`: IR → pydot.Dot rendering with UML-inspired styling - `__init__.py`: backwards-compatible facade (DotGraphMachine, etc.) Key improvements: - States with actions render as HTML TABLE labels with UML compartments (name + separator + entry/exit/actions), inspired by state-machine-cat - States without actions use native rounded rectangles - Active/inactive states use consistent rounded shapes (no polygon fill regression) - Class diagrams no longer highlight initial state as active - SVG shape consistency tests catch visual regressions automatically - Visual showcase section in docs/diagram.md demonstrates all features The public API is fully preserved: DotGraphMachine, quickchart_write_svg, write_image, main, import_sm, and `python -m statemachine.contrib.diagram`.
1 parent cac607a commit 8750736

42 files changed

Lines changed: 1269 additions & 432 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/diagram.md

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,300 @@ JupyterLab cells — no extra code needed:
130130
![Approval machine on JupyterLab](images/lab_approval_machine_accepted.png)
131131

132132

133+
## Visual showcase
134+
135+
This section demonstrates how each state machine feature is rendered in
136+
diagrams. Each example shows both the **class** diagram (no active state) and
137+
the **instance** diagram (with the current state highlighted).
138+
139+
``` py
140+
>>> # This showcase will only run on automated tests if dot is present
141+
>>> getfixture("requires_dot_installed")
142+
143+
>>> from statemachine import State, StateChart, HistoryState
144+
>>> from statemachine.contrib.diagram import DotGraphMachine
145+
146+
```
147+
148+
### Simple states
149+
150+
A minimal state machine with three atomic states and linear transitions.
151+
152+
```py
153+
>>> class SimpleSC(StateChart):
154+
... idle = State(initial=True)
155+
... running = State()
156+
... done = State(final=True)
157+
... start = idle.to(running)
158+
... finish = running.to(done)
159+
160+
>>> DotGraphMachine(SimpleSC)().write_png("docs/images/showcase_simple_class.png")
161+
162+
>>> sm = SimpleSC()
163+
>>> DotGraphMachine(sm)().write_png("docs/images/showcase_simple_initial.png")
164+
165+
>>> sm.start()
166+
>>> DotGraphMachine(sm)().write_png("docs/images/showcase_simple_running.png")
167+
168+
>>> sm.finish()
169+
>>> DotGraphMachine(sm)().write_png("docs/images/showcase_simple_done.png")
170+
171+
```
172+
173+
| Class | Initial | Running | Done (final) |
174+
|:---:|:---:|:---:|:---:|
175+
| ![](images/showcase_simple_class.png) | ![](images/showcase_simple_initial.png) | ![](images/showcase_simple_running.png) | ![](images/showcase_simple_done.png) |
176+
177+
178+
### Entry and exit actions
179+
180+
States can declare `entry` / `exit` callbacks, shown in the state label.
181+
182+
```py
183+
>>> class ActionsSC(StateChart):
184+
... off = State(initial=True)
185+
... on = State()
186+
... done = State(final=True)
187+
... power_on = off.to(on)
188+
... shutdown = on.to(done)
189+
... def on_exit_off(self): ...
190+
... def on_enter_on(self): ...
191+
... def on_exit_on(self): ...
192+
... def on_enter_done(self): ...
193+
194+
>>> DotGraphMachine(ActionsSC)().write_png("docs/images/showcase_actions_class.png")
195+
196+
>>> sm = ActionsSC()
197+
>>> sm.power_on()
198+
>>> DotGraphMachine(sm)().write_png("docs/images/showcase_actions_on.png")
199+
200+
```
201+
202+
| Class | Active: On |
203+
|:---:|:---:|
204+
| ![](images/showcase_actions_class.png) | ![](images/showcase_actions_on.png) |
205+
206+
207+
### Guard conditions
208+
209+
Transitions can have `cond` guards, shown in brackets on the edge label.
210+
211+
```py
212+
>>> class GuardSC(StateChart):
213+
... pending = State(initial=True)
214+
... approved = State(final=True)
215+
... rejected = State(final=True)
216+
... def is_valid(self): return True
217+
... def is_invalid(self): return False
218+
... review = pending.to(approved, cond="is_valid") | pending.to(rejected, cond="is_invalid")
219+
220+
>>> DotGraphMachine(GuardSC)().write_png("docs/images/showcase_guards_class.png")
221+
222+
>>> sm = GuardSC()
223+
>>> DotGraphMachine(sm)().write_png("docs/images/showcase_guards_pending.png")
224+
225+
```
226+
227+
| Class | Active: Pending |
228+
|:---:|:---:|
229+
| ![](images/showcase_guards_class.png) | ![](images/showcase_guards_pending.png) |
230+
231+
232+
### Self-transitions
233+
234+
A transition from a state back to itself.
235+
236+
```py
237+
>>> class SelfTransitionSC(StateChart):
238+
... counting = State(initial=True)
239+
... done = State(final=True)
240+
... increment = counting.to.itself()
241+
... stop = counting.to(done)
242+
243+
>>> DotGraphMachine(SelfTransitionSC)().write_png("docs/images/showcase_self_class.png")
244+
245+
>>> sm = SelfTransitionSC()
246+
>>> DotGraphMachine(sm)().write_png("docs/images/showcase_self_active.png")
247+
248+
```
249+
250+
| Class | Active: Counting |
251+
|:---:|:---:|
252+
| ![](images/showcase_self_class.png) | ![](images/showcase_self_active.png) |
253+
254+
255+
### Internal transitions
256+
257+
Internal transitions execute actions without exiting/entering the state.
258+
259+
```py
260+
>>> class InternalSC(StateChart):
261+
... monitoring = State(initial=True)
262+
... done = State(final=True)
263+
... def log_status(self): ...
264+
... check = monitoring.to.itself(internal=True, on="log_status")
265+
... stop = monitoring.to(done)
266+
267+
>>> DotGraphMachine(InternalSC)().write_png("docs/images/showcase_internal_class.png")
268+
269+
>>> sm = InternalSC()
270+
>>> DotGraphMachine(sm)().write_png("docs/images/showcase_internal_active.png")
271+
272+
```
273+
274+
| Class | Active: Monitoring |
275+
|:---:|:---:|
276+
| ![](images/showcase_internal_class.png) | ![](images/showcase_internal_active.png) |
277+
278+
279+
### Compound states
280+
281+
A compound state contains child states. Entering the compound activates
282+
its initial child.
283+
284+
```py
285+
>>> class CompoundSC(StateChart):
286+
... class active(State.Compound, name="Active"):
287+
... idle = State(initial=True)
288+
... working = State()
289+
... begin = idle.to(working)
290+
...
291+
... off = State(initial=True)
292+
... done = State(final=True)
293+
... turn_on = off.to(active)
294+
... turn_off = active.to(done)
295+
296+
>>> DotGraphMachine(CompoundSC)().write_png("docs/images/showcase_compound_class.png")
297+
298+
>>> sm = CompoundSC()
299+
>>> DotGraphMachine(sm)().write_png("docs/images/showcase_compound_off.png")
300+
301+
>>> sm.turn_on()
302+
>>> DotGraphMachine(sm)().write_png("docs/images/showcase_compound_idle.png")
303+
304+
>>> sm.begin()
305+
>>> DotGraphMachine(sm)().write_png("docs/images/showcase_compound_working.png")
306+
307+
```
308+
309+
| Class | Off | Active/Idle | Active/Working |
310+
|:---:|:---:|:---:|:---:|
311+
| ![](images/showcase_compound_class.png) | ![](images/showcase_compound_off.png) | ![](images/showcase_compound_idle.png) | ![](images/showcase_compound_working.png) |
312+
313+
314+
### Parallel states
315+
316+
A parallel state activates all its regions simultaneously.
317+
318+
```py
319+
>>> class ParallelSC(StateChart):
320+
... class both(State.Parallel, name="Both"):
321+
... class left(State.Compound, name="Left"):
322+
... l1 = State(initial=True)
323+
... l2 = State(final=True)
324+
... go_l = l1.to(l2)
325+
... class right(State.Compound, name="Right"):
326+
... r1 = State(initial=True)
327+
... r2 = State(final=True)
328+
... go_r = r1.to(r2)
329+
...
330+
... start = State(initial=True)
331+
... end = State(final=True)
332+
... enter = start.to(both)
333+
... done_state_both = both.to(end)
334+
335+
>>> DotGraphMachine(ParallelSC)().write_png("docs/images/showcase_parallel_class.png")
336+
337+
>>> sm = ParallelSC()
338+
>>> sm.enter()
339+
>>> DotGraphMachine(sm)().write_png("docs/images/showcase_parallel_active.png")
340+
341+
>>> sm.go_l()
342+
>>> DotGraphMachine(sm)().write_png("docs/images/showcase_parallel_l_done.png")
343+
344+
```
345+
346+
| Class | Both active | Left done |
347+
|:---:|:---:|:---:|
348+
| ![](images/showcase_parallel_class.png) | ![](images/showcase_parallel_active.png) | ![](images/showcase_parallel_l_done.png) |
349+
350+
351+
### History states (shallow)
352+
353+
A history pseudo-state remembers the last active child of a compound state.
354+
355+
```py
356+
>>> class HistorySC(StateChart):
357+
... class process(State.Compound, name="Process"):
358+
... step1 = State(initial=True)
359+
... step2 = State()
360+
... advance = step1.to(step2)
361+
... h = HistoryState()
362+
...
363+
... paused = State(initial=True)
364+
... pause = process.to(paused)
365+
... resume = paused.to(process.h)
366+
... begin = paused.to(process)
367+
368+
>>> DotGraphMachine(HistorySC)().write_png("docs/images/showcase_history_class.png")
369+
370+
>>> sm = HistorySC()
371+
>>> sm.begin()
372+
>>> sm.advance()
373+
>>> DotGraphMachine(sm)().write_png("docs/images/showcase_history_step2.png")
374+
375+
>>> sm.pause()
376+
>>> DotGraphMachine(sm)().write_png("docs/images/showcase_history_paused.png")
377+
378+
>>> sm.resume()
379+
>>> DotGraphMachine(sm)().write_png("docs/images/showcase_history_resumed.png")
380+
381+
```
382+
383+
| Class | Step2 | Paused | Resumed (→Step2) |
384+
|:---:|:---:|:---:|:---:|
385+
| ![](images/showcase_history_class.png) | ![](images/showcase_history_step2.png) | ![](images/showcase_history_paused.png) | ![](images/showcase_history_resumed.png) |
386+
387+
388+
### Deep history
389+
390+
Deep history remembers the exact leaf state across nested compounds.
391+
392+
```py
393+
>>> class DeepHistorySC(StateChart):
394+
... class outer(State.Compound, name="Outer"):
395+
... class inner(State.Compound, name="Inner"):
396+
... a = State(initial=True)
397+
... b = State()
398+
... go = a.to(b)
399+
... start = State(initial=True)
400+
... enter_inner = start.to(inner)
401+
... h = HistoryState(type="deep")
402+
...
403+
... away = State(initial=True)
404+
... dive = away.to(outer)
405+
... leave = outer.to(away)
406+
... restore = away.to(outer.h)
407+
408+
>>> DotGraphMachine(DeepHistorySC)().write_png("docs/images/showcase_deep_history_class.png")
409+
410+
>>> sm = DeepHistorySC()
411+
>>> sm.dive()
412+
>>> sm.enter_inner()
413+
>>> sm.go()
414+
>>> DotGraphMachine(sm)().write_png("docs/images/showcase_deep_history_inner_b.png")
415+
416+
>>> sm.leave()
417+
>>> sm.restore()
418+
>>> DotGraphMachine(sm)().write_png("docs/images/showcase_deep_history_restored.png")
419+
420+
```
421+
422+
| Class | Inner/B | Restored (→Inner/B) |
423+
|:---:|:---:|:---:|
424+
| ![](images/showcase_deep_history_class.png) | ![](images/showcase_deep_history_inner_b.png) | ![](images/showcase_deep_history_restored.png) |
425+
426+
133427
## Online generation (QuickChart)
134428

135429
If you prefer not to install Graphviz locally, you can generate diagrams
-3.09 KB
Loading
7.42 KB
Loading
-2.59 KB
Loading
-2.33 KB
Loading
13.7 KB
Loading
15.1 KB
Loading
22.6 KB
Loading
23.1 KB
Loading
22.7 KB
Loading

0 commit comments

Comments
 (0)