Skip to content

Commit 4953985

Browse files
authored
refactor: restructure diagram module and add Sphinx directive (#589)
* 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`. * fix: avoid instantiating StateChart class in diagram extraction The extract function now uses the class directly (never instantiates it) since all structural metadata (states, transitions, name) is already available on the class thanks to the metaclass. Active-state highlighting is only produced when an instance is passed. * feat: set default graph DPI to 200 for sharper diagram images Increase the default Graphviz DPI from 96 to 200, producing noticeably sharper PNG output. The setting is customizable via DotGraphMachine.graph_dpi. Regenerate all documentation images at the new resolution. * refactor: improve diagram layout and edge clipping - Use native shape for all states (with and without actions) so Graphviz clips edges at the actual rounded border - States with actions embed a border=0 HTML TABLE inside the native shape for UML compartment layout - Transition labels use HTML tables for better spacing - Escape guard conditions with HTML entities - Increase ranksep to 0.3, enable forcelabels, add labeldistance - Regenerate all documentation images * refactor: use is_initial flag and hide implicit initial transitions - Add is_initial field to DiagramState IR, extracted from state metadata - Use is_initial to determine which state gets the black-dot initial arrow instead of relying on document order heuristics - Skip rendering implicit initial transitions from compound/parallel states to their initial child — these are already represented by the black-dot initial node inside the cluster - Skip initial arrows for parallel area children (all are auto-initial) - Regenerate affected documentation images * refactor: improve compound state edge routing with bidirectional anchors - Compound states with both incoming and outgoing transitions get separate _anchor_in/_anchor_out nodes for cleaner edge routing - Move anchor nodes into the atomic subgraph so they share the same rank region as real states, avoiding blank space in clusters - Place inner initial dots in the atomic cluster for shorter arrows - Extract _render_initial_arrow helper to reduce _render_states complexity - Regenerate affected documentation images * refactor: reduce compound state padding and fix integrations doctest - Add margin="4" to compound/parallel subgraphs to reduce default Graphviz cluster padding (was 8pt default) - Place anchor nodes directly on parent graph when no atomic states exist at that level, avoiding empty cluster whitespace - Remove graph_dpi config (use Graphviz default) - Fix integrations.md doctest: register CampaignMachine in registry and skip Django autodiscovery to prevent ImproperlyConfigured error - Merge doctest blocks so context carries across examples - Regenerate all documentation images * refactor: enrich diagram IR to separate extraction from rendering Move domain analysis logic from the renderer (dot.py) into the extractor (extract.py) so the renderer becomes a pure IR→pydot mapping: - Add ActionType enum replacing free strings for DiagramAction.type - Add compound_state_ids and bidirectional_compound_ids to DiagramGraph - Add DiagramTransition.is_initial flag for implicit initial transitions - Remove redundant DiagramTransition.target (use targets list) - Move _collect_compound_ids, _collect_compound_bidir_ids from renderer to extractor - Add _mark_initial_transitions and _resolve_initial_states in extractor - Remove _is_initial_candidate from renderer (use state.is_initial) - Remove implicit transition filtering logic from renderer (use transition.is_initial) * feat: add statemachine-diagram Sphinx directive for inline diagram rendering Add a Sphinx extension that renders state machine diagrams inline in docs from an importable class path, eliminating manual DotGraphMachine/write_png boilerplate. Supports standard image/figure options (width, height, scale, align, target, class, name) and state-machine-specific options (events, caption, figclass). Key features: - Inline SVG rendering (no intermediate files) - :events: option to instantiate and advance the machine before rendering - :target: (empty) auto-generates a standalone SVG for full-size zoom - Responsive sizing: intrinsic width becomes max-width, scales down on narrow viewports - Compatible with any Sphinx theme (uses native align-center/left/right classes) Refactor diagram.md to use the directive for the visual showcase, replacing doctest+write_png workflow. Extract showcase machines to tests/machines/ and use literalinclude with :pyobject: to display source code. Reorganize the page narrative: _graph() as primary entry point, DotGraphMachine for advanced customization. * docs: add Sphinx directive release notes to 3.1.0 * test: add comprehensive tests for sphinx_ext directive (100% coverage) Cover all untested code paths: _prepare_svg, _build_svg_styles, _resolve_target, _build_wrapper_classes, _split_length, _align_spec, setup, and the full run() method with various option combinations (caption, events, alt, width, height, scale, align, target, class, figclass, name) plus error handling for invalid imports and render failures. * test: reach 100% branch coverage for diagram package Add tests for extract.py (deep history type, internal transitions with and without actions, bidirectional compound detection, invalid input, initial state fallback), dot.py (compound labels with actions, internal action format, targetless transitions, non-bidirectional anchors), and __main__.py (via runpy). Mark unreachable branch in extract.py (history states never have substates) with pragma: no cover, and add `if __name__` to coverage exclusions in pyproject.toml. * docs: add TDD and branch coverage requirements to AGENTS.md Document that tests are first-class planning requirements, not afterthoughts. 100% branch coverage is mandatory (enforced by CI), coverage must be verified before committing, and pytest fixtures should be used instead of hardcoded paths. * refactor: fix SonarCloud maintainability issues in diagram package - Use pytest.approx for float comparisons in _split_length tests - Rename unused variables to _ in _prepare_svg tests - Remove unused 'getter' parameter from _extract_transitions_from_state and _extract_all_transitions - Reduce cognitive complexity of _create_edges (30→~10) by extracting _create_single_edge, _resolve_edge_endpoints, and _build_edge_label - Reduce cognitive complexity of _render_states (17→~12) by extracting _place_extra_nodes static method * docs: replace inline diagram generation with statemachine-diagram directive Convert tutorial.md and transitions.md to use the directive instead of doctest write_png + image references. Extract CoffeeOrder, OrderWorkflow, and OrderWorkflowCompound to tests/machines/ for importability. Add tip about Graphviz requirement and seealso linking to diagram.md for deeper coverage (Sphinx directive, Jupyter, QuickChart, etc.). Delete the 3 PNG files that are no longer referenced.
1 parent cac607a commit 4953985

36 files changed

Lines changed: 2757 additions & 528 deletions

AGENTS.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,26 @@ async def test_something(self, sm_runner):
160160

161161
Do **not** manually add async no-op listeners or duplicate test classes — prefer `sm_runner`.
162162

163+
### TDD and coverage requirements
164+
165+
Follow a **test-driven development** approach: tests are not an afterthought — they are a
166+
first-class requirement that must be part of every implementation plan.
167+
168+
- **Planning phase:** every plan must include test tasks as explicit steps, not a final
169+
"add tests" bullet. Identify what needs to be tested (new branches, edge cases, error
170+
paths) while designing the implementation.
171+
- **100% branch coverage is mandatory.** The pre-commit hook enforces `--cov-fail-under=100`
172+
with branch coverage enabled. Code that drops coverage will not pass CI.
173+
- **Verify coverage before committing:** after writing tests, run coverage on the affected
174+
modules and check for missing lines/branches:
175+
```bash
176+
timeout 120 uv run pytest tests/<test_file>.py --cov=statemachine.<module> --cov-report=term-missing --cov-branch
177+
```
178+
- **Use pytest fixtures** (`tmp_path`, `monkeypatch`, etc.) — never hardcode paths or
179+
use mutable global state when a fixture exists.
180+
- **Unreachable defensive branches** (e.g., `if` guards that can never be True given the
181+
type system) may be marked with `pragma: no cover`, but prefer writing a test first.
182+
163183
## Linting and formatting
164184

165185
```bash

docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"sphinx.ext.autosectionlabel",
5252
"sphinx_gallery.gen_gallery",
5353
"sphinx_copybutton",
54+
"statemachine.contrib.diagram.sphinx_ext",
5455
]
5556

5657
autosectionlabel_prefix_document = True

0 commit comments

Comments
 (0)