Repository: fgmacedo/python-statemachine Branch: develop Commit: ee3607a7b9b5 Files: 518 Total size: 1.6 MB Directory structure: gitextract_3onmcp2r/ ├── .git-blame-ignore-revs ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE.md │ └── workflows/ │ ├── python-package.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── AGENTS.md ├── LICENSE ├── README.md ├── conftest.py ├── contributing.md ├── docs/ │ ├── _static/ │ │ └── custom_machine.css │ ├── actions.md │ ├── api.md │ ├── async.md │ ├── authors.md │ ├── behaviour.md │ ├── concepts.md │ ├── conf.py │ ├── contributing.md │ ├── diagram.md │ ├── error_handling.md │ ├── events.md │ ├── guards.md │ ├── how-to/ │ │ ├── coming_from_state_pattern.md │ │ └── coming_from_transitions.md │ ├── index.md │ ├── installation.md │ ├── integrations.md │ ├── invoke.md │ ├── listeners.md │ ├── models.md │ ├── processing_model.md │ ├── releases/ │ │ ├── 0.1.0.md │ │ ├── 0.2.0.md │ │ ├── 0.3.0.md │ │ ├── 0.4.2.md │ │ ├── 0.5.0.md │ │ ├── 0.5.1.md │ │ ├── 0.6.0.md │ │ ├── 0.6.1.md │ │ ├── 0.6.2.md │ │ ├── 0.7.0.md │ │ ├── 0.7.1.md │ │ ├── 0.8.0.md │ │ ├── 0.9.0.md │ │ ├── 1.0.0.md │ │ ├── 1.0.1.md │ │ ├── 1.0.2.md │ │ ├── 1.0.3.md │ │ ├── 2.0.0.md │ │ ├── 2.1.0.md │ │ ├── 2.1.1.md │ │ ├── 2.1.2.md │ │ ├── 2.2.0.md │ │ ├── 2.3.0.md │ │ ├── 2.3.1.md │ │ ├── 2.3.2.md │ │ ├── 2.3.3.md │ │ ├── 2.3.4.md │ │ ├── 2.3.5.md │ │ ├── 2.3.6.md │ │ ├── 2.4.0.md │ │ ├── 2.5.0.md │ │ ├── 2.6.0.md │ │ ├── 3.0.0.md │ │ ├── 3.1.0.md │ │ ├── index.md │ │ └── upgrade_2x_to_3.md │ ├── statechart.md │ ├── states.md │ ├── timeout.md │ ├── transitions.md │ ├── tutorial.md │ ├── validations.md │ └── weighted_transitions.md ├── pyproject.toml ├── statemachine/ │ ├── __init__.py │ ├── callbacks.py │ ├── configuration.py │ ├── contrib/ │ │ ├── __init__.py │ │ ├── diagram/ │ │ │ ├── __init__.py │ │ │ ├── __main__.py │ │ │ ├── extract.py │ │ │ ├── formatter.py │ │ │ ├── model.py │ │ │ ├── renderers/ │ │ │ │ ├── __init__.py │ │ │ │ ├── dot.py │ │ │ │ ├── mermaid.py │ │ │ │ └── table.py │ │ │ └── sphinx_ext.py │ │ ├── timeout.py │ │ └── weighted.py │ ├── dispatcher.py │ ├── engines/ │ │ ├── __init__.py │ │ ├── async_.py │ │ ├── base.py │ │ └── sync.py │ ├── event.py │ ├── event_data.py │ ├── events.py │ ├── exceptions.py │ ├── factory.py │ ├── graph.py │ ├── i18n.py │ ├── invoke.py │ ├── io/ │ │ ├── __init__.py │ │ └── scxml/ │ │ ├── __init__.py │ │ ├── actions.py │ │ ├── invoke.py │ │ ├── parser.py │ │ ├── processor.py │ │ └── schema.py │ ├── locale/ │ │ ├── en/ │ │ │ └── LC_MESSAGES/ │ │ │ └── statemachine.po │ │ ├── hi_IN/ │ │ │ └── LC_MESSAGES/ │ │ │ └── statemachine.po │ │ ├── pt_BR/ │ │ │ └── LC_MESSAGES/ │ │ │ └── statemachine.po │ │ └── zh_CN/ │ │ └── LC_MESSAGES/ │ │ └── statemachine.po │ ├── mixins.py │ ├── model.py │ ├── orderedset.py │ ├── py.typed │ ├── registry.py │ ├── signature.py │ ├── spec_parser.py │ ├── state.py │ ├── statemachine.py │ ├── states.py │ ├── transition.py │ ├── transition_list.py │ ├── transition_mixin.py │ └── utils.py └── tests/ ├── __init__.py ├── conftest.py ├── django_project/ │ ├── app.py │ ├── core/ │ │ ├── __init__,.py │ │ ├── settings.py │ │ └── wsgi.py │ ├── manage.py │ └── workflow/ │ ├── __init__.py │ ├── apps.py │ ├── models.py │ ├── statemachines.py │ └── tests.py ├── examples/ │ ├── README.rst │ ├── __init__.py │ ├── ai_shell_machine.py │ ├── air_conditioner_machine.py │ ├── all_actions_machine.py │ ├── async_guess_the_number_machine.py │ ├── async_without_loop_machine.py │ ├── enum_campaign_machine.py │ ├── guess_the_number_machine.py │ ├── lor_machine.py │ ├── order_control_machine.py │ ├── order_control_rich_model_machine.py │ ├── persistent_model_machine.py │ ├── recursive_event_machine.py │ ├── reusing_transitions_machine.py │ ├── sqlite_persistent_model_machine.py │ ├── statechart_cleanup_machine.py │ ├── statechart_compound_machine.py │ ├── statechart_delayed_machine.py │ ├── statechart_error_handling_machine.py │ ├── statechart_eventless_machine.py │ ├── statechart_history_machine.py │ ├── statechart_in_condition_machine.py │ ├── statechart_parallel_machine.py │ ├── traffic_light_machine.py │ ├── user_machine.py │ └── weighted_idle_machine.py ├── helpers.py ├── machines/ │ ├── __init__.py │ ├── compound/ │ │ ├── __init__.py │ │ ├── middle_earth_journey.py │ │ ├── middle_earth_journey_two_compounds.py │ │ ├── middle_earth_journey_with_finals.py │ │ ├── moria_expedition.py │ │ ├── moria_expedition_with_escape.py │ │ ├── quest_for_erebor.py │ │ └── shire_to_rivendell.py │ ├── donedata/ │ │ ├── __init__.py │ │ ├── destroy_the_ring.py │ │ ├── destroy_the_ring_simple.py │ │ ├── nested_quest_donedata.py │ │ ├── quest_for_erebor_done_convention.py │ │ ├── quest_for_erebor_explicit_id.py │ │ ├── quest_for_erebor_multi_word.py │ │ └── quest_for_erebor_with_event.py │ ├── error/ │ │ ├── __init__.py │ │ ├── error_convention_event.py │ │ ├── error_convention_transition_list.py │ │ ├── error_in_action_sc.py │ │ ├── error_in_action_sm_with_flag.py │ │ ├── error_in_after_sc.py │ │ ├── error_in_error_handler_sc.py │ │ ├── error_in_guard_sc.py │ │ ├── error_in_guard_sm.py │ │ └── error_in_on_enter_sc.py │ ├── eventless/ │ │ ├── __init__.py │ │ ├── auto_advance.py │ │ ├── beacon_chain.py │ │ ├── beacon_chain_lighting.py │ │ ├── coordinated_advance.py │ │ ├── ring_corruption.py │ │ ├── ring_corruption_with_bear_ring.py │ │ └── ring_corruption_with_tick.py │ ├── history/ │ │ ├── __init__.py │ │ ├── deep_memory_of_moria.py │ │ ├── gollum_personality.py │ │ ├── gollum_personality_default_gollum.py │ │ ├── gollum_personality_with_default.py │ │ └── shallow_moria.py │ ├── in_condition/ │ │ ├── __init__.py │ │ ├── combined_guard.py │ │ ├── descendant_check.py │ │ ├── eventless_in.py │ │ ├── fellowship.py │ │ ├── fellowship_coordination.py │ │ └── gate_of_moria.py │ ├── parallel/ │ │ ├── __init__.py │ │ ├── session.py │ │ ├── session_with_done_state.py │ │ ├── two_towers.py │ │ ├── war_of_the_ring.py │ │ └── war_with_exit.py │ ├── showcase_actions.py │ ├── showcase_compound.py │ ├── showcase_deep_history.py │ ├── showcase_guards.py │ ├── showcase_history.py │ ├── showcase_internal.py │ ├── showcase_parallel.py │ ├── showcase_parallel_compound.py │ ├── showcase_self_transition.py │ ├── showcase_simple.py │ ├── transition_from_any.py │ ├── tutorial_coffee_order.py │ ├── validators/ │ │ ├── __init__.py │ │ ├── multi_validator.py │ │ ├── order_validation.py │ │ ├── order_validation_no_error_events.py │ │ ├── validator_fallthrough.py │ │ ├── validator_with_cond.py │ │ └── validator_with_error_transition.py │ └── workflow/ │ ├── __init__.py │ ├── campaign_machine.py │ ├── campaign_machine_with_validator.py │ ├── campaign_machine_with_values.py │ └── reverse_traffic_light.py ├── models.py ├── scrape_images.py ├── scxml/ │ ├── __init__.py │ ├── conftest.py │ ├── test_microwave.py │ ├── test_scxml_cases.py │ └── w3c/ │ ├── LICENSE │ ├── mandatory/ │ │ ├── test144.scxml │ │ ├── test145.scxml │ │ ├── test147.scxml │ │ ├── test148.scxml │ │ ├── test149.scxml │ │ ├── test150.scxml │ │ ├── test151.scxml │ │ ├── test152.scxml │ │ ├── test153.scxml │ │ ├── test155.scxml │ │ ├── test156.scxml │ │ ├── test158.scxml │ │ ├── test159.scxml │ │ ├── test172.scxml │ │ ├── test173.scxml │ │ ├── test174.scxml │ │ ├── test175.scxml │ │ ├── test176.scxml │ │ ├── test179.scxml │ │ ├── test183.scxml │ │ ├── test185.scxml │ │ ├── test186.scxml │ │ ├── test187.scxml │ │ ├── test189.scxml │ │ ├── test190.scxml │ │ ├── test191.scxml │ │ ├── test192.scxml │ │ ├── test194.scxml │ │ ├── test198.scxml │ │ ├── test199.scxml │ │ ├── test200.scxml │ │ ├── test205.scxml │ │ ├── test207.scxml │ │ ├── test208.scxml │ │ ├── test210.scxml │ │ ├── test215.scxml │ │ ├── test216.scxml │ │ ├── test216sub1.scxml │ │ ├── test220.scxml │ │ ├── test223.scxml │ │ ├── test224.scxml │ │ ├── test225.scxml │ │ ├── test226.scxml │ │ ├── test226sub1.scxml │ │ ├── test228.scxml │ │ ├── test229.scxml │ │ ├── test232.scxml │ │ ├── test233.scxml │ │ ├── test234.scxml │ │ ├── test235.scxml │ │ ├── test236.scxml │ │ ├── test237.scxml │ │ ├── test239.scxml │ │ ├── test239sub1.scxml │ │ ├── test240.scxml │ │ ├── test241.scxml │ │ ├── test242.scxml │ │ ├── test242sub1.scxml │ │ ├── test243.scxml │ │ ├── test244.scxml │ │ ├── test245.scxml │ │ ├── test247.scxml │ │ ├── test252.scxml │ │ ├── test253.scxml │ │ ├── test276.scxml │ │ ├── test276sub1.scxml │ │ ├── test277.scxml │ │ ├── test279.scxml │ │ ├── test280.scxml │ │ ├── test286.scxml │ │ ├── test287.scxml │ │ ├── test294.scxml │ │ ├── test298.scxml │ │ ├── test302.scxml │ │ ├── test303.scxml │ │ ├── test304.scxml │ │ ├── test309.scxml │ │ ├── test310.scxml │ │ ├── test311.scxml │ │ ├── test312.scxml │ │ ├── test318.scxml │ │ ├── test319.scxml │ │ ├── test321.scxml │ │ ├── test322.scxml │ │ ├── test323.scxml │ │ ├── test324.scxml │ │ ├── test325.scxml │ │ ├── test326.scxml │ │ ├── test329.scxml │ │ ├── test330.scxml │ │ ├── test331.scxml │ │ ├── test332.scxml │ │ ├── test333.scxml │ │ ├── test335.scxml │ │ ├── test336.scxml │ │ ├── test337.scxml │ │ ├── test338.scxml │ │ ├── test339.scxml │ │ ├── test342.scxml │ │ ├── test343.scxml │ │ ├── test344.scxml │ │ ├── test346.scxml │ │ ├── test347.scxml │ │ ├── test348.scxml │ │ ├── test349.scxml │ │ ├── test350.scxml │ │ ├── test351.scxml │ │ ├── test352.scxml │ │ ├── test354.scxml │ │ ├── test355.scxml │ │ ├── test364.scxml │ │ ├── test372.scxml │ │ ├── test375.scxml │ │ ├── test376.scxml │ │ ├── test377.scxml │ │ ├── test378.scxml │ │ ├── test387.scxml │ │ ├── test388.scxml │ │ ├── test396.scxml │ │ ├── test399.scxml │ │ ├── test401.scxml │ │ ├── test402.scxml │ │ ├── test403a.scxml │ │ ├── test403b.scxml │ │ ├── test403c.scxml │ │ ├── test404.scxml │ │ ├── test405.scxml │ │ ├── test406.scxml │ │ ├── test407.scxml │ │ ├── test409.scxml │ │ ├── test411.scxml │ │ ├── test412.scxml │ │ ├── test413.scxml │ │ ├── test416.scxml │ │ ├── test417.scxml │ │ ├── test419.scxml │ │ ├── test421.scxml │ │ ├── test422.scxml │ │ ├── test423.scxml │ │ ├── test487.scxml │ │ ├── test488.scxml │ │ ├── test495.scxml │ │ ├── test496.scxml │ │ ├── test500.scxml │ │ ├── test501.scxml │ │ ├── test503.scxml │ │ ├── test504.scxml │ │ ├── test505.scxml │ │ ├── test506.scxml │ │ ├── test521.scxml │ │ ├── test525.scxml │ │ ├── test527.scxml │ │ ├── test528.scxml │ │ ├── test529.scxml │ │ ├── test530.scxml │ │ ├── test533.scxml │ │ ├── test550.scxml │ │ ├── test551.scxml │ │ ├── test552.scxml │ │ ├── test552.txt │ │ ├── test553.scxml │ │ ├── test554.scxml │ │ ├── test570.scxml │ │ ├── test576.scxml │ │ ├── test579.scxml │ │ └── test580.scxml │ └── optional/ │ ├── test193.scxml │ ├── test201.scxml │ ├── test278.scxml │ ├── test444.scxml │ ├── test445.scxml │ ├── test446.scxml │ ├── test446.txt │ ├── test448.scxml │ ├── test449.scxml │ ├── test451.scxml │ ├── test452.scxml │ ├── test453.scxml │ ├── test456.scxml │ ├── test457.scxml │ ├── test459.scxml │ ├── test460.scxml │ ├── test509.scxml │ ├── test510.scxml │ ├── test518.scxml │ ├── test519.scxml │ ├── test520.scxml │ ├── test522.scxml │ ├── test531.scxml │ ├── test532.scxml │ ├── test534.scxml │ ├── test557.scxml │ ├── test557.txt │ ├── test558.scxml │ ├── test558.txt │ ├── test560.scxml │ ├── test561.scxml │ ├── test562.scxml │ ├── test567.scxml │ ├── test569.scxml │ ├── test577.scxml │ └── test578.scxml ├── test_actions.py ├── test_api_contract.py ├── test_async.py ├── test_async_futures.py ├── test_callbacks.py ├── test_callbacks_isolation.py ├── test_class_listeners.py ├── test_conditions_algebra.py ├── test_configuration.py ├── test_contrib_diagram.py ├── test_contrib_timeout.py ├── test_copy.py ├── test_dispatcher.py ├── test_error_execution.py ├── test_events.py ├── test_examples.py ├── test_fellowship_quest.py ├── test_invoke.py ├── test_io.py ├── test_listener.py ├── test_mermaid_renderer.py ├── test_mixins.py ├── test_mock_compatibility.py ├── test_multiple_destinations.py ├── test_profiling.py ├── test_registry.py ├── test_rtc.py ├── test_scxml_units.py ├── test_signature.py ├── test_signature_positional_only.py ├── test_spec_parser.py ├── test_state.py ├── test_state_callbacks.py ├── test_statechart_compound.py ├── test_statechart_delayed.py ├── test_statechart_donedata.py ├── test_statechart_error.py ├── test_statechart_eventless.py ├── test_statechart_history.py ├── test_statechart_in_condition.py ├── test_statechart_parallel.py ├── test_statemachine.py ├── test_statemachine_bounded_transitions.py ├── test_statemachine_compat.py ├── test_statemachine_inheritance.py ├── test_threading.py ├── test_transition_list.py ├── test_transition_table.py ├── test_transitions.py ├── test_validators.py ├── test_weighted_transitions.py └── testcases/ ├── __init__.py ├── issue308.md ├── issue384_multiple_observers.md ├── issue449.md ├── test_issue434.py ├── test_issue480.py └── test_issue509.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .git-blame-ignore-revs ================================================ 37fcf9818178587635fffe1bb67a9fd5024a0a45 345d82390af35d5d70ddd39c612faa4a64b11080 d7738e9ad0a3e50bc5c87d4a75c436fb771c96f6 5bf10afae2b214900aa58dd44b0a91e469c70631 ================================================ FILE: .github/FUNDING.yml ================================================ github: fgmacedo ================================================ FILE: .github/ISSUE_TEMPLATE.md ================================================ * Python State Machine version: * Python version: * Operating System: ### Description Describe what you were trying to get done. Tell us what happened, what went wrong, and what you expected to happen. ### What I Did ``` Paste the command(s) you ran and the output. If there was a crash, please include the traceback here. ``` If you're reporting a bug, consider providing a complete example that can be used directly in the automated tests. We allways write tests to reproduce the issue in order to avoid future regressions. ================================================ FILE: .github/workflows/python-package.yml ================================================ # This workflow will install Python dependencies, run tests and lint with a variety of Python versions # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python name: Python checks on: push: branches: [ "develop" ] pull_request: branches: [ "develop" ] permissions: contents: read jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 - run: git fetch origin develop - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Setup Graphviz uses: ts-graphviz/setup-graphviz@v2 - name: Install uv uses: astral-sh/setup-uv@v3 with: enable-cache: true cache-suffix: "python${{ matrix.python-version }}" - name: Install the project run: uv sync --all-extras --dev #---------------------------------------------- # run ruff #---------------------------------------------- - name: Linter with ruff if: matrix.python-version == 3.14 run: | uv run ruff check . uv run ruff format --check . #---------------------------------------------- # run pytest #---------------------------------------------- - name: Test with pytest run: | uv run pytest -n auto --cov --cov-report=xml:coverage.xml uv run coverage xml #---------------------------------------------- # upload coverage #---------------------------------------------- - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 if: matrix.python-version == 3.14 with: token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos directory: . env_vars: OS,PYTHON fail_ci_if_error: true flags: unittests name: codecov-umbrella verbose: true ================================================ FILE: .github/workflows/release.yml ================================================ on: push: tags: [ 'v?*.*.*' ] name: release jobs: release-build: name: Build release artifacts runs-on: ubuntu-latest permissions: id-token: write steps: - uses: actions/checkout@v4 - run: git fetch origin develop - name: Setup Python uses: actions/setup-python@v5 with: python-version: '3.14' - name: Setup Graphviz uses: ts-graphviz/setup-graphviz@v2 - name: Install uv uses: astral-sh/setup-uv@v3 with: enable-cache: true - name: Install the project run: uv sync --all-extras --dev - name: Test run: | uv run pytest -n auto --cov - name: Build run: | uv build - name: Upload dists uses: actions/upload-artifact@v4 with: name: release-dists path: dist/ pypi-publish: # by a dedicated job to publish we avoid the risk of # running code with access to PyPI credentials name: Upload release to PyPI runs-on: ubuntu-latest needs: - release-build environment: release permissions: id-token: write steps: - name: Retrieve release distributions uses: actions/download-artifact@v4 with: name: release-dists path: dist/ - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 ================================================ FILE: .gitignore ================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg .mypy_cache # jupyter .ipynb_checkpoints/ .jupyterlite.doit.db # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports prof/ .benchmarks/ htmlcov/ .tox/ .coverage .coverage.* .cache .pytest_cache nosetests.xml coverage.xml *,cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log # Sphinx documentation docs/_build/ docs/auto_examples/ # PyBuilder target/ # pyenv python configuration file .python-version # IDEs and editors *.sublime* .idea/ .vscode/ # Sphinx-galery docs/auto_examples/sg_execution_times.* docs/auto_examples/*.pickle docs/sg_execution_times.rst # Temporary files tmp/ ================================================ FILE: .pre-commit-config.yaml ================================================ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: - id: check-yaml - id: end-of-file-fixer exclude: docs/auto_examples - id: trailing-whitespace exclude: docs/auto_examples - repo: https://github.com/charliermarsh/ruff-pre-commit # Ruff version. rev: v0.15.0 hooks: # Run the linter. - id: ruff args: [ --fix ] # Run the formatter. - id: ruff-format - repo: local hooks: - id: mypy name: Mypy entry: uv run mypy --namespace-packages --explicit-package-bases statemachine/ tests/ types: [python] language: system pass_filenames: false - id: pyright name: Pyright entry: uv run pyright statemachine/ types: [python] language: system pass_filenames: false - id: generate-images name: Generate README images entry: >- uv run python -m statemachine.contrib.diagram tests.examples.traffic_light_machine.TrafficLightMachine docs/images/readme_trafficlightmachine.png --events cycle cycle cycle language: system pass_filenames: false files: (statemachine/contrib/diagram/|tests/examples/traffic_light_machine\.py) - id: pytest name: Pytest entry: uv run pytest -n auto --cov-fail-under=100 types: [python] language: system pass_filenames: false ================================================ FILE: .readthedocs.yaml ================================================ # .readthedocs.yaml # Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details # Required version: 2 build: os: "ubuntu-22.04" tools: python: "3.14" apt_packages: - graphviz jobs: post_create_environment: - asdf plugin add uv - asdf install uv latest - asdf global uv latest - UV_PROJECT_ENVIRONMENT=$READTHEDOCS_VIRTUALENV_PATH uv sync --all-extras --frozen # Build documentation in the docs/ directory with Sphinx sphinx: configuration: docs/conf.py ================================================ FILE: AGENTS.md ================================================ # python-statemachine Python Finite State Machines made easy. ## Project overview A library for building finite state machines in Python, with support for sync and async engines, Django integration, diagram generation, and a flexible callback/listener system. - **Source code:** `statemachine/` - **Tests:** `tests/` - **Documentation:** `docs/` (Sphinx + MyST Markdown, hosted on ReadTheDocs) ## Architecture - `statemachine.py` — Core `StateMachine` and `StateChart` classes - `factory.py` — `StateMachineMetaclass` handles class construction, state/transition validation - `state.py` / `event.py` — Descriptor-based `State` and `Event` definitions - `transition.py` / `transition_list.py` — Transition logic and composition (`|` operator) - `callbacks.py` — Priority-based callback registry (`CallbackPriority`, `CallbackGroup`) - `dispatcher.py` — Listener/observer pattern, `callable_method` wraps callables with signature adaptation - `signature.py` — `SignatureAdapter` for dependency injection into callbacks - `engines/base.py` — Shared engine logic (microstep, transition selection, error handling) - `engines/sync.py`, `engines/async_.py` — Sync and async processing loops - `registry.py` — Global state machine registry (used by `MachineMixin`) - `mixins.py` — `MachineMixin` for domain model integration (e.g., Django models) - `spec_parser.py` — Boolean expression parser for condition guards - `contrib/diagram.py` — Diagram generation via pydot/Graphviz ## Processing model The engine follows the SCXML run-to-completion (RTC) model with two processing levels: - **Microstep**: atomic execution of one transition set (before → exit → on → enter → after). - **Macrostep**: complete processing cycle for one external event; repeats microsteps until the machine reaches a **stable configuration** (no eventless transitions enabled, internal queue empty). ### Event queues - `send()` → **external queue** (processed after current macrostep ends). - `raise_()` → **internal queue** (processed within the current macrostep, before external events). ### Error handling (`catch_errors_as_events`) - `StateChart` has `catch_errors_as_events=True` by default; `StateMachine` has `False`. - Errors are caught at the **block level** (per onentry/onexit/transition `on` block), not per microstep. This means `after` callbacks still run even when an action raises — making `after_()` a natural **finalize** hook (runs on both success and failure paths). - `error.execution` is dispatched as an internal event; define transitions for it to handle errors within the statechart. - Error during `error.execution` handling → ignored to prevent infinite loops. #### `on_error` asymmetry: transition `on` vs onentry/onexit Transition `on` content uses `on_error` **only for non-`error.execution` events**. During `error.execution` processing, `on_error` is disabled for transition `on` content — errors propagate to `microstep()` where `_send_error_execution` ignores them. This prevents infinite loops in self-transition error handlers (e.g., `error_execution = s1.to(s1, on="handler")` where `handler` raises). `onentry`/`onexit` blocks always use `on_error` regardless of the current event. ### Eventless transitions - Bare transition statements (not assigned to a variable) are **eventless** — they fire automatically when their guard condition is met. - Assigned transitions (e.g., `go = s1.to(s2)`) create **named events**. - `error_` prefix naming convention: `error_X` auto-registers both `error_X` and `error.X` event names (explicit `id=` takes precedence). ### Callback conventions - Generic callbacks (always available): `prepare_event()`, `before_transition()`, `on_transition()`, `on_exit_state()`, `on_enter_state()`, `after_transition()`. - Event-specific: `before_()`, `on_()`, `after_()`. - State-specific: `on_enter_()`, `on_exit_()`. - `on_error_execution()` works via naming convention but **only** when a transition for `error.execution` is declared — it is NOT a generic callback. ### Thread safety - The sync engine is **thread-safe**: multiple threads can send events to the same SM instance concurrently. The processing loop uses a `threading.Lock` so at most one thread executes transitions at a time. Event queues use `PriorityQueue` (stdlib, thread-safe). - **Do not replace `PriorityQueue`** with non-thread-safe alternatives (e.g., `collections.deque`, plain `list`) — this would break concurrent access guarantees. - Stress tests in `tests/test_threading.py::TestThreadSafety` exercise real contention with barriers and multiple sender threads. Any change to queue or locking internals must pass these. ### Invoke (``) - `invoke.py` — `InvokeManager` on the engine manages the lifecycle: `mark_for_invoke()`, `cancel_for_state()`, `spawn_pending_sync/async()`, `send_to_child()`. - `_cleanup_terminated()` only removes invocations that are both terminated **and** cancelled. A terminated-but-not-cancelled invocation means the handler's `run()` returned but the owning state is still active — it must stay in `_active` so `send_to_child()` can still route events. - **Child machine constructor blocks** in the processing loop. Use a listener pattern (e.g., `_ChildRefSetter`) to capture the child reference during the first `on_enter_state`, before the loop spins. - `#_` send target: routed via `_send_to_invoke()` in `io/scxml/actions.py` → `InvokeManager.send_to_child()` → handler's `on_event()`. - **Tests with blocking threads**: use `threading.Event.wait(timeout=)` instead of `time.sleep()` for interruptible waits — avoids thread leak errors in teardown. ## Environment setup ```bash uv sync --all-extras --dev pre-commit install ``` ## Running tests Always use `uv` to run commands. Also, use a timeout to avoid being stuck in the case of a leaked thread or infinite loop: ```bash # Run all tests (parallel) timeout 120 uv run pytest -n 4 # Run a specific test file uv run pytest tests/test_signature.py # Run a specific test uv run pytest tests/test_signature.py::TestSignatureAdapter::test_wrap_fn_single_positional_parameter # Skip slow tests uv run pytest -m "not slow" ``` When trying to run all tests, prefer to use xdist (`-n`) as some SCXML tests uses timeout of 30s to verify fallback mechanism. Don't specify the directory `tests/`, because this will exclude doctests from both source modules (`--doctest-modules`) and markdown docs (`--doctest-glob=*.md`) (enabled by default): ```bash timeout 120 uv run pytest -n 4 ``` Testes normally run under 60s (~40s on average), so take a closer look if they take longer, it can be a regression. ### Debug logging `log_cli_level` defaults to `WARNING` in `pyproject.toml`. The engine caches a no-op for `logger.debug` at init time — running tests with `DEBUG` would bypass this optimization and inflate benchmark numbers. To enable debug logs for a specific run: ```bash uv run pytest -o log_cli_level=DEBUG tests/test_something.py ``` When analyzing warnings or extensive output, run the tests **once** saving the output to a file (`> /tmp/pytest-output.txt 2>&1`), then analyze the file — instead of running the suite repeatedly with different greps. Coverage is enabled by default (`--cov` is in `pyproject.toml`'s `addopts`). To generate a coverage report to a file, pass `--cov-report` **in addition to** `--cov`: ```bash # JSON report (machine-readable, includes missing_lines per file) timeout 120 uv run pytest -n auto --cov=statemachine --cov-report=json:cov.json # Terminal report with missing lines timeout 120 uv run pytest -n auto --cov=statemachine --cov-report=term-missing ``` Note: `--cov=statemachine` is required to activate coverage collection; `--cov-report` alone only changes the output format. ### Testing both sync and async engines Use the `sm_runner` fixture (from `tests/conftest.py`) when you need to test the same statechart on both sync and async engines. It is parametrized with `["sync", "async"]` and provides `start()` / `send()` helpers that handle engine selection automatically: ```python async def test_something(self, sm_runner): sm = await sm_runner.start(MyStateChart) await sm_runner.send(sm, "some_event") assert "expected_state" in sm.configuration_values ``` Do **not** manually add async no-op listeners or duplicate test classes — prefer `sm_runner`. ### TDD and coverage requirements Follow a **test-driven development** approach: tests are not an afterthought — they are a first-class requirement that must be part of every implementation plan. - **Planning phase:** every plan must include test tasks as explicit steps, not a final "add tests" bullet. Identify what needs to be tested (new branches, edge cases, error paths) while designing the implementation. - **100% branch coverage is mandatory.** The pre-commit hook enforces `--cov-fail-under=100` with branch coverage enabled. Code that drops coverage will not pass CI. - **Verify coverage before committing:** after writing tests, run coverage on the affected modules and check for missing lines/branches: ```bash timeout 120 uv run pytest tests/.py --cov=statemachine. --cov-report=term-missing --cov-branch ``` - **Use pytest fixtures** (`tmp_path`, `monkeypatch`, etc.) — never hardcode paths or use mutable global state when a fixture exists. - **Unreachable defensive branches** (e.g., `if` guards that can never be True given the type system) may be marked with `pragma: no cover`, but prefer writing a test first. ## Linting and formatting ```bash # Lint uv run ruff check . # Auto-fix lint issues uv run ruff check --fix . # Format uv run ruff format . # Type check uv run mypy statemachine/ tests/ ``` ## Code style - **Formatter/Linter:** ruff (line length 99, target Python 3.9) - **Rules:** pycodestyle, pyflakes, isort, pyupgrade, flake8-comprehensions, flake8-bugbear, flake8-pytest-style - **Imports:** single-line, sorted by isort. **Always prefer top-level imports** — only use lazy (in-function) imports when strictly necessary to break circular dependencies - **Docstrings:** Google convention - **Naming:** PascalCase for classes, snake_case for functions/methods, UPPER_SNAKE_CASE for constants - **Type hints:** used throughout; `TYPE_CHECKING` for circular imports - Pre-commit hooks enforce ruff + mypy + pytest ## Design principles - **Use GRASP/SOLID patterns to guide decisions.** When refactoring or designing, explicitly apply patterns like Information Expert, Single Responsibility, and Law of Demeter to decide where logic belongs — don't just pick a convenient location. - **Information Expert (GRASP):** Place logic in the module/class that already has the knowledge it needs. If a method computes a result, it should signal or return it rather than forcing another method to recompute the same thing. - **Law of Demeter:** Methods should depend only on the data they need, not on the objects that contain it. Pass the specific value (e.g., a `Future`) rather than the parent object (e.g., `TriggerData`) — this reduces coupling and removes the need for null-checks on intermediate accessors. - **Single Responsibility:** Each module, class, and function should have one clear reason to change. Functions and types belong in the module that owns their domain (e.g., event-name helpers belong in `event.py`, not in `factory.py`). - **Interface Segregation:** Depend on narrow interfaces. If a helper only needs one field from a dataclass, accept that field directly. - **Decouple infrastructure from domain:** Modules like `signature.py` and `dispatcher.py` are general-purpose (signature adaptation, listener/observer pattern) and intentionally not coupled to the state machine domain. Prefer this separation even for modules that are only used internally — it keeps responsibilities clear and the code easier to reason about. - **Favor small, focused modules:** When adding new functionality, consider whether it can live in its own module with a well-defined boundary, rather than growing an existing one. ## Building documentation ```bash # Build HTML docs uv run sphinx-build docs docs/_build/html # Live reload for development uv run sphinx-autobuild docs docs/_build/html --re-ignore "auto_examples/.*" ``` ### Documentation code examples All code examples in `docs/*.md` **must** be testable doctests (using ```` ```py ```` with `>>>` prompts), not plain ```` ```python ```` blocks. The test suite collects them via `--doctest-glob=*.md`. If an example cannot be expressed as a doctest (e.g., it requires real concurrency), write it as a unit test in `tests/` and reference it from the docs instead. ## Git workflow - Main branch: `develop` - PRs target `develop` - Releases are tagged as `v*.*.*` - Signed commits preferred (`git commit -s`) - Use [Conventional Commits](https://www.conventionalcommits.org/) messages (e.g., `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`, `perf:`) ## Security - Do not commit secrets, credentials, or `.env` files - Validate at system boundaries; trust internal code ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2017, Fernando Macedo Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Python StateMachine [![pypi](https://img.shields.io/pypi/v/python-statemachine.svg)](https://pypi.python.org/pypi/python-statemachine) [![downloads total](https://static.pepy.tech/badge/python-statemachine)](https://pepy.tech/project/python-statemachine) [![downloads](https://img.shields.io/pypi/dm/python-statemachine.svg)](https://pypi.python.org/pypi/python-statemachine) [![Coverage report](https://codecov.io/gh/fgmacedo/python-statemachine/branch/develop/graph/badge.svg)](https://codecov.io/gh/fgmacedo/python-statemachine) [![Documentation Status](https://readthedocs.org/projects/python-statemachine/badge/?version=latest)](https://python-statemachine.readthedocs.io/en/latest/?badge=latest) [![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) Expressive [statecharts](https://statecharts.dev/) and [FSMs](https://en.wikipedia.org/wiki/Finite-state_machine) for modern Python.
![](https://github.com/fgmacedo/python-statemachine/blob/develop/docs/images/python-statemachine.png?raw=true)
Welcome to python-statemachine, an intuitive and powerful state machine library designed for a great developer experience. Define flat state machines or full statecharts with compound states, parallel regions, and history — all with a clean, _pythonic_, declarative API that works in both sync and async Python codebases. ## Quick start ```py >>> from statemachine import StateChart, State >>> class TrafficLightMachine(StateChart): ... "A traffic light machine" ... green = State(initial=True) ... yellow = State() ... red = State() ... ... cycle = ( ... green.to(yellow) ... | yellow.to(red) ... | red.to(green) ... ) ... ... def before_cycle(self, event: str, source: State, target: State): ... return f"Running {event} from {source.id} to {target.id}" ... ... def on_enter_red(self): ... print("Don't move.") ... ... def on_exit_red(self): ... print("Go ahead!") ``` Create an instance and send events: ```py >>> sm = TrafficLightMachine() >>> sm.send("cycle") 'Running cycle from green to yellow' >>> sm.send("cycle") Don't move. 'Running cycle from yellow to red' >>> sm.send("cycle") Go ahead! 'Running cycle from red to green' ``` Check which states are active: ```py >>> sm.configuration OrderedSet([State('Green', id='green', value='green', initial=True, final=False, parallel=False)]) >>> sm.green.is_active True ``` Generate a diagram or get a text representation with f-strings: ```py >>> print(f"{sm:md}") | State | Event | Guard | Target | | ------ | ----- | ----- | ------ | | Green | Cycle | | Yellow | | Yellow | Cycle | | Red | | Red | Cycle | | Green | ``` ```python sm._graph().write_png("traffic_light.png") ``` ![](https://raw.githubusercontent.com/fgmacedo/python-statemachine/develop/docs/images/readme_trafficlightmachine.png) Parameters are injected into callbacks automatically — the library inspects the signature and provides only the arguments each callback needs: ```py >>> sm.send("cycle") 'Running cycle from green to yellow' ``` ## Guards and conditional transitions Use `cond=` and `unless=` to add guards. When multiple transitions share the same event, declaration order determines priority: ```py >>> from statemachine import StateChart, State >>> class ApprovalWorkflow(StateChart): ... pending = State(initial=True) ... approved = State(final=True) ... rejected = State(final=True) ... ... review = ( ... pending.to(approved, cond="is_valid") ... | pending.to(rejected) ... ) ... ... def is_valid(self, score: int = 0): ... return score >= 70 >>> sm = ApprovalWorkflow() >>> sm.send("review", score=50) >>> sm.rejected.is_active True >>> sm = ApprovalWorkflow() >>> sm.send("review", score=85) >>> sm.approved.is_active True ``` The first transition whose guard passes wins. When `score < 70`, `is_valid` returns `False` so the second transition (no guard — always matches) fires instead. ## Compound states — hierarchy Break complex behavior into hierarchical levels with `State.Compound`. Entering a compound activates both the parent and its `initial` child. Exiting removes the parent and all descendants: ```py >>> from statemachine import StateChart, State >>> class DocumentWorkflow(StateChart): ... class editing(State.Compound): ... draft = State(initial=True) ... review = State() ... submit = draft.to(review) ... revise = review.to(draft) ... ... published = State(final=True) ... approve = editing.to(published) >>> sm = DocumentWorkflow() >>> set(sm.configuration_values) == {"editing", "draft"} True >>> sm.send("submit") >>> "review" in sm.configuration_values True >>> sm.send("approve") >>> set(sm.configuration_values) == {"published"} True ``` ## Parallel states — concurrency `State.Parallel` activates all child regions simultaneously. Events in one region don't affect others. A `done.state` event fires only when **all** regions reach a final state: ```py >>> from statemachine import StateChart, State >>> class DeployPipeline(StateChart): ... class deploy(State.Parallel): ... class build(State.Compound): ... compiling = State(initial=True) ... compiled = State(final=True) ... finish_build = compiling.to(compiled) ... class tests(State.Compound): ... running = State(initial=True) ... passed = State(final=True) ... finish_tests = running.to(passed) ... released = State(final=True) ... done_state_deploy = deploy.to(released) >>> sm = DeployPipeline() >>> "compiling" in sm.configuration_values and "running" in sm.configuration_values True >>> sm.send("finish_build") >>> "compiled" in sm.configuration_values and "running" in sm.configuration_values True >>> sm.send("finish_tests") >>> set(sm.configuration_values) == {"released"} True ``` ## History states `HistoryState()` records which child was active when a compound is exited. Re-entering via the history pseudo-state restores the previous child instead of starting from the initial one: ```py >>> from statemachine import HistoryState, StateChart, State >>> class EditorWithHistory(StateChart): ... class editor(State.Compound): ... source = State(initial=True) ... visual = State() ... h = HistoryState() ... toggle = source.to(visual) | visual.to(source) ... settings = State() ... open_settings = editor.to(settings) ... back = settings.to(editor.h) >>> sm = EditorWithHistory() >>> sm.send("toggle") >>> "visual" in sm.configuration_values True >>> sm.send("open_settings") >>> sm.send("back") >>> "visual" in sm.configuration_values True ``` Use `HistoryState(type="deep")` for deep history that remembers the exact leaf state across nested compounds. ## Eventless transitions Transitions without an event trigger fire automatically. With a guard, they fire after any event processing when the condition is met: ```py >>> from statemachine import StateChart, State >>> class AutoCounter(StateChart): ... counting = State(initial=True) ... done = State(final=True) ... ... counting.to(done, cond="limit_reached") ... increment = counting.to.itself(internal=True, on="do_increment") ... ... count = 0 ... ... def do_increment(self): ... self.count += 1 ... def limit_reached(self): ... return self.count >= 3 >>> sm = AutoCounter() >>> sm.send("increment") >>> sm.send("increment") >>> "counting" in sm.configuration_values True >>> sm.send("increment") >>> "done" in sm.configuration_values True ``` ## Error handling When using `StateChart`, runtime exceptions in callbacks are caught and turned into `error.execution` events. Define a transition for that event to handle errors within the state machine itself: ```py >>> from statemachine import StateChart, State >>> class ResilientService(StateChart): ... running = State(initial=True) ... failed = State(final=True) ... ... process = running.to(running, on="do_work") ... error_execution = running.to(failed) ... ... def do_work(self): ... raise RuntimeError("something broke") >>> sm = ResilientService() >>> sm.send("process") >>> sm.failed.is_active True ``` ## Async support Async callbacks just work — same API, no changes needed. The engine detects async callbacks and switches to the async engine automatically: ```py >>> import asyncio >>> from statemachine import StateChart, State >>> class AsyncWorkflow(StateChart): ... idle = State(initial=True) ... done = State(final=True) ... ... finish = idle.to(done) ... ... async def on_finish(self): ... return 42 >>> async def run(): ... sm = AsyncWorkflow() ... result = await sm.finish() ... print(f"Result: {result}") ... print(sm.done.is_active) >>> asyncio.run(run()) Result: 42 True ``` ## More features There's a lot more to explore: - **DoneData** on final states — pass structured data to `done.state` handlers - **Delayed events** — schedule events with `sm.send("event", delay=500)` - **`In(state)` conditions** — cross-region guards in parallel states - **`prepare_event`** callback — inject custom data into all callbacks - **Observer pattern** — register external listeners to watch events and state changes - **Django integration** — auto-discover state machines in Django apps with `MachineMixin` - **Diagram generation** — via f-strings (`f"{sm:mermaid}"`), CLI, Sphinx directive, or Jupyter - **Dictionary-based definitions** — create state machines from data structures - **Internationalization** — error messages in multiple languages Full documentation: https://python-statemachine.readthedocs.io ## Installing ``` pip install python-statemachine ``` To generate diagrams, install with the `diagrams` extra (requires [Graphviz](https://graphviz.org/)): ``` pip install python-statemachine[diagrams] ``` ## Contributing - If you found this project helpful, please consider giving it a star on GitHub. - **Contribute code**: If you would like to contribute code, please submit a pull request. For more information on how to contribute, please see our [contributing.md](contributing.md) file. - **Report bugs**: If you find any bugs, please report them by opening an issue on our GitHub issue tracker. - **Suggest features**: If you have an idea for a new feature, or feel something is harder than it should be, please let us know by opening an issue on our GitHub issue tracker. - **Documentation**: Help improve documentation by submitting pull requests. - **Promote the project**: Help spread the word by sharing on social media, writing a blog post, or giving a talk about it. Tag me on Twitter [@fgmacedo](https://twitter.com/fgmacedo) so I can share it too! ================================================ FILE: conftest.py ================================================ import shutil import sys import pytest @pytest.fixture(autouse=True, scope="session") def add_doctest_context(doctest_namespace): # noqa: PT004 from statemachine.utils import run_async_from_sync from statemachine import State from statemachine import StateChart from statemachine import StateMachine class ContribAsyncio: """ Using `run_async_from_sync` to be injected in the doctests to better integration with an already running loop, as all of our examples are also automated executed as doctests. On real life code you should use standard `import asyncio; asyncio.run(main())`. """ def __init__(self): self.run = run_async_from_sync doctest_namespace["State"] = State doctest_namespace["StateChart"] = StateChart doctest_namespace["StateMachine"] = StateMachine doctest_namespace["asyncio"] = ContribAsyncio() def pytest_ignore_collect(collection_path, config): if sys.version_info >= (3, 10): # noqa: UP036 return None if "django_project" in str(collection_path): return True @pytest.fixture(scope="session") def has_dot_installed(): return bool(shutil.which("dot")) @pytest.fixture() def requires_dot_installed(request, has_dot_installed): if not has_dot_installed: pytest.skip(f"Test {request.node.nodeid} requires 'dot' that is not installed.") ================================================ FILE: contributing.md ================================================ Please see [docs/contributing.md](docs/contributing). ================================================ FILE: docs/_static/custom_machine.css ================================================ /* div.sphx-glr-download { height: 0px; visibility: hidden; } */ @media only screen and (min-width: 650px) { .sphx-glr-thumbnails { grid-template-columns: repeat(auto-fill, minmax(600px, 1fr)) !important; } .sphx-glr-thumbcontainer { min-height: 320px !important; margin: 20px !important; justify-content: center; } .sphx-glr-thumbcontainer .figure { width: 600px !important; } .sphx-glr-thumbcontainer img { max-height: 250px !important; max-width: 600px !important; width: 100% !important; } .sphx-glr-thumbcontainer a.internal { padding: 20px 10px 0 !important; } } /* Gallery Donwload buttons */ div.sphx-glr-download a { color: #404040 !important; background-color: #f3f6f6 !important; background-image: none; border-radius: 4px; border: none; display: inline-block; font-weight: bold; padding: 1ex; text-align: center; } div.sphx-glr-download code.download { display: inline-block; white-space: normal; word-break: normal; overflow-wrap: break-word; /* border and background are given by the enclosing 'a' */ border: none; background: none; } div.sphx-glr-download a:hover { box-shadow: none; text-decoration: none; background-image: none; background-color: #e5ebeb !important; } ================================================ FILE: docs/actions.md ================================================ (actions)= # Actions ```{seealso} New to statecharts? See [](concepts.md) for an overview of how states, transitions, events, and actions fit together. ``` An **action** is a side-effect that runs during a state change — sending notifications, updating a database, logging, or returning a value. Actions are the main reason statecharts exist: they ensure the right code runs at the right time, depending on the sequence of events and the current state. ## Execution order A single {ref}`microstep ` executes callbacks in a fixed sequence of **groups**. Each group runs to completion before the next one starts: ```{list-table} :header-rows: 1 * - Group - Callbacks - `state` is - Description * - Prepare - `prepare_event()` - `source` - Enrich event kwargs before anything else runs. See {ref}`preparing-events`. * - Validators - `validators` - `source` - Raise an exception to block the transition. * - Conditions - `cond`, `unless` - `source` - Return a boolean to allow or prevent the transition. * - Before - `before_transition()`, `before_()` - `source` - Runs before any state changes. * - Exit - `on_exit_state()`, `on_exit_()` - exiting state - Runs once per state being exited, from child to ancestor. * - On - `on_transition()`, `on_()` - `source` - Transition content — the main action. * - Enter - `on_enter_state()`, `on_enter_()` - entering state - Runs once per state being entered, from ancestor to child. * - Invoke - `on_invoke_state()`, `on_invoke_()` - `target` - Spawns background work. See {ref}`invoke`. * - After - `after_transition()`, `after_()` - `target` - Runs after all state changes are complete. ``` The `state` column shows what the `state` parameter resolves to when {ref}`injected ` into that callback. The `source` and `target` parameters are always available regardless of group. ```{tip} `after` callbacks run even when an earlier group raises and `catch_errors_as_events` is enabled — making them a natural **finalize** hook. See {ref}`error-handling-cleanup-finalize` for the full pattern. ``` ```{seealso} See {ref}`validators and guards` for the `validators`, `cond`, and `unless` groups. The rest of this page focuses on actions. ``` ### Priority within a group Each group can contain multiple callbacks. Within the same group, callbacks execute in **priority order**: 1. **Generic** — built-in callbacks like `on_enter_state()` or `before_transition()`. 2. **Inline** — callbacks passed as constructor parameters (e.g., `on="do_work"`). 3. **Decorator** — callbacks added via decorators (e.g., `@state.enter`). 4. **Naming convention** — callbacks discovered by name (e.g., `on_enter_idle()`). ```{seealso} See the example {ref}`sphx_glr_auto_examples_all_actions_machine.py` for a complete demonstration of callback resolution order. ``` ### Exit and enter in compound states In a flat state machine, exit and enter each run exactly once — for the single source and the single target. With {ref}`compound ` and {ref}`parallel ` states, a transition may cross multiple levels of the hierarchy, and the engine exits and enters **each level individually**, following the [SCXML](https://www.w3.org/TR/scxml/#AlgorithmforSCXMLInterpretation) specification: - **Exit** runs from the **innermost** (deepest child) state up to the ancestor being left — children exit before their parents. - **Enter** runs from the **outermost** (highest ancestor) state down to the target leaf — parents enter before their children. ```py >>> from statemachine import State, StateChart >>> class HierarchicalExample(StateChart): ... class parent_a(State.Compound): ... child_a = State(initial=True) ... class parent_b(State.Compound): ... child_b = State(initial=True, final=True) ... cross = parent_a.to(parent_b) ... ... def on_exit_child_a(self): ... print(" exit child_a") ... def on_exit_parent_a(self): ... print(" exit parent_a") ... def on_enter_parent_b(self): ... print(" enter parent_b") ... def on_enter_child_b(self): ... print(" enter child_b") >>> sm = HierarchicalExample() >>> sm.send("cross") exit child_a exit parent_a enter parent_b enter child_b ``` This means that **exit and enter callbacks fire multiple times per microstep** — once for each state in the exit/entry set. Use state-specific callbacks (`on_exit_`, `on_enter_`) to target individual levels of the hierarchy. ```{note} The generic `on_exit_state()` and `on_enter_state()` callbacks also fire once per state in the set, but the `state` parameter is bound to the transition's `source` or `target` — not the individual state being exited/entered. Use `event_data` if you need the full context, or prefer state-specific callbacks for clarity. ``` ```{seealso} See {ref}`macrostep-microstep` for how microsteps compose into macrosteps, and {ref}`compound-states` for how state hierarchies work. ``` (dependency-injection)= (dynamic-dispatch)= (dynamic dispatch)= ## Dependency injection All callbacks (actions, conditions, validators) support automatic dependency injection. The library inspects your method signature and passes only the parameters you declare — you never need to accept arguments you don't use. ```py >>> class FlexibleSC(StateChart): ... idle = State(initial=True) ... done = State(final=True) ... ... go = idle.to(done) ... ... def on_go(self): ... """No params needed? That's fine.""" ... return "minimal" ... ... def after_go(self, event, source, target): ... """Need context? Just declare what you want.""" ... print(f"{event}: {source.id} → {target.id}") >>> sm = FlexibleSC() >>> sm.send("go") go: idle → done 'minimal' ``` ### Available parameters These parameters are available for injection into any callback: | Parameter | Type | Description | |---|---|---| | `event_data` | {class}`~statemachine.event_data.EventData` | The full event data object for this microstep. | | `event` | {class}`~statemachine.event.Event` | The event that triggered the transition. | | `source` | {class}`~statemachine.state.State` | The state the machine was in when the event started. | | `target` | {class}`~statemachine.state.State` | The destination state of the transition. | | `state` | {class}`~statemachine.state.State` | The *current* state — equals `source` for before/exit/on, `target` for enter/after. | | `error` | `Exception` | The exception object. Only available in callbacks triggered by `error.execution` events. See {ref}`error-execution`. | | `model` | {class}`~statemachine.model.Model` | The underlying model instance (see {ref}`models`). | | `machine` | {class}`~statemachine.statemachine.StateChart` | The state machine instance itself. | | `transition` | {class}`~statemachine.transition.Transition` | The transition being executed. | The following parameters are available **only in `on` callbacks** (transition content): | Parameter | Type | Description | |---|---|---| | `previous_configuration` | `OrderedSet[`{class}`~statemachine.state.State``]` | States that were active before the microstep. | | `new_configuration` | `OrderedSet[`{class}`~statemachine.state.State``]` | States that will be active after the microstep. | #### Configuration during `on` callbacks By the time the `on` group runs, exit callbacks have already fired and the exiting states may have been removed from `sm.configuration`, but the entering states have not been added yet. This means that reading `sm.configuration` inside an `on` callback returns a **transitional** snapshot — neither the old nor the new configuration. Use `previous_configuration` and `new_configuration` instead to reliably inspect which states were active before and which will be active after: ```py >>> from statemachine import State, StateChart >>> class InspectConfig(StateChart): ... a = State(initial=True) ... b = State(final=True) ... ... go = a.to(b) ... ... def on_go(self, previous_configuration, new_configuration): ... current = {s.id for s in self.configuration} ... prev = {s.id for s in previous_configuration} ... new = {s.id for s in new_configuration} ... print(f"previous: {sorted(prev)}") ... print(f"configuration: {sorted(current)}") ... print(f"new: {sorted(new)}") >>> sm = InspectConfig() >>> sm.send("go") previous: ['a'] configuration: [] new: ['b'] ``` Notice that `sm.configuration` is **empty** during the `on` callback — state `a` has already exited, but state `b` has not entered yet. ```{tip} If you need the old 2.x behavior where `sm.configuration` updates atomically (all exits and entries applied at once after the `on` group), set `atomic_configuration_update = True` on your class. See the [behaviour reference](behaviour.md) for details. ``` In addition, any positional or keyword arguments you pass when triggering the event are forwarded to all callbacks: ```py >>> class Greeter(StateChart): ... idle = State(initial=True) ... ... greet = idle.to.itself() ... ... def on_greet(self, name, greeting="Hello"): ... return f"{greeting}, {name}!" >>> sm = Greeter() >>> sm.send("greet", "Alice") 'Hello, Alice!' >>> sm.send("greet", "Bob", greeting="Hi") 'Hi, Bob!' ``` ```{seealso} All actions and {ref}`conditions ` support the same dependency injection mechanism. See {ref}`validators and guards` for how it applies to guards. ``` ## Binding actions There are three ways to attach an action to a state or transition: **naming conventions**, **inline parameters**, and **decorators**. All three can be combined — the priority rules above determine execution order. (state-actions)= ### State actions States support `enter` and `exit` callbacks. **Naming convention** — define a method matching `on_enter_()` or `on_exit_()`: ```py >>> from statemachine import StateChart, State >>> class LoginFlow(StateChart): ... idle = State(initial=True) ... logged_in = State(final=True) ... ... login = idle.to(logged_in) ... ... def on_enter_logged_in(self): ... print("session started") >>> sm = LoginFlow() >>> sm.send("login") session started ``` **Inline parameter** — pass callback names to the `State` constructor: ```py >>> class LoginFlow(StateChart): ... idle = State(initial=True) ... logged_in = State(final=True, enter="start_session") ... ... login = idle.to(logged_in) ... ... def start_session(self): ... print("session started") >>> sm = LoginFlow() >>> sm.send("login") session started ``` **Decorator** — use `@state.enter` or `@state.exit`: ```py >>> class LoginFlow(StateChart): ... idle = State(initial=True) ... logged_in = State(final=True) ... ... login = idle.to(logged_in) ... ... @logged_in.enter ... def start_session(self): ... print("session started") >>> sm = LoginFlow() >>> sm.send("login") session started ``` States also support `invoke` callbacks — background work that is spawned when the state is entered and automatically cancelled when the state is exited. Invoke supports the same three binding patterns (naming convention, inline, decorator) and has its own completion and cancellation lifecycle. ```{seealso} See {ref}`invoke` for the full invoke reference: execution model, binding patterns, `done.invoke` transitions, cancellation, error handling, grouped invokes, the `IInvoke` protocol, and child state machines. ``` (transition-actions)= ### Transition actions Transitions support `before`, `on`, and `after` callbacks. **Naming convention** — define a method matching `before_()`, `on_()`, or `after_()`. The callback is called for every transition triggered by that event: ```py >>> from statemachine import StateChart, State >>> class Turnstile(StateChart): ... locked = State(initial=True) ... unlocked = State() ... ... coin = locked.to(unlocked) ... push = unlocked.to(locked) ... ... def on_coin(self): ... return "accepted" ... ... def after_push(self): ... print("gate closed") >>> sm = Turnstile() >>> sm.send("coin") 'accepted' >>> sm.send("push") gate closed ``` **Inline parameter** — pass callback names to the transition constructor: ```py >>> class Turnstile(StateChart): ... locked = State(initial=True) ... unlocked = State() ... ... coin = locked.to(unlocked, on="accept_coin") ... push = unlocked.to(locked, after="close_gate") ... ... def accept_coin(self): ... return "accepted" ... ... def close_gate(self): ... print("gate closed") >>> sm = Turnstile() >>> sm.send("coin") 'accepted' >>> sm.send("push") gate closed ``` **Decorator** — use `@event.before`, `@event.on`, or `@event.after`: ```py >>> class Turnstile(StateChart): ... locked = State(initial=True) ... unlocked = State() ... ... coin = locked.to(unlocked) ... push = unlocked.to(locked) ... ... @coin.on ... def accept_coin(self): ... return "accepted" ... ... @push.after ... def close_gate(self): ... print("gate closed") >>> sm = Turnstile() >>> sm.send("coin") 'accepted' >>> sm.send("push") gate closed ``` #### Declaring an event with an inline action You can declare an event and its `on` action in a single expression by using the transition as a decorator: ```py >>> class Turnstile(StateChart): ... locked = State(initial=True) ... unlocked = State() ... ... push = unlocked.to(locked) ... ... @locked.to(unlocked) ... def coin(self): ... return "accepted" >>> sm = Turnstile() >>> sm.send("coin") 'accepted' ``` The resulting `coin` attribute is an {ref}`Event `, not a plain method — it only executes when the machine is in a state where a matching transition exists. ## Generic callbacks Generic callbacks run on **every** transition, regardless of which event or state is involved. They follow the same group ordering and are useful for cross-cutting concerns like logging or auditing: ```py >>> class Audited(StateChart): ... idle = State(initial=True) ... active = State(final=True) ... ... start = idle.to(active) ... ... def before_transition(self, event, source): ... print(f"about to transition from {source.id} on {event}") ... ... def on_enter_state(self, target, event): ... print(f"entered {target.id} on {event}") ... ... def after_transition(self, event, source, target): ... print(f"completed {source.id} → {target.id} on {event}") >>> sm = Audited() entered idle on __initial__ >>> sm.send("start") about to transition from idle on start entered active on start completed idle → active on start ``` The full list of generic callbacks: | Callback | Group | Description | |---|---|---| | `before_transition()` | Before | Runs before any state change. | | `on_exit_state()` | Exit | Runs when leaving any state. | | `on_transition()` | On | Runs during any transition. | | `on_enter_state()` | Enter | Runs when entering any state. | | `on_invoke_state()` | Invoke | Runs when spawning invoke handlers for any state. See {ref}`invoke`. | | `after_transition()` | After | Runs after all state changes. | ```{note} `prepare_event()` is also a generic callback, but it serves a special purpose — see {ref}`preparing-events` below. ``` ```{tip} Generic callbacks are the building blocks for {ref}`listeners ` — an external object that implements the same callback signatures can observe every transition without modifying the state machine class. ``` (preparing-events)= ## Preparing events The `prepare_event` callback runs **before validators and conditions** and has a unique capability: its return value (a `dict`) is merged into the keyword arguments available to all subsequent callbacks in the same microstep. This is useful for enriching events with computed context — for example, looking up a user record from an ID before the transition runs: ```py >>> class OrderFlow(StateChart): ... pending = State(initial=True) ... confirmed = State(final=True) ... ... confirm = pending.to(confirmed) ... ... def prepare_event(self, order_id=None): ... if order_id is not None: ... return {"order_total": order_id * 10} ... return {} ... ... def on_confirm(self, order_total=0): ... return f"confirmed ${order_total}" >>> sm = OrderFlow() >>> sm.send("confirm", order_id=5) 'confirmed $50' ``` ## Return values The return values from `before` and `on` callbacks are collected into a list and returned to the caller. Other groups (`exit`, `enter`, `after`) do not contribute to the return value. ```py >>> class ReturnExample(StateChart): ... a = State(initial=True) ... b = State(final=True) ... ... go = a.to(b) ... ... def before_go(self): ... return "before" ... ... def on_go(self): ... return "on" ... ... def on_enter_b(self): ... return "enter (ignored)" ... ... def after_go(self): ... return "after (ignored)" >>> sm = ReturnExample() >>> sm.send("go") ['before', 'on'] ``` When only one callback returns a value, the result is unwrapped (not a list): ```py >>> class SingleReturn(StateChart): ... a = State(initial=True) ... b = State(final=True) ... ... go = a.to(b, on="do_it") ... ... def do_it(self): ... return 42 >>> sm = SingleReturn() >>> sm.send("go") 42 ``` When no callback returns a value, the result is `None`: ```py >>> class NoReturn(StateChart): ... a = State(initial=True) ... b = State(final=True) ... ... go = a.to(b) >>> sm = NoReturn() >>> sm.send("go") is None True ``` ```{note} If a callback is defined but returns `None` explicitly, it is included in the result list. Only callbacks that are not defined at all are excluded. ``` ================================================ FILE: docs/api.md ================================================ # API ## StateChart ```{versionadded} 3.0.0 ``` ```{eval-rst} .. autoclass:: statemachine.statemachine.StateChart :members: :undoc-members: ``` ## StateMachine ```{eval-rst} .. autoclass:: statemachine.statemachine.StateMachine :members: :undoc-members: ``` ## State ```{seealso} {ref}`States` reference. ``` ```{eval-rst} .. autoclass:: statemachine.state.State :members: ``` ## HistoryState ```{versionadded} 3.0.0 ``` ```{eval-rst} .. autoclass:: statemachine.state.HistoryState :members: ``` ## States (class) ```{eval-rst} .. autoclass:: statemachine.states.States :noindex: :members: ``` ## Transition ```{seealso} {ref}`Transitions` reference. ``` ```{eval-rst} .. autoclass:: statemachine.transition.Transition :members: ``` ## TransitionList ```{eval-rst} .. autoclass:: statemachine.transition_list.TransitionList :members: ``` ## Model ```{seealso} {ref}`Domain models` reference. ``` ```{eval-rst} .. autoclass:: statemachine.model.Model :members: ``` ## TriggerData ```{eval-rst} .. autoclass:: statemachine.event_data.TriggerData :members: ``` ## Event ```{eval-rst} .. autoclass:: statemachine.event.Event :members: id, name, __call__ ``` ## EventData ```{eval-rst} .. autoclass:: statemachine.event_data.EventData :members: ``` ## Callback conventions These are convention-based callbacks that you can define on your state machine subclass. They are not methods on the base class — define them in your subclass to enable the behavior. ### `prepare_event` Called before every event is processed. Returns a `dict` of keyword arguments that will be merged into `**kwargs` for all subsequent callbacks (guards, actions, entry/exit handlers) during that event's processing: ```python class MyMachine(StateChart): initial = State(initial=True) loop = initial.to.itself() def prepare_event(self): return {"request_id": generate_id()} def on_loop(self, request_id): # request_id is available here ... ``` ## MachineMixin ```{seealso} {ref}`Integrations ` for usage examples. ``` ```{eval-rst} .. autoclass:: statemachine.mixins.MachineMixin :members: :undoc-members: ``` ## create_machine_class_from_definition ```{versionadded} 3.0.0 ``` ```{eval-rst} .. autofunction:: statemachine.io.create_machine_class_from_definition ``` ## timeout ```{versionadded} 3.0.0 ``` ```{seealso} {ref}`timeout` how-to guide. ``` ```{eval-rst} .. autofunction:: statemachine.contrib.timeout.timeout ``` ================================================ FILE: docs/async.md ================================================ (async)= # Async support ```{seealso} New to statecharts? See [](concepts.md) for an overview of how states, transitions, events, and actions fit together. ``` The public API is the same for synchronous and asynchronous code. If the state machine has at least one `async` callback, the engine switches to {ref}`AsyncEngine ` automatically — no configuration needed. All statechart features — compound states, parallel states, history pseudo-states, eventless transitions, `done.state` events — work identically in both engines. ## Writing async callbacks Declare any callback as `async def` and the engine handles the rest: ```py >>> class AsyncStateMachine(StateChart): ... initial = State("Initial", initial=True) ... final = State("Final", final=True) ... ... keep = initial.to.itself(internal=True) ... advance = initial.to(final) ... ... async def on_advance(self): ... return 42 >>> async def run_sm(): ... sm = AsyncStateMachine() ... result = await sm.advance() ... print(f"Result is {result}") ... print(list(sm.configuration_values)) >>> asyncio.run(run_sm()) Result is 42 ['final'] ``` ### Using from synchronous code The same state machine can be used from a synchronous context — even without a running `asyncio` loop. The engine creates one internally with `asyncio.new_event_loop()` and awaits callbacks using `loop.run_until_complete()`: ```py >>> sm = AsyncStateMachine() >>> result = sm.advance() >>> print(f"Result is {result}") Result is 42 >>> print(list(sm.configuration_values)) ['final'] ``` (initial state activation)= ## Initial state activation In async code, Python cannot `await` during `__init__`, so the initial state is **not** activated at instantiation time. If you inspect `configuration` immediately after creating the instance, it won't reflect the initial state: ```py >>> async def show_problem(): ... sm = AsyncStateMachine() ... print(list(sm.configuration_values)) >>> asyncio.run(show_problem()) [] ``` To fix this, explicitly await {func}`activate_initial_state() ` before inspecting the configuration: ```py >>> async def correct_init(): ... sm = AsyncStateMachine() ... await sm.activate_initial_state() ... print(list(sm.configuration_values)) >>> asyncio.run(correct_init()) ['initial'] ``` ```{tip} If you don't inspect the configuration before sending the first event, you can skip this step — the first `send()` activates the initial state automatically. ``` ```py >>> async def auto_activate(): ... sm = AsyncStateMachine() ... await sm.keep() # activates initial state before handling the event ... print(list(sm.configuration_values)) >>> asyncio.run(auto_activate()) ['initial'] ``` ## Concurrent event sending A benefit exclusive to the async engine: when multiple coroutines send events concurrently (e.g., via `asyncio.gather`), each caller receives its own event's result — even though only one coroutine runs the processing loop at a time. The sync engine does not support this pattern. ```py >>> class ConcurrentSC(StateChart): ... s1 = State(initial=True) ... s2 = State() ... s3 = State(final=True) ... ... step1 = s1.to(s2) ... step2 = s2.to(s3) ... ... async def on_step1(self): ... return "result_1" ... ... async def on_step2(self): ... return "result_2" >>> async def run_concurrent(): ... import asyncio as _asyncio ... sm = ConcurrentSC() ... await sm.activate_initial_state() ... r1, r2 = await _asyncio.gather( ... sm.send("step1"), ... sm.send("step2"), ... ) ... return r1, r2 >>> asyncio.run(run_concurrent()) ('result_1', 'result_2') ``` Under the hood, the async engine attaches an `asyncio.Future` to each externally enqueued event. The coroutine that acquires the processing lock resolves each event's future as it processes the queue. Callers that didn't acquire the lock simply `await` their future. ```{note} Futures are only created for **external** events sent from outside the processing loop. Events triggered from within callbacks (via `send()` or `raise_()`) follow the {ref}`run-to-completion ` model — they are enqueued and processed within the current macrostep. ``` If an exception occurs during processing (with `catch_errors_as_events=False`), the exception is routed to the caller whose event caused it. Other callers whose events were still pending will also receive the exception, since the processing loop clears the queue on failure. (syncengine)= (asyncengine)= ## Engine selection The engine is selected automatically when the state machine is instantiated, based on the registered callbacks: | Outer scope | Async callbacks? | Engine | Event loop | |---|---|---|---| | Sync | No | SyncEngine | None | | Sync | Yes | AsyncEngine | Creates internal loop | | Async | No | SyncEngine | None | | Async | Yes | AsyncEngine | Reuses running loop | **Outer scope** is the context where the state machine instance is created. **Async callbacks** means at least one `async def` callback or condition is declared on the machine, its model, or its listeners. ```{note} All callbacks run on the same thread they are called from. Mixing synchronous and asynchronous code is supported but requires care — avoid sharing a state machine instance across threads without external synchronization. ``` ```{seealso} See {ref}`processing model ` for how the engine processes events, and {ref}`behaviour` for the behavioral attributes that affect processing. ``` ================================================ FILE: docs/authors.md ================================================ # Credits ## Development Lead * [Fernando Macedo](mailto:fgmacedo@gmail.com) ## Contributors * [Guilherme Nepomuceno](mailto:piercio@loggi.com) * [Rafael Rêgo](mailto:crafards@gmail.com) * [Raphael Schrader](mailto:raphael@schradercloud.de) * [João S. O. Bueno](mailto:gwidion@gmail.com) * [Rodrigo Nogueira](mailto:rodrigo.b.nogueira@gmail.com) ## Scaffolding This package was created with [Cookiecutter](https://github.com/audreyr/cookiecutter) and the [audreyr/cookiecutter-pypackage](https://github.com/audreyr/cookiecutter-pypackage) project template. ================================================ FILE: docs/behaviour.md ================================================ (behaviour)= (statecharts)= # Behaviour ```{seealso} New to statecharts? See [](concepts.md) for an overview of how states, transitions, events, and actions fit together. ``` The {class}`~statemachine.statemachine.StateChart` class follows the [SCXML specification](https://www.w3.org/TR/scxml/) by default. The {class}`~statemachine.statemachine.StateMachine` class extends `StateChart` but overrides several defaults to preserve backward compatibility with pre-3.0 code. The behavioral differences are controlled by class-level attributes. This design allows a gradual upgrade path: start from `StateMachine` and selectively enable spec-compliant behaviors one at a time, or start from `StateChart` and get full SCXML compliance out of the box. ```{tip} We **strongly recommend** that new projects use `StateChart` directly. Existing projects should consider migrating when possible, as the SCXML-compliant behavior provides more predictable semantics. ``` ## Comparison table | Attribute | `StateChart` | `StateMachine` | Description | |---|---|---|---| | `allow_event_without_transition` | `True` | `False` | Tolerate events that don't match any transition | | `enable_self_transition_entries` | `True` | `False` | Execute entry/exit actions on self-transitions | | `atomic_configuration_update` | `False` | `True` | When to update {ref}`configuration ` during a microstep | | `catch_errors_as_events` | `True` | `False` | Catch action errors as `error.execution` events | Each attribute is described below, with cross-references to the pages that cover the topic in depth. ## `allow_event_without_transition` When `True` (SCXML default), sending an event that does not match any enabled transition is silently ignored. When `False` (legacy default), a `TransitionNotAllowed` exception is raised, including for unknown event names. The SCXML spec requires tolerance to unmatched events, as the event-driven model expects that not every event is relevant in every state. ```{seealso} See {ref}`conditions` for how the engine selects transitions, and {ref}`checking enabled events` to query which events are currently valid. ``` ## `enable_self_transition_entries` When `True` (SCXML default), a {ref}`self-transition ` executes the state's exit and entry actions, just like any other transition. When `False` (legacy default), self-transitions skip entry/exit actions. The SCXML spec treats self-transitions as regular transitions that happen to return to the same state, so entry/exit actions must fire. Use an {ref}`internal transition ` if you need a transition that stays in the same state **without** running exit/entry actions. ```{seealso} See {ref}`transitions` for the full reference on self-transitions and internal transitions. ``` ## `atomic_configuration_update` Controls **when** the {ref}`configuration ` is updated during a microstep. When `False` (SCXML default), the configuration reflects each phase as it happens: states are removed during exit and added during entry. This means that during transition `on` callbacks, the configuration may be empty or partial — the source states have already been exited but the target states have not yet been entered. When `True` (legacy default), the configuration is updated atomically **after** the `on` callbacks complete, so `sm.configuration` and `state.is_active` always reflect a consistent snapshot during the transition. ```py >>> from statemachine import State, StateChart >>> class AtomicDemo(StateChart): ... atomic_configuration_update = True ... off = State(initial=True) ... on = State(final=True) ... ... switch = off.to(on, on="check_config") ... ... def check_config(self): ... # With atomic update, configuration is unchanged during 'on' ... self.off_was_active = self.off.is_active ... self.on_was_active = self.on.is_active >>> sm = AtomicDemo() >>> sm.send("switch") >>> sm.off_was_active # source still in configuration during 'on' True >>> sm.on_was_active # target not yet in configuration during 'on' False ``` With `atomic_configuration_update = False` (the SCXML default), the result is different — `off.is_active` is `False` because exit already removed it, and `on.is_active` is also `False` because entry hasn't added it yet. In this mode, use `previous_configuration` and `new_configuration` to inspect the full picture: ```py >>> class SCXMLDemo(StateChart): ... off = State(initial=True) ... on = State(final=True) ... ... switch = off.to(on, on="check_config") ... ... def check_config(self, previous_configuration, new_configuration): ... self.prev = {s.id for s in previous_configuration} ... self.new = {s.id for s in new_configuration} >>> sm = SCXMLDemo() >>> sm.send("switch") >>> sm.prev {'off'} >>> sm.new {'on'} ``` ```{seealso} See {ref}`dependency-injection` for the full list of parameters available in callbacks. ``` ## `catch_errors_as_events` When `True` (SCXML default), runtime exceptions in action callbacks (entry/exit, transition `on`) are caught by the engine and dispatched as internal `error.execution` events. When `False` (legacy default), exceptions propagate normally to the caller. ```{note} {ref}`Validators ` are **not** affected by this flag — they always propagate exceptions to the caller, regardless of the `catch_errors_as_events` setting. See {ref}`validators` for details. ``` ```{seealso} See {ref}`error-handling` for the full `error.execution` lifecycle, block-level error catching, and the cleanup/finalize pattern. ``` ## Gradual migration All behavioral attributes can be overridden individually. This lets you adopt SCXML semantics incrementally in an existing `StateMachine`: ```python class MyMachine(StateMachine): catch_errors_as_events = True # ... everything else behaves as before ... ``` Or keep a specific legacy behavior while using `StateChart` for the rest: ```python class MyChart(StateChart): atomic_configuration_update = True # ... SCXML-compliant otherwise ... ``` ```{seealso} See [](releases/upgrade_2x_to_3.md) for a complete migration guide from `StateMachine` 2.x to `StateChart` 3.x. ``` ================================================ FILE: docs/concepts.md ================================================ (concepts)= # Core concepts A statechart organizes behavior around **states**, **transitions**, and **events**. Together they describe *when* the system can change, *what* triggers the change, and *what happens* as a result. ```py >>> from statemachine import StateChart, State >>> class Turnstile(StateChart): ... locked = State(initial=True) ... unlocked = State() ... ... coin = locked.to(unlocked, on="thank_you") ... push = unlocked.to(locked) ... ... def thank_you(self): ... return "Welcome!" >>> sm = Turnstile() >>> sm.coin() 'Welcome!' ``` Even in this minimal example, the core concepts appear: | Concept | What it is | Declared as | |---|---|---| | {ref}`StateChart ` | The container and runtime for the machine | `class MyChart(StateChart)` | | {ref}`State ` | A mode or condition of the system | `State()`, `State.Compound`, `State.Parallel` | | {ref}`Transition ` | A link from source state to target state | `source.to(target)`, `target.from_(source)` | | {ref}`Event ` | A signal that triggers transitions | Class-level assignment or `Event(...)` | | {ref}`Action ` | A side-effect during state changes | `on`, `before`, `after`, `enter`, `exit` callbacks | | {ref}`Condition ` | A guard that allows/blocks a transition | `cond`, `unless`, `validators` parameters | | {ref}`Listener ` | An external observer of the lifecycle | `listeners = [...]` class attribute | Each concept below introduces the idea briefly; follow the "See also" links for the full reference. Listeners are covered in {ref}`their own page `. (concepts-statechart)= ## StateChart A {ref}`StateChart ` is the container for states, transitions, and events. It defines the topology (which states exist and how they connect) and provides the runtime API — sending events, querying the current configuration, and managing listeners. In the turnstile example, `Turnstile` is the `StateChart`. After instantiation, `sm` holds the runtime state and exposes methods like `sm.send("coin")`, `sm.configuration`, and `sm.allowed_events`. ```{seealso} See [](statechart.md) for the full reference: creating instances, sending events, querying configuration, checking termination, and managing listeners at runtime. ``` (concepts-states)= ## States A **state** describes what the system is doing right now. At any point in time, a statechart is "in" one or more states — the **configuration**. States determine which transitions are available and which events are accepted. In the turnstile example, `locked` and `unlocked` are the two possible states. The machine starts in `locked` (its **initial state**) and can only reach `unlocked` when the `coin` event fires. ```{seealso} See [](states.md) for the full reference: initial and final states, compound (nested) states, parallel regions, history pseudo-states, and more. ``` (concepts-transitions)= ## Transitions A **transition** is a link between a **source** state and a **target** state. When a transition fires, the system leaves the source and enters the target. Transitions can carry {ref}`actions ` (side-effects) and {ref}`conditions ` (guards that must be satisfied). In the turnstile, `locked.to(unlocked)` is a transition: it moves the system from `locked` to `unlocked` and runs the `thank_you` action along the way. ```{seealso} See [](transitions.md) for the full reference: declaring transitions, self-transitions, internal transitions, eventless (automatic) transitions, and more. ``` (concepts-events)= ## Events An **event** is a signal that something has happened. Events trigger transitions — without an event, a transition will not fire (unless it is an {ref}`eventless ` transition with a guard condition). In the turnstile, `coin` and `push` are events. When you call `sm.coin()` or `sm.send("coin")`, the engine looks for a matching transition from the current state and fires it. Events are processed following a **run-to-completion** model — each event is fully handled before the next one starts. ```{seealso} See [](events.md) for the full reference: declaring, triggering, scheduling, and naming conventions. See [](processing_model.md) for how macrosteps and microsteps work under the hood. ``` (concepts-actions)= ## Actions An **action** is a side-effect that runs during a transition or on entry/exit of a state. Actions are how the statechart interacts with the outside world — sending notifications, updating a database, logging, or returning a value. In the turnstile, `thank_you` is an action attached to the `coin` transition via the `on` parameter. ```{seealso} See [](actions.md) for the full reference: callback naming conventions, execution order, dependency injection, and all available hooks. ``` (concepts-conditions)= ## Conditions A **condition** (also called a **guard**) is a predicate that must evaluate to `True` for a transition to fire. A **validator** is similar but raises an exception to block the transition instead of silently preventing it. Conditions let you have multiple transitions for the same event, each with a different guard — the first one that passes wins. ```{seealso} See [](guards.md) for the full reference: `cond`, `unless`, `validators`, boolean expressions, and checking enabled events. ``` ================================================ FILE: docs/conf.py ================================================ #!/usr/bin/env python # # statemachine documentation build configuration file, created by # sphinx-quickstart on Tue Jul 9 22:26:36 2013. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import os import sys from sphinx_gallery import gen_gallery # If extensions (or modules to document with autodoc) are in another # directory, add these directories to sys.path here. If the directory is # relative to the documentation root, use os.path.abspath to make it # absolute, like shown here. # sys.path.insert(0, os.path.abspath('.')) # Get the project root dir, which is the parent dir of this cwd = os.getcwd() project_root = os.path.dirname(cwd) # Insert the project root dir as the first element in the PYTHONPATH. # This lets us ensure that the source package is imported, and that its # version is used. sys.path.insert(0, project_root) from tests.scrape_images import MachineScraper # noqa: E402 import statemachine # noqa: E402 # -- General configuration --------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ "myst_parser", "sphinx.ext.autodoc", "sphinx.ext.doctest", "sphinx.ext.napoleon", "sphinx.ext.viewcode", "sphinx.ext.autosectionlabel", "sphinx_gallery.gen_gallery", "sphinx_copybutton", "statemachine.contrib.diagram.sphinx_ext", "sphinxcontrib.mermaid", ] autosectionlabel_prefix_document = True # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # The suffix of source filenames. source_suffix = ".rst" # The encoding of source files. # source_encoding = 'utf-8-sig' # The master toctree document. master_doc = "index" # General information about the project. project = "Python State Machine" copyright = "2024, Fernando Macedo" # The version info for the project you're documenting, acts as replacement # for |version| and |release|, also used in various other places throughout # the built documents. # # The short X.Y version. version = statemachine.__version__ # The full version, including alpha/beta/rc tags. release = statemachine.__version__ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # language = None # There are two options for replacing |today|: either, you set today to # some non-false value, then it is used: # today = '' # Else, today_fmt is used as the format for a strftime call. # today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ["_build", "examples/.ipynb_checkpoints", "*.ipynb"] # The reST default role (used for this markup: `text`) to use for all # documents. # default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. # add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). # add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. # show_authors = False # The name of the Pygments (syntax highlighting) style to use. # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built # documents. # keep_warnings = False # -- Options for HTML output ------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = "furo" # https://pradyunsg.me/furo/ # Theme options are theme-specific and customize the look and feel of a # theme further. For a list of options available for each theme, see the # documentation. # html_theme_options = {} # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". # html_title = None # A shorter title for the navigation bar. Default is the same as # html_title. # html_short_title = None # The name of an image file (relative to this directory) to place at the # top of the sidebar. # html_logo = None # The name of an image file (within the static path) to use as favicon # of the docs. This file should be a Windows icon file (.ico) being # 16x16 or 32x32 pixels large. # html_favicon = None # Add any paths that contain custom static files (such as style sheets) # here, relative to this directory. They are copied after the builtin # static files, so a file named "default.css" will overwrite the builtin # "default.css". html_static_path = ["_static"] html_css_files = [ "custom_machine.css", ] html_js_files = [ "https://buttons.github.io/buttons.js", ] html_title = f"python-statemachine {release}" html_logo = "images/python-statemachine.png" html_copy_source = False html_show_sourcelink = False html_theme_options = { "navigation_with_keys": True, "top_of_page_buttons": ["view", "edit"], "source_repository": "https://github.com/fgmacedo/python-statemachine/", # "source_branch": "develop", "source_directory": "docs/", } pygments_style = "monokai" pygments_dark_style = "monokai" # If not '', a 'Last updated on:' timestamp is inserted at every page # bottom, using the given strftime format. # html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. # html_use_smartypants = True # Custom sidebar templates, maps document names to template names. # html_sidebars = {} # Additional templates that should be rendered to pages, maps page names # to template names. # html_additional_pages = {} # If false, no module index is generated. # html_domain_indices = True # If false, no index is generated. # html_use_index = True # If true, the index is split into individual pages for each letter. # html_split_index = False # If true, links to the reST sources are added to the pages. # html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. # Default is True. # html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. # Default is True. # html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages # will contain a tag referring to it. The value of this option # must be the base URL from which the finished HTML is served. # html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). # html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = "statemachinedoc" # Documents to append as an appendix to all manuals. # texinfo_appendices = [] # If false, no module index is generated. # texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. # texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. # texinfo_no_detailmenu = False # Napoleon settings (Google format ) napoleon_google_docstring = True napoleon_numpy_docstring = True napoleon_include_init_with_doc = True napoleon_include_private_with_doc = False napoleon_include_special_with_doc = True napoleon_use_admonition_for_examples = True napoleon_use_admonition_for_notes = False napoleon_use_admonition_for_references = False napoleon_use_ivar = False napoleon_use_param = True napoleon_use_rtype = True napoleon_preprocess_types = False napoleon_type_aliases = None napoleon_attr_annotations = True # Markdown (MyST) configs myst_heading_anchors = 3 myst_enable_extensions = ["deflist", "substitution"] myst_substitutions = { "state": "{ref}`state`", "event": "{ref}`event`", } # Github html_context = { "display_github": True, # Integrate GitHub "github_user": "fgmacedo", # Username "github_repo": "python-statemachine", # Repo name "github_version": "develop", # Version "conf_py_path": "/docs/", # Path in the checkout to the docs root } # Sphinx Galery sphinx_gallery_conf = { "examples_dirs": [ "../tests/examples", ], # path to your example scripts "gallery_dirs": "auto_examples", # path to where to save gallery generated output "capture_repr": ("_repr_html_", "__repr__"), "filename_pattern": r"/.*\_machine.py", "download_all_examples": False, "show_signature": False, "min_reported_time": 9999, "thumbnail_size": (400, 280), "image_scrapers": (MachineScraper(project_root),), "reset_modules": [], } copybutton_exclude = ".linenos, .gp, .go" def dummy_write_computation_times(gallery_conf, target_dir, costs): "patch gen_gallery to disable write_computation_times" pass gen_gallery.write_computation_times = dummy_write_computation_times ================================================ FILE: docs/contributing.md ================================================ # Contributing * Star this project * Open an Issue * Fork Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given. You can contribute in many ways: ## Types of Contributions ### Report Bugs Report bugs at [https://github.com/fgmacedo/python-statemachine/issues](https://github.com/fgmacedo/python-statemachine/issues). If you are reporting a bug, please include: * Your operating system name and version. * Any details about your local setup that might be helpful in troubleshooting. * Detailed steps to reproduce the bug. ### Fix Bugs Look through the GitHub issues for bugs. Anything tagged with "bug" and "help wanted" is open to whoever wants to implement it. ### Implement Features Look through the GitHub issues for features. Anything tagged with "enhancement" and "help wanted" is open to whoever wants to implement it. ### Write Documentation Python State Machine could always use more documentation, whether as part of the official Python State Machine docs, in docstrings, or even on the web in blog posts, articles, and such. ### Add a translation Extract a `Portable Object Template` (`POT`) file: ```shell pybabel extract statemachine -o statemachine/locale/statemachine.pot ``` Then, copy the template as a `.po` file into the target locale folder. For example, if you're adding support for Brazilian Portuguese language, the code is `pt_BR`, and the file path should be `statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po`: ```shell cp statemachine/locale/statemachine.pot statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po ``` Then open the `statemachine.po` and translate. After translation, to get the new language working locally, you need to compile the `.po` files into `.mo` (binary format). Run: ```shell pybabel compile -d statemachine/locale/ -D statemachine ``` On Linux (Debian based), you can test changing the `LANGUAGE` environment variable. ```shell # If the last line is `Can't guess when in Won.` something went wrong. LANGUAGE=pt_BR python tests/examples/guess_the_number_machine.py ``` Then open a [pull request](https://github.com/fgmacedo/python-statemachine/pulls) with your translation file. ### Submit Feedback The best way to send feedback is to file an issue at https://github.com/fgmacedo/python-statemachine/issues. If you are proposing a feature: * Explain in detail how it would work. * Keep the scope as narrow as possible, to make it easier to implement. * Remember that this is a volunteer-driven project, and that contributions are welcome :) ## Get Started! Ready to contribute? Here's how to set up `python-statemachine` for local development. 1. Install dependencies. 1. [graphviz](https://graphviz.org/download/#linux) 1. [uv](https://docs.astral.sh/uv/getting-started/installation/) 1. Fork the `python-statemachine` repository on GitHub. 1. Clone the forked repository to your local machine by running:: git clone https://github.com/YOUR-USERNAME/python-statemachine.git. 1. Run `uv sync` once to install all the development dependencies and create a virtual environment:: uv sync --all-extras 2. Install the pre-commit validations: pre-commit install 3. Create a branch for local development: git checkout -b 4. Make changes to the code. 5. Run tests to ensure they pass by running: uv run pytest 6. Update the documentation as needed. Build the documentation: uv run sphinx-build docs docs/_build/html Now you can serve the local documentation using a webserver, like the built-in included with python: python -m http.server --directory docs/_build/html And access your browser at http://localhost:8000/ If you're specially writting documentation, I strongly recommend using `sphinx-autobuild` as it improves the workflow watching for file changes and with live reloading: uv run sphinx-autobuild docs docs/_build/html --re-ignore "auto_examples/.*" Sometimes you need a full fresh of the files being build for docs, you can safely remove all automatically generated files to get a clean state by running: rm -rf docs/_build/ docs/auto_examples 1. Commit your changes and push them to your forked repository: git add -A . git commit -s -m "Your detailed description of your changes." git push origin name-of-your-bugfix-or-feature 1. Create a pull request on the original repository for your changes to be reviewed and potentially merged. Be sure to follow the project's code of conduct and contributing guidelines. 1. Use `exit` to leave the virtual environment. ## Pull Request Guidelines Before you submit a pull request, check that it meets these guidelines: 1. The pull request should include tests. 2. If the pull request adds functionality, the docs should be updated. Put your new functionality into a function with a docstring, and add the feature to the list in the next release notes. 3. Consider adding yourself to the contributor's list. 4. The pull request should work for all supported Python versions. ## Releasing a New Version This project uses [git-flow](https://github.com/nvie/gitflow) for release management and publishes to PyPI automatically via GitHub Actions when a version tag is pushed. ### Prerequisites - You must be on the `develop` branch with a clean working tree. - `git-flow` must be installed and initialized: ```shell brew install git-flow # macOS git flow init # use main for production, develop for next release ``` - All changes intended for the release must already be merged into `develop`. ### Step-by-step release process The following steps use version `X.Y.Z` as a placeholder. Replace it with the actual version number (e.g., `2.6.0`). #### 1. Start the release branch ```shell git checkout develop git pull origin develop git flow release start X.Y.Z ``` This creates and switches to a `release/X.Y.Z` branch based on `develop`. #### 2. Bump the version number Update the version string in **both** files: - `pyproject.toml` — the `version` field under `[project]` - `statemachine/__init__.py` — the `__version__` variable #### 3. Update translations Extract new translatable strings, merge them into all existing `.po` files, translate the new entries, and compile: ```shell uv run pybabel extract statemachine -o statemachine/locale/statemachine.pot uv run pybabel update -i statemachine/locale/statemachine.pot -d statemachine/locale/ -D statemachine # Edit each .po file to translate new empty msgstr entries uv run pybabel compile -d statemachine/locale/ -D statemachine ``` ```{note} The `.pot` and `.mo` files are git-ignored. Only the `.po` source files are committed. The compiled `.mo` files may cause test failures if your system locale matches a translated language (error messages will appear translated instead of in English). Delete them after verifying translations work: `rm -f statemachine/locale/*/LC_MESSAGES/statemachine.mo` ``` #### 4. Write release notes Create `docs/releases/X.Y.Z.md` documenting all changes since the previous release. Include sections for new features, bugfixes, performance improvements, and miscellaneous changes. Reference GitHub issues/PRs where applicable. Add the new file to the toctree in `docs/releases/index.md` (at the top of the appropriate major version section). Update any related documentation pages (e.g., if a bugfix adds a new behavior that users should know about). #### 5. Run linters and tests ```shell uv run ruff check . uv run ruff format --check . uv run mypy statemachine/ uv run pytest -n auto ``` All checks must pass before committing. #### 6. Commit Stage all changed files and commit. The pre-commit hooks will run ruff, mypy, and pytest automatically. ```shell git add git commit -m "chore: prepare release X.Y.Z" ``` #### 7. Finish the release ```shell git flow release finish X.Y.Z -m "vX.Y.Z" ``` This will: - Merge `release/X.Y.Z` into `main` - Create an annotated tag `X.Y.Z` on `main` - Merge `main` back into `develop` - Delete the `release/X.Y.Z` branch ```{note} If tagging fails (e.g., GPG or editor issues), create the tag manually and re-run: `git tag -a X.Y.Z -m "vX.Y.Z"` then `git flow release finish X.Y.Z -m "vX.Y.Z"`. ``` #### 8. Update the `latest` tag and push ```shell git tag latest -f git push origin main develop --tags -f ``` Force-pushing tags is needed to move the `latest` tag. #### 9. Verify the release The tag push triggers the `release` GitHub Actions workflow (`.github/workflows/release.yml`), which will: 1. Check out the tag 2. Run the full test suite 3. Build the sdist and wheel with `uv build` 4. Publish to PyPI using trusted publishing Monitor the workflow run at `https://github.com/fgmacedo/python-statemachine/actions` to confirm the release was published successfully. ================================================ FILE: docs/diagram.md ================================================ (diagram)= (diagrams)= # Diagrams You can generate visual diagrams from any {class}`~statemachine.statemachine.StateChart` — useful for documentation, debugging, or sharing your machine's structure with teammates. ```{statemachine-diagram} tests.examples.order_control_machine.OrderControl :target: ``` ## Installation Diagram generation requires [pydot](https://github.com/pydot/pydot) and [Graphviz](https://graphviz.org/): ```bash pip install python-statemachine[diagrams] # installs pydot ``` You also need the `dot` command-line tool from Graphviz. On Debian/Ubuntu: ```bash sudo apt install graphviz ``` For other systems, see the [Graphviz downloads page](https://graphviz.org/download/). ## Generating diagrams Every state machine instance exposes a `_graph()` method that returns a [pydot.Dot](https://github.com/pydot/pydot) graph object: ```python from tests.examples.order_control_machine import OrderControl sm = OrderControl() graph = sm._graph() # returns a pydot.Dot object ``` ### Highlighting the current state The diagram automatically highlights the current state of the instance. Send events to advance the machine and see the active state change: ```python from tests.examples.traffic_light_machine import TrafficLightMachine sm = TrafficLightMachine() sm.send("cycle") sm._graph().write_png("traffic_light_yellow.png") ``` ```{statemachine-diagram} tests.examples.traffic_light_machine.TrafficLightMachine :events: cycle :caption: TrafficLightMachine after one cycle ``` ### Exporting to a file The `pydot.Dot` object supports writing to many formats — use `write_png()`, `write_svg()`, `write_pdf()`, etc.: ```python sm = OrderControl() sm._graph().write_png("order_control.png") ``` ```{statemachine-diagram} tests.examples.order_control_machine.OrderControl :caption: OrderControl ``` For higher resolution PNGs, set the DPI before exporting: ```python graph = sm._graph() graph.set_dpi(300).write_png("order_control_300dpi.png") ``` ```{note} Supported formats include `dia`, `dot`, `fig`, `gif`, `jpg`, `pdf`, `png`, `ps`, `svg`, and many others. See [Graphviz output formats](https://graphviz.org/docs/outputs/) for the complete list. ``` ## Text representations State machines support multiple text-based output formats, all accessible through Python's built-in `format()` protocol, the `formatter` API, or the command line. | Format | Aliases | Description | Dependencies | |--------|---------|-------------|--------------| | `mermaid` | | [Mermaid stateDiagram-v2](https://mermaid.js.org/syntax/stateDiagram.html) source | None [^mermaid] | | `md` | `markdown` | Transition table (pipe-delimited Markdown) | None | | `rst` | | Transition table (RST grid table) | None | | `dot` | | [Graphviz DOT](https://graphviz.org/doc/info/lang.html) language source | pydot | | `svg` | | SVG markup (generated via DOT) | pydot, Graphviz | [^mermaid]: Mermaid has a known rendering bug ([mermaid-js/mermaid#4052](https://github.com/mermaid-js/mermaid/issues/4052)) where transitions targeting or originating from a compound state inside a parallel region crash the renderer. As a workaround, the `MermaidRenderer` redirects such transitions to the compound's initial child state. The visual result is equivalent — Mermaid draws the arrow crossing into the compound boundary — but the arrow points to the child rather than the compound border. This workaround will be revisited when the upstream bug is resolved. ### Using `format()` Use f-strings or the built-in `format()` function — no diagram imports needed: ```py >>> from tests.examples.traffic_light_machine import TrafficLightMachine >>> sm = TrafficLightMachine() >>> print(f"{sm:mermaid}") stateDiagram-v2 direction LR state "Green" as green state "Yellow" as yellow state "Red" as red [*] --> green green --> yellow : Cycle yellow --> red : Cycle red --> green : Cycle classDef active fill:#40E0D0,stroke:#333 green:::active >>> print(f"{sm:md}") | State | Event | Guard | Target | | ------ | ----- | ----- | ------ | | Green | Cycle | | Yellow | | Yellow | Cycle | | Red | | Red | Cycle | | Green | ``` Works on **classes** too (no active-state highlighting): ```py >>> print(f"{TrafficLightMachine:mermaid}") stateDiagram-v2 direction LR state "Green" as green state "Yellow" as yellow state "Red" as red [*] --> green green --> yellow : Cycle yellow --> red : Cycle red --> green : Cycle ``` The `dot` format returns the Graphviz DOT language source: ```py >>> print(f"{sm:dot}") # doctest: +ELLIPSIS digraph TrafficLightMachine { ... } ``` An empty format spec (e.g., `f"{sm:}"`) falls back to `repr()`. (formatter-api)= ### Using the `formatter` API The `formatter` object is the programmatic entry point for rendering state machines in any registered text format: ```py >>> from statemachine.contrib.diagram import formatter >>> from tests.examples.traffic_light_machine import TrafficLightMachine >>> print(formatter.render(TrafficLightMachine, "mermaid")) stateDiagram-v2 direction LR state "Green" as green state "Yellow" as yellow state "Red" as red [*] --> green green --> yellow : Cycle yellow --> red : Cycle red --> green : Cycle >>> formatter.supported_formats() ['dot', 'markdown', 'md', 'mermaid', 'rst', 'svg'] ``` Both `format()` and the Sphinx directive delegate to this same `formatter` under the hood. #### Registering custom formats The `formatter` is extensible — register your own format with a decorator and it becomes available everywhere (`format()`, CLI, Sphinx directive): ```python from statemachine.contrib.diagram import formatter @formatter.register_format("plantuml", "puml") def _render_plantuml(machine_or_class): # your PlantUML renderer here ... ``` After registration, `f"{sm:plantuml}"` and `--format plantuml` work immediately. ### Command line You can generate diagrams without writing Python code: ```bash python -m statemachine.contrib.diagram ``` The output format is inferred from the file extension: ```bash python -m statemachine.contrib.diagram tests.examples.traffic_light_machine.TrafficLightMachine diagram.png ``` To highlight the current state, use `--events` to instantiate the machine and send events before rendering: ```bash python -m statemachine.contrib.diagram tests.examples.traffic_light_machine.TrafficLightMachine diagram.png --events cycle cycle cycle ``` Use `--format` to produce a text format instead of a Graphviz image: ```bash # Mermaid stateDiagram-v2 python -m statemachine.contrib.diagram tests.examples.traffic_light_machine.TrafficLightMachine output.mmd --format mermaid # DOT source python -m statemachine.contrib.diagram tests.examples.traffic_light_machine.TrafficLightMachine output.dot --format dot # Markdown transition table python -m statemachine.contrib.diagram tests.examples.traffic_light_machine.TrafficLightMachine output.md --format md # RST transition table python -m statemachine.contrib.diagram tests.examples.traffic_light_machine.TrafficLightMachine output.rst --format rst ``` Use `-` as the output file to write to stdout (handy for piping): ```bash python -m statemachine.contrib.diagram tests.examples.traffic_light_machine.TrafficLightMachine - --format mermaid ``` ## Auto-expanding docstrings Use `{statechart:FORMAT}` placeholders in your class docstring to embed a live representation of the state machine. The placeholder is replaced at class definition time, so the docstring always reflects the actual states and transitions: ```py >>> from statemachine.statemachine import StateChart >>> from statemachine.state import State >>> class TrafficLight(StateChart): ... """A traffic light. ... ... {statechart:md} ... """ ... green = State(initial=True) ... yellow = State() ... red = State() ... cycle = green.to(yellow) | yellow.to(red) | red.to(green) >>> print(TrafficLight.__doc__) A traffic light. | State | Event | Guard | Target | | ------ | ----- | ----- | ------ | | Green | Cycle | | Yellow | | Yellow | Cycle | | Red | | Red | Cycle | | Green | ``` Any registered format works: `{statechart:rst}`, `{statechart:mermaid}`, `{statechart:dot}`, etc. ### Choosing the right format | Context | Recommended format | |---------|-------------------| | Sphinx with RST (autodoc default) | `{statechart:rst}` | | Sphinx with MyST Markdown | `{statechart:md}` | | `help()` in terminal / IDE | Either works; `md` reads more cleanly | ### Sphinx autodoc integration Since the placeholder is expanded at class definition time, Sphinx `autodoc` sees the final rendered text — no extra configuration needed. For example, this class uses `{statechart:rst}` in its docstring: ```{literalinclude} ../tests/machines/showcase_simple.py :pyobject: SimpleSC :language: python ``` And here is the rendered autodoc output: ```{eval-rst} .. autoclass:: tests.machines.showcase_simple.SimpleSC :noindex: ``` ## Sphinx directive If you use [Sphinx](https://www.sphinx-doc.org/) to build your documentation, the `statemachine-diagram` directive renders diagrams inline — no need to generate image files manually. ### Setup Add the extension to your `conf.py`: ```python extensions = [ ... "statemachine.contrib.diagram.sphinx_ext", ] ``` ### Basic usage Reference any importable {class}`~statemachine.statemachine.StateChart` class by its fully qualified path: ````markdown ```{statemachine-diagram} myproject.machines.OrderControl ``` ```` ```{statemachine-diagram} tests.examples.order_control_machine.OrderControl :alt: OrderControl state machine :align: center ``` ### Highlighting a specific state Pass `:events:` to instantiate the machine and send events before rendering. This highlights the current state after processing: ````markdown ```{statemachine-diagram} myproject.machines.TrafficLight :events: cycle :caption: Traffic light after one cycle ``` ```` ```{statemachine-diagram} tests.examples.traffic_light_machine.TrafficLightMachine :events: cycle :caption: Traffic light after one cycle :align: center ``` ### Enabling zoom For complex diagrams, add `:target:` (without a value) to make the diagram clickable — it opens the full SVG in a new browser tab where users can zoom and pan freely: ````markdown ```{statemachine-diagram} myproject.machines.OrderControl :target: ``` ```` ```{statemachine-diagram} tests.examples.order_control_machine.OrderControl :caption: Click to open full-size SVG :target: :align: center ``` ### Mermaid format Use `:format: mermaid` to render via [sphinxcontrib-mermaid](https://github.com/mgaitan/sphinxcontrib-mermaid) instead of Graphviz SVG — useful when you don't want to install Graphviz in your docs build environment: ````markdown ```{statemachine-diagram} myproject.machines.TrafficLight :format: mermaid :caption: Rendered as Mermaid ``` ```` ```{statemachine-diagram} tests.examples.traffic_light_machine.TrafficLightMachine :format: mermaid :caption: TrafficLightMachine (Mermaid) :align: center ``` ### Directive options The directive supports the same layout options as the standard `image` and `figure` directives, plus state-machine-specific ones. **State-machine options:** `:events:` *(comma-separated string)* : Events to send in sequence. When present, the machine is instantiated and each event is sent before rendering. `:format:` *(string)* : Output format. Use `mermaid` to render via sphinxcontrib-mermaid instead of Graphviz SVG. Default: DOT/SVG. **Image/figure options:** `:caption:` *(string)* : Caption text; wraps the image in a `figure` node. `:alt:` *(string)* : Alt text for the image. Defaults to the class name. `:width:` *(CSS length, e.g. `400px`, `80%`)* : Explicit width for the diagram. `:height:` *(CSS length)* : Explicit height for the diagram. `:scale:` *(integer percentage, e.g. `50%`)* : Uniform scaling relative to the intrinsic size. `:align:` *(left | center | right)* : Image alignment. Defaults to `center`. `:target:` *(URL or empty)* : Makes the diagram clickable. When set without a value, the raw SVG is saved as a file and linked so users can open it in a new tab for full-resolution zooming — useful for large or complex diagrams. `:class:` *(space-separated strings)* : Extra CSS classes for the wrapper element. `:figclass:` *(space-separated strings)* : Extra CSS classes for the `figure` element (only when `:caption:` is set). `:name:` *(string)* : Reference target name for cross-referencing with `{ref}`. ```{note} The directive imports the state machine class at Sphinx parse time. Machines defined inline in doctest blocks cannot be referenced — use the `_graph()` method for those cases. ``` ## Jupyter integration State machine instances are automatically rendered as diagrams in JupyterLab cells — no extra code needed: ![Approval machine on JupyterLab](images/lab_approval_machine_accepted.png) ## Online generation (QuickChart) If you prefer not to install Graphviz locally, you can generate diagrams using the [QuickChart](https://quickchart.io/) online service: ```{eval-rst} .. autofunction:: statemachine.contrib.diagram.quickchart_write_svg ``` ## Customizing the output The `DotGraphMachine` class gives you control over the diagram's visual properties. Subclass it and override the class attributes to customize fonts, colors, and layout: ```python from statemachine.contrib.diagram import DotGraphMachine from tests.examples.order_control_machine import OrderControl ``` Available attributes: | Attribute | Default | Description | |-----------|---------|-------------| | `graph_rankdir` | `"LR"` | Graph direction (`"LR"` left-to-right, `"TB"` top-to-bottom) | | `font_name` | `"Helvetica"` | Font face for labels | | `state_font_size` | `"10"` | State label font size | | `state_active_penwidth` | `2` | Border width of the active state | | `state_active_fillcolor` | `"turquoise"` | Fill color of the active state | | `transition_font_size` | `"9"` | Transition label font size | For example, to generate a top-to-bottom diagram with a custom active state color: ```python class CustomDiagram(DotGraphMachine): graph_rankdir = "TB" state_active_fillcolor = "lightyellow" sm = OrderControl() sm.receive_payment(10) graph = CustomDiagram(sm) dot = graph() dot.write_svg("order_control_custom.svg") ``` `DotGraphMachine` also works with **classes** (not just instances) to generate diagrams without an active state: ```python dot = DotGraphMachine(OrderControl)() dot.write_png("order_control_class.png") ``` ## Visual showcase This section shows how each state machine feature is rendered in diagrams. Each example includes the class definition, diagrams in both **Graphviz** and **Mermaid** formats, and **instance** diagrams with the current state highlighted after sending events. ### Simple states A minimal state machine with three atomic states and linear transitions. ```{literalinclude} ../tests/machines/showcase_simple.py :pyobject: SimpleSC :language: python ``` ```{statemachine-diagram} tests.machines.showcase_simple.SimpleSC :caption: Class (Graphviz) ``` ```{statemachine-diagram} tests.machines.showcase_simple.SimpleSC :format: mermaid :caption: Class (Mermaid) ``` ```{statemachine-diagram} tests.machines.showcase_simple.SimpleSC :events: :caption: Initial ``` ```{statemachine-diagram} tests.machines.showcase_simple.SimpleSC :events: start :caption: Running ``` ```{statemachine-diagram} tests.machines.showcase_simple.SimpleSC :events: start, finish :caption: Done (final) ``` ### Entry and exit actions States can declare `entry` / `exit` callbacks, shown in the state label. ```{literalinclude} ../tests/machines/showcase_actions.py :pyobject: ActionsSC :language: python ``` ```{statemachine-diagram} tests.machines.showcase_actions.ActionsSC :caption: Class (Graphviz) ``` ```{statemachine-diagram} tests.machines.showcase_actions.ActionsSC :format: mermaid :caption: Class (Mermaid) ``` ```{statemachine-diagram} tests.machines.showcase_actions.ActionsSC :events: power_on :caption: Active: On ``` ### Guard conditions Transitions can have `cond` guards, shown in brackets on the edge label. ```{literalinclude} ../tests/machines/showcase_guards.py :pyobject: GuardSC :language: python ``` ```{statemachine-diagram} tests.machines.showcase_guards.GuardSC :caption: Class (Graphviz) ``` ```{statemachine-diagram} tests.machines.showcase_guards.GuardSC :format: mermaid :caption: Class (Mermaid) ``` ```{statemachine-diagram} tests.machines.showcase_guards.GuardSC :events: :caption: Active: Pending ``` ### Self-transitions A transition from a state back to itself. ```{literalinclude} ../tests/machines/showcase_self_transition.py :pyobject: SelfTransitionSC :language: python ``` ```{statemachine-diagram} tests.machines.showcase_self_transition.SelfTransitionSC :caption: Class (Graphviz) ``` ```{statemachine-diagram} tests.machines.showcase_self_transition.SelfTransitionSC :format: mermaid :caption: Class (Mermaid) ``` ```{statemachine-diagram} tests.machines.showcase_self_transition.SelfTransitionSC :events: :caption: Active: Counting ``` ### Internal transitions Internal transitions execute actions without exiting/entering the state. ```{literalinclude} ../tests/machines/showcase_internal.py :pyobject: InternalSC :language: python ``` ```{statemachine-diagram} tests.machines.showcase_internal.InternalSC :caption: Class (Graphviz) ``` ```{statemachine-diagram} tests.machines.showcase_internal.InternalSC :format: mermaid :caption: Class (Mermaid) ``` ```{statemachine-diagram} tests.machines.showcase_internal.InternalSC :events: :caption: Active: Monitoring ``` ### Compound states A compound state contains child states. Entering the compound activates its initial child. ```{literalinclude} ../tests/machines/showcase_compound.py :pyobject: CompoundSC :language: python ``` ```{statemachine-diagram} tests.machines.showcase_compound.CompoundSC :caption: Class (Graphviz) :target: ``` ```{statemachine-diagram} tests.machines.showcase_compound.CompoundSC :format: mermaid :caption: Class (Mermaid) ``` ```{statemachine-diagram} tests.machines.showcase_compound.CompoundSC :events: :caption: Off :target: ``` ```{statemachine-diagram} tests.machines.showcase_compound.CompoundSC :events: turn_on :caption: Active/Idle :target: ``` ```{statemachine-diagram} tests.machines.showcase_compound.CompoundSC :events: turn_on, begin :caption: Active/Working :target: ``` ### Parallel states A parallel state activates all its regions simultaneously. ```{literalinclude} ../tests/machines/showcase_parallel.py :pyobject: ParallelSC :language: python ``` ```{statemachine-diagram} tests.machines.showcase_parallel.ParallelSC :caption: Class (Graphviz) :target: ``` ```{statemachine-diagram} tests.machines.showcase_parallel.ParallelSC :format: mermaid :caption: Class (Mermaid) ``` ```{statemachine-diagram} tests.machines.showcase_parallel.ParallelSC :events: enter :caption: Both active :target: ``` ```{statemachine-diagram} tests.machines.showcase_parallel.ParallelSC :events: enter, go_l :caption: Left done :target: ``` ### Parallel with cross-boundary transitions A transition targeting a compound state **inside** a parallel region triggers a rendering bug in Mermaid (`mermaid-js/mermaid#4052`). The Mermaid renderer works around this by redirecting the arrow to the compound's initial child — compare the ``rebuild`` arrow in both diagrams below. ```{literalinclude} ../tests/machines/showcase_parallel_compound.py :pyobject: ParallelCompoundSC :language: python ``` ```{statemachine-diagram} tests.machines.showcase_parallel_compound.ParallelCompoundSC :caption: Class (Graphviz) — ``rebuild`` points to the Build compound border :target: ``` ```{statemachine-diagram} tests.machines.showcase_parallel_compound.ParallelCompoundSC :format: mermaid :caption: Class (Mermaid) — ``rebuild`` is redirected to Compile (initial child of Build) ``` ```{statemachine-diagram} tests.machines.showcase_parallel_compound.ParallelCompoundSC :events: start, do_build :caption: Build done :target: ``` ```{statemachine-diagram} tests.machines.showcase_parallel_compound.ParallelCompoundSC :events: start, do_build, do_test :caption: Pipeline done → Review :target: ``` ### History states (shallow) A history pseudo-state remembers the last active child of a compound state. ```{literalinclude} ../tests/machines/showcase_history.py :pyobject: HistorySC :language: python ``` ```{statemachine-diagram} tests.machines.showcase_history.HistorySC :caption: Class (Graphviz) :target: ``` ```{statemachine-diagram} tests.machines.showcase_history.HistorySC :format: mermaid :caption: Class (Mermaid) ``` ```{statemachine-diagram} tests.machines.showcase_history.HistorySC :events: begin, advance :caption: Step2 :target: ``` ```{statemachine-diagram} tests.machines.showcase_history.HistorySC :events: begin, advance, pause :caption: Paused :target: ``` ```{statemachine-diagram} tests.machines.showcase_history.HistorySC :events: begin, advance, pause, resume :caption: Resumed (→Step2) :target: ``` ### Deep history Deep history remembers the exact leaf state across nested compounds. ```{literalinclude} ../tests/machines/showcase_deep_history.py :pyobject: DeepHistorySC :language: python ``` ```{statemachine-diagram} tests.machines.showcase_deep_history.DeepHistorySC :caption: Class (Graphviz) :target: ``` ```{statemachine-diagram} tests.machines.showcase_deep_history.DeepHistorySC :format: mermaid :caption: Class (Mermaid) ``` ```{statemachine-diagram} tests.machines.showcase_deep_history.DeepHistorySC :events: dive, enter_inner, go :caption: Inner/B :target: ``` ```{statemachine-diagram} tests.machines.showcase_deep_history.DeepHistorySC :events: dive, enter_inner, go, leave, restore :caption: Restored (→Inner/B) :target: ``` ================================================ FILE: docs/error_handling.md ================================================ (error-handling)= # Error handling ```{seealso} New to statecharts? See [](concepts.md) for an overview of how states, transitions, events, and actions fit together. ``` What happens when a callback raises an exception during a transition? With `StateChart`, errors in actions are caught by the engine and dispatched as `error.execution` internal events — so the machine itself can react to failures by transitioning to an error state, retrying, or recovering. This follows the [SCXML error handling specification](https://www.w3.org/TR/scxml/#errorsAndEvents). ```{tip} `catch_errors_as_events` is a class attribute that controls this behavior. `StateChart` uses `True` by default (SCXML-compliant); set it to `False` to let exceptions propagate to the caller instead. See {ref}`behaviour` for the full comparison of behavioral attributes and how to customize them. ``` (error-execution)= ## How errors are caught When an action raises during a {ref}`microstep `, the engine catches the exception at the **block level**. Each phase of the microstep is an independent block: | Block | Callbacks | |---|---| | Exit | `on_exit_state()`, `on_exit_()` | | On | `on_transition()`, `on_()` | | Enter | `on_enter_state()`, `on_enter_()` | An error in one block: - **Stops remaining actions in that block** — per the SCXML spec, execution MUST NOT continue within the same block after an error. - **Does not affect other blocks** — subsequent phases of the microstep still execute. In particular, **`after` callbacks always run** regardless of errors in earlier blocks. This means that even if a transition's `on` action raises, the target states are still entered and `after_()` callbacks still run. The error is caught and queued as an `error.execution` internal event that fires within the same {ref}`macrostep `. ```{note} `before` callbacks run before any state changes, so an error in `before` prevents the transition from executing — but `after` still runs because it belongs to a separate block. ``` ## The `error.execution` event After catching an error, the engine places an `error.execution` event on the internal queue. You can define transitions for this event to handle errors within the state machine itself — transitioning to error states, logging, or recovering. ### The `error_` naming convention Since Python identifiers cannot contain dots, any event attribute starting with `error_` automatically matches both the underscore and dot-notation forms. For example, `error_execution` matches both `"error_execution"` and `"error.execution"`: ```py >>> from statemachine import State, StateChart >>> class ResilientChart(StateChart): ... operational = State(initial=True) ... broken = State(final=True) ... ... do_work = operational.to(operational, on="risky_action") ... error_execution = operational.to(broken) ... ... def risky_action(self): ... raise RuntimeError("something went wrong") >>> sm = ResilientChart() >>> sm.send("do_work") >>> "broken" in sm.configuration_values True ``` ```{note} If you provide an explicit `id=` parameter on the `Event`, it takes precedence and the naming convention is not applied. ``` ### Accessing error data The original exception is available as `error` in the keyword arguments of callbacks on the `error.execution` transition. Use {ref}`dependency injection ` to receive it: ```py >>> from statemachine import State, StateChart >>> class ErrorLogger(StateChart): ... running = State(initial=True) ... failed = State(final=True) ... ... process = running.to(running, on="do_process") ... error_execution = running.to(failed, on="log_error") ... ... def do_process(self): ... raise ValueError("bad data") ... ... def log_error(self, error): ... self.last_error = error >>> sm = ErrorLogger() >>> sm.send("process") >>> str(sm.last_error) 'bad data' ``` ### Error in error handler If the `error.execution` handler itself raises, the engine **ignores** the second error (logging a warning) to prevent infinite loops. The machine remains in whatever configuration it reached before the failed handler. ```{note} During `error.execution` processing, errors in transition `on` content are **not** caught at block level — they propagate to the microstep where they are silently discarded. This prevents infinite loops when an error handler's own action raises (e.g., a self-transition `error_execution = s1.to(s1, on="handler")` where `handler` raises). Entry/exit blocks still use block-level catching regardless of the current event. ``` (error-handling-cleanup-finalize)= ## Cleanup / finalize pattern A common need is to run cleanup code after a transition **regardless of success or failure** — releasing a lock, closing a connection, or clearing temporary state. Because errors are caught at the block level, `after_()` callbacks always run — making them a natural **finalize** hook, similar to Python's `try/finally`: ```py >>> from statemachine import Event, State, StateChart >>> class ResourceManager(StateChart): ... idle = State(initial=True) ... working = State() ... recovering = State() ... ... start = idle.to(working) ... done = working.to(idle) ... recover = recovering.to(idle) ... error_execution = Event(working.to(recovering), id="error.execution") ... ... def __init__(self, should_fail=False): ... self.should_fail = should_fail ... self.released = False ... super().__init__() ... ... def on_enter_working(self): ... if self.should_fail: ... raise RuntimeError("something went wrong") ... self.raise_("done") ... ... def after_start(self): ... self.released = True # always runs — finalize hook ... ... def on_enter_recovering(self, error): ... self.last_error = error ... self.raise_("recover") ``` On the **success** path, the machine transitions `idle → working → idle` and `after_start` releases the resource: ```py >>> sm = ResourceManager(should_fail=False) >>> sm.send("start") >>> "idle" in sm.configuration_values True >>> sm.released True ``` On the **failure** path, the action raises, but `after_start` **still runs**. Then `error.execution` fires, transitions to `recovering`, and auto-recovers back to `idle`: ```py >>> sm = ResourceManager(should_fail=True) >>> sm.send("start") >>> "idle" in sm.configuration_values True >>> sm.released # finalize ran despite the error True >>> str(sm.last_error) 'something went wrong' ``` ```{seealso} See {ref}`sphx_glr_auto_examples_statechart_cleanup_machine.py` for a more detailed version of this pattern with annotated output. ``` ## Validators do not trigger error events {ref}`Validators ` operate in the **transition-selection** phase, before any state changes occur. Their exceptions **always propagate** to the caller — they are never caught by the engine and never converted to `error.execution` events, regardless of the `catch_errors_as_events` setting. This is intentional: a validator rejection means the transition should not happen at all. It is semantically equivalent to a condition returning `False`, but communicates the reason via an exception. ```{seealso} See {ref}`validators` for examples and the full semantics of validator propagation. ``` ```{seealso} See {ref}`behaviour` for the full comparison of behavioral attributes and how to customize `catch_errors_as_events` and other settings. See {ref}`actions` for the callback execution order within each microstep. ``` ================================================ FILE: docs/events.md ================================================ (events)= (event)= # Events ```{seealso} New to statecharts? See [](concepts.md) for an overview of how states, transitions, events, and actions fit together. ``` An **event** is a named signal that drives the state machine forward. When you assign a transition to a class-level name, that name becomes an event — the library creates an `Event` object automatically. Events are the external interface of your machine: callers send event names, and the machine decides which transitions to take. (declaring-events)= ## Declaring events The simplest way to declare an event is by assigning a transition to a name: ```py >>> from statemachine import Event, State, StateChart >>> class SimpleSM(StateChart): ... initial = State(initial=True) ... final = State(final=True) ... ... start = initial.to(final) >>> isinstance(SimpleSM.start, Event) True ``` The name `start` is automatically converted to an `Event` with `id="start"`. Multiple transitions can share the same event using the `|` operator: ```py >>> class TrafficLight(StateChart): ... green = State(initial=True) ... yellow = State() ... red = State() ... ... cycle = green.to(yellow) | yellow.to(red) | red.to(green) >>> sm = TrafficLight() >>> sm.send("cycle") >>> sm.yellow.is_active True ``` For better IDE support (autocompletion, type checking) or to set a human-readable display name, use the `Event` class explicitly: ```py >>> class SimpleSM(StateChart): ... initial = State(initial=True) ... final = State(final=True) ... ... start = Event(initial.to(final), name="Start the machine") >>> SimpleSM.start.name 'Start the machine' >>> SimpleSM.start.id 'start' ``` (event-identity)= ## Event identity: `id` vs `name` Every event has two string properties: - **`id`** — the programmatic identifier, derived from the class attribute name. Use this in `send()`, guards, and comparisons. - **`name`** — a human-readable label for display purposes. Auto-generated from the `id` by replacing `_` and `.` with spaces and capitalizing the first word. You can override the automatic name by passing `name=` explicitly when declaring the event: ```py >>> TrafficLight.cycle.id 'cycle' >>> TrafficLight.cycle.name 'Cycle' >>> class Example(StateChart): ... on = State(initial=True) ... off = State(final=True) ... shut_down = Event(on.to(off), name="Shut the system down") >>> Example.shut_down.name 'Shut the system down' ``` ```{tip} Always use `event.id` for programmatic checks. The `name` property is intended for UI display and may differ from the `id`. ``` (triggering-events)= (triggering events)= ## Triggering events Once declared, events are triggered on a {ref}`StateChart ` instance in two ways: - **As a method call:** `sm.cycle()` — when the event name is known at development time. - **Via `send()`:** `sm.send("cycle")` — when the event name is dynamic (e.g., from user input, a message queue, or a data file). Both styles produce the same result. The machine evaluates {ref}`guard conditions `, executes {ref}`actions`, and updates the {ref}`configuration `. ```{seealso} See {ref}`sending-events` for the full runtime API — `send()`, `raise_()`, delayed events, and cancellation. ``` (event-parameter)= ## The `event` parameter on transitions Each transition accepts an optional `event` parameter that binds it to a specific event, overriding the default (which is the class-level attribute name). This lets individual transitions within a group respond to their own event identifiers: ```py >>> from statemachine import Event, State, StateChart >>> class TrafficLightMachine(StateChart): ... green = State(initial=True) ... yellow = State() ... red = State() ... ... slowdown = Event(name="Slowing down") ... ... cycle = Event( ... green.to(yellow, event=slowdown) ... | yellow.to(red, event="stop") ... | red.to(green, event="go"), ... name="Loop", ... ) >>> sm = TrafficLightMachine() >>> sm.send("cycle") # umbrella event — dispatches green→yellow >>> sm.yellow.is_active True >>> sm.send("stop") # individual event — dispatches yellow→red >>> sm.red.is_active True >>> sm.send("go") # individual event — dispatches red→green >>> sm.green.is_active True ``` The `event` parameter accepts a string, an `Event` instance, a reference to a previously declared `Event` (like `slowdown` above), or a **list** of any of these. A space-separated string is also accepted and split into individual events automatically: ```py >>> class MultiEvent(StateChart): ... a = State(initial=True) ... b = State(final=True) ... ... # Both forms are equivalent — the transition responds to "move", "go" and "start" ... move = a.to(b, event=["go", "start"]) >>> sm = MultiEvent() >>> sm.send("move") >>> sm.b.is_active True >>> sm = MultiEvent() >>> sm.send("go") >>> sm.b.is_active True >>> sm = MultiEvent() >>> sm.send("start") >>> sm.b.is_active True ``` ```{tip} This is an advanced feature. Most state machines only need the simple `name = source.to(target)` form. Use the `event` parameter when you need fine-grained control over event routing within a composite transition group. ``` (done-state-events)= ## Automatic events The engine generates certain events automatically in response to structural changes. You don't send these yourself — you define transitions that react to them. ### `done.state` events ```{versionadded} 3.0.0 ``` When a {ref}`compound state's ` final child is entered, the engine queues a `done.state.{parent_id}` internal event. Define a transition for this event to react when a compound's work is complete: ```py >>> from statemachine import State, StateChart >>> class QuestWithDone(StateChart): ... class quest(State.Compound): ... traveling = State(initial=True) ... arrived = State(final=True) ... finish = traveling.to(arrived) ... celebration = State(final=True) ... done_state_quest = quest.to(celebration) >>> sm = QuestWithDone() >>> sm.send("finish") >>> set(sm.configuration_values) == {"celebration"} True ``` For {ref}`parallel states `, the `done.state` event fires only when **all** regions have reached a final state: ```py >>> from statemachine import State, StateChart >>> class WarWithDone(StateChart): ... class war(State.Parallel): ... class quest(State.Compound): ... start_q = State(initial=True) ... end_q = State(final=True) ... finish_q = start_q.to(end_q) ... class battle(State.Compound): ... start_b = State(initial=True) ... end_b = State(final=True) ... finish_b = start_b.to(end_b) ... peace = State(final=True) ... done_state_war = war.to(peace) >>> sm = WarWithDone() >>> sm.send("finish_q") >>> "war" in sm.configuration_values True >>> sm.send("finish_b") >>> set(sm.configuration_values) == {"peace"} True ``` (donedata)= #### DoneData Final states can carry data to their `done.state` handlers via the `donedata` parameter. The value should be a callable (or method name string) that returns a `dict`, which is forwarded as keyword arguments to the transition handler: ```py >>> from statemachine import Event, State, StateChart >>> class QuestCompletion(StateChart): ... class quest(State.Compound): ... traveling = State(initial=True) ... completed = State(final=True, donedata="get_result") ... finish = traveling.to(completed) ... def get_result(self): ... return {"hero": "frodo", "outcome": "victory"} ... epilogue = State(final=True) ... done_state_quest = Event(quest.to(epilogue, on="capture_result")) ... def capture_result(self, hero=None, outcome=None, **kwargs): ... self.result = f"{hero}: {outcome}" >>> sm = QuestCompletion() >>> sm.send("finish") >>> sm.result 'frodo: victory' ``` ```{note} `donedata` can only be specified on `final=True` states. Attempting to use it on a non-final state raises `InvalidDefinition`. ``` ### `error.execution` events When a callback raises during a macrostep and {ref}`catch_errors_as_events ` is enabled, the engine dispatches an `error.execution` internal event. Define a transition for this event to recover from errors within the statechart: ```py >>> from statemachine import State, StateChart >>> class ResilientChart(StateChart): ... working = State(initial=True) ... failed = State(final=True) ... ... go = working.to.itself(on="do_work") ... error_execution = working.to(failed) ... ... def do_work(self): ... raise RuntimeError("something went wrong") >>> sm = ResilientChart() >>> sm.send("go") >>> "failed" in sm.configuration_values True ``` ```{seealso} See {ref}`error-execution` for the full error handling reference: recovery patterns, `after` as a finalize hook, and nested error scenarios. ``` (naming-conventions)= ## Dot-notation naming conventions SCXML uses dot-separated event names (`done.state.quest`, `error.execution`), but Python identifiers cannot contain dots. The library provides prefix-based naming conventions that automatically register both forms: (done-state-convention)= ### `done_state_` prefix Any event attribute starting with `done_state_` matches both the underscore form and the dot-notation form. Only the prefix is replaced — the suffix is kept as-is, preserving multi-word state names: | Attribute name | Matches event names | |-------------------------------|---------------------| | `done_state_quest` | `"done_state_quest"` and `"done.state.quest"` | | `done_state_lonely_mountain` | `"done_state_lonely_mountain"` and `"done.state.lonely_mountain"` | ### `error_` prefix Any event attribute starting with `error_` matches both the underscore form and the dot-notation form. Unlike `done_state_`, **all** underscores after the prefix are replaced with dots: | Attribute name | Matches event names | |----------------------|---------------------| | `error_execution` | `"error_execution"` and `"error.execution"` | ```{note} If you provide an explicit `id=` parameter on the `Event`, it takes precedence and the naming convention is not applied. ``` ================================================ FILE: docs/guards.md ================================================ (validators-and-guards)= (validators and guards)= # Conditions ```{seealso} New to statecharts? See [](concepts.md) for an overview of how states, transitions, events, and actions fit together. ``` Conditions and validators are checked **before** a transition starts — they decide whether the transition is allowed to proceed. The difference is in how they communicate rejection: | Mechanism | Rejects by | Use when | |---|---|---| | {ref}`Conditions ` (`cond` / `unless`) | Returning a falsy value | You want the engine to silently skip the transition and try the next one. | | {ref}`Validators ` | Raising an exception | You want the caller to know *why* the transition was rejected. | Both run in the **transition-selection** phase, before any state changes occur. See the {ref}`execution order ` table for where they fit in the callback sequence. (guards)= (conditions)= ## Conditions A **condition** (also known as a _guard_) is a boolean predicate attached to a transition. When an event arrives, the engine checks each candidate transition in {ref}`declaration order ` — the first transition whose conditions are all satisfied is selected. If none match, the event is either ignored or raises an exception (see `allow_event_without_transition` in the {ref}`behaviour reference `). ```{important} A condition must not have side effects. Side effects belong in {ref}`actions`. ``` There are two guard clause variants: `cond` : A list of condition expressions. The transition is allowed only if **all** evaluate to `True`. - Single: `cond="is_valid"` - Multiple: `cond=["is_valid", "has_stock"]` `unless` : Same as `cond`, but the transition is allowed only if **all** evaluate to `False`. - Single: `unless="is_blocked"` - Multiple: `unless=["is_blocked", "is_expired"]` ```py >>> from statemachine import State, StateChart >>> class ApprovalFlow(StateChart): ... pending = State(initial=True) ... approved = State(final=True) ... rejected = State(final=True) ... ... approve = pending.to(approved, cond="is_manager") ... reject = pending.to(rejected) ... ... is_manager = False >>> sm = ApprovalFlow() >>> sm.send("approve") # cond is False — no transition >>> "pending" in sm.configuration_values True >>> sm.is_manager = True >>> sm.send("approve") >>> "approved" in sm.configuration_values True ``` ### Multiple conditional transitions When multiple transitions share the same event, guards let the engine pick the right one at runtime. Transitions are checked in **declaration order** (the order of `.to()` calls), not the order they appear in the `|` composition: ```py >>> class PriorityRouter(StateChart): ... inbox = State(initial=True) ... urgent = State(final=True) ... normal = State(final=True) ... low = State(final=True) ... ... # Declaration order = evaluation order ... route = ( ... inbox.to(urgent, cond="is_urgent") ... | inbox.to(normal, cond="is_normal") ... | inbox.to(low) # fallback — no condition ... ) ... ... def is_urgent(self, priority=0, **kwargs): ... return priority >= 9 ... ... def is_normal(self, priority=0, **kwargs): ... return priority >= 5 >>> sm = PriorityRouter() >>> sm.send("route", priority=2) >>> "low" in sm.configuration_values # fallback True >>> sm = PriorityRouter() >>> sm.send("route", priority=7) >>> "normal" in sm.configuration_values True >>> sm = PriorityRouter() >>> sm.send("route", priority=10) >>> "urgent" in sm.configuration_values # checked first True ``` Condition methods receive the same keyword arguments passed to `send()` via {ref}`dependency injection ` — declare only the parameters you need. ```{seealso} See {ref}`sphx_glr_auto_examples_air_conditioner_machine.py` for another example combining multiple transitions on the same event. ``` (condition expressions)= ### Condition expressions Conditions support a mini-language for boolean expressions, allowing guards to be defined as strings that reference attributes on the state machine, its model, or registered {ref}`listeners `. The mini-language is based on Python's built-in [`ast`](https://docs.python.org/3/library/ast.html) parser, so the syntax is familiar: ```py >>> class AccessControl(StateChart): ... locked = State(initial=True) ... unlocked = State(final=True) ... ... unlock = locked.to(unlocked, cond="has_key and not is_locked_out") ... ... has_key = True ... is_locked_out = False >>> sm = AccessControl() >>> sm.send("unlock") >>> "unlocked" in sm.configuration_values True ``` ```{tip} All condition expressions are validated when the `StateChart` class is created. Invalid attribute names raise `InvalidDefinition` immediately, helping you catch typos early. ``` #### Syntax elements **Names** refer to attributes on the state machine instance, its model, or listeners. They can point to properties, attributes, or methods: - `is_active` — evaluated as `self.is_active` (property/attribute) - `check_stock` — if it's a method, it's called with {ref}`dependency injection ` **Boolean operators** (highest to lowest precedence): 1. `not` / `!` — Logical negation 2. `and` / `^` — Logical conjunction 3. `or` / `v` — Logical disjunction **Comparison operators:** `>`, `>=`, `==`, `!=`, `<`, `<=` **Parentheses** control evaluation order: ```python cond="(is_admin or is_moderator) and not is_banned" ``` #### Expression examples - `is_logged_in and has_permission` - `not is_active or is_admin` - `!(is_guest ^ has_access)` - `(is_admin or is_moderator) and !is_banned` - `count > 0 and count <= 10` ```{seealso} See {ref}`sphx_glr_auto_examples_lor_machine.py` for a complete example using boolean algebra in conditions. ``` (checking enabled events)= ### Checking enabled events The {ref}`allowed_events ` property returns events reachable from the current state based on topology alone — it does **not** evaluate guards. To check which events currently have their conditions satisfied, use `enabled_events()`: ```py >>> class TaskMachine(StateChart): ... idle = State(initial=True) ... running = State(final=True) ... ... start = idle.to(running, cond="has_enough_resources") ... ... def has_enough_resources(self, cpu=0, **kwargs): ... return cpu >= 4 >>> sm = TaskMachine() >>> [e.id for e in sm.allowed_events] ['start'] >>> sm.enabled_events() [] >>> [e.id for e in sm.enabled_events(cpu=8)] ['start'] ``` `enabled_events()` accepts `*args` / `**kwargs` that are forwarded to the condition callbacks, just like when triggering an event. This makes it useful for UI scenarios where you want to show or hide buttons based on whether an event's conditions are currently satisfied. ```{note} An event is considered **enabled** if at least one of its transitions from the current state has all conditions satisfied. If a condition raises an exception, the event is treated as enabled (permissive behavior). ``` (validators)= ## Validators Validators are imperative guards that **raise an exception** to reject a transition. While conditions silently skip a transition and let the engine try the next candidate, validators communicate the rejection reason directly to the caller. - Single: `validators="check_stock"` - Multiple: `validators=["check_stock", "check_credit"]` ```py >>> class OrderMachine(StateChart): ... pending = State(initial=True) ... confirmed = State(final=True) ... ... confirm = pending.to(confirmed, validators="check_stock") ... ... def check_stock(self, quantity=0, **kwargs): ... if quantity <= 0: ... raise ValueError("Quantity must be positive") >>> sm = OrderMachine() >>> sm.send("confirm", quantity=0) Traceback (most recent call last): ... ValueError: Quantity must be positive >>> "pending" in sm.configuration_values # state unchanged True >>> sm.send("confirm", quantity=5) # retry with valid data >>> "confirmed" in sm.configuration_values True ``` ### Validators always propagate Validator exceptions **always propagate** to the caller, regardless of the `catch_errors_as_events` flag. This is intentional: validators operate in the **transition-selection** phase, not the execution phase. A validator that rejects is semantically equivalent to a condition that returns `False` — the transition simply should not happen. The difference is that the validator communicates the reason via an exception. This means that even when `catch_errors_as_events = True` (the default for `StateChart`): - Validator exceptions are **not** converted to `error.execution` events. - Validator exceptions do **not** trigger `error.execution` transitions. - The caller receives the exception directly and can handle it with `try`/`except`. ```py >>> class GuardedWithErrorHandler(StateChart): ... idle = State(initial=True) ... active = State() ... error_state = State(final=True) ... ... start = idle.to(active, validators="check_input") ... do_work = active.to.itself(on="risky_action") ... error_execution = active.to(error_state) ... ... def check_input(self, value=None, **kwargs): ... if value is None: ... raise ValueError("Input required") ... ... def risky_action(self, **kwargs): ... raise RuntimeError("Boom") >>> sm = GuardedWithErrorHandler() >>> sm.send("start") Traceback (most recent call last): ... ValueError: Input required >>> "idle" in sm.configuration_values # NOT in error_state True >>> sm.send("start", value="ok") >>> "active" in sm.configuration_values True >>> sm.send("do_work") # action error → goes to error_state >>> "error_state" in sm.configuration_values True ``` The validator rejection propagates directly to the caller, while the action error in `risky_action()` is caught by the engine and routed through the `error.execution` transition. ### Combining validators and conditions Validators and conditions can be used together on the same transition. Validators run **first** — if a validator rejects, conditions are never evaluated: ```py >>> class CombinedGuards(StateChart): ... idle = State(initial=True) ... active = State(final=True) ... ... start = idle.to(active, validators="check_auth", cond="has_permission") ... ... has_permission = True ... ... def check_auth(self, token=None, **kwargs): ... if token != "valid": ... raise PermissionError("Invalid token") >>> sm = CombinedGuards() >>> sm.send("start", token="bad") Traceback (most recent call last): ... PermissionError: Invalid token >>> sm.send("start", token="valid") >>> "active" in sm.configuration_values True ``` ```{seealso} See the example {ref}`sphx_glr_auto_examples_all_actions_machine.py` for a complete demonstration of validator and condition resolution order. ``` ```{hint} In Python, specific values are considered **falsy** and evaluate as `False` in a boolean context: `None`, `0`, `0.0`, empty strings, lists, tuples, sets, and dictionaries, and instances of classes whose `__bool__()` or `__len__()` returns `False` or `0`. So `cond=lambda: []` evaluates as `False`. ``` ================================================ FILE: docs/how-to/coming_from_state_pattern.md ================================================ (coming-from-state-pattern)= # Coming from the State Pattern This guide is for developers familiar with the classic **State Pattern** from the Gang of Four book (*Design Patterns: Elements of Reusable Object-Oriented Software*). It walks through a typical State Pattern implementation, discusses its trade-offs, and shows how to express the same behavior declaratively with python-statemachine. ## The classic State Pattern The GoF State Pattern models an object whose behavior changes based on its internal state. The standard recipe has three ingredients: 1. A **Context** class that delegates behavior to a state object. 2. An **abstract State** base class (or protocol) defining the interface. 3. **Concrete State** classes implementing state-specific behavior. Here is a complete example — an order workflow with four states (draft, confirmed, shipped, delivered) and a guard condition (orders can only be confirmed if they have at least one item): ```python from abc import ABC, abstractmethod class OrderState(ABC): """Abstract base for all order states.""" @abstractmethod def confirm(self, order): ... @abstractmethod def ship(self, order): ... @abstractmethod def deliver(self, order): ... class DraftState(OrderState): def confirm(self, order): if order.item_count <= 0: raise ValueError("Cannot confirm an empty order") order._state = ConfirmedState() print("Order confirmed") def ship(self, order): raise RuntimeError("Cannot ship a draft order") def deliver(self, order): raise RuntimeError("Cannot deliver a draft order") class ConfirmedState(OrderState): def confirm(self, order): raise RuntimeError("Order already confirmed") def ship(self, order): order._state = ShippedState() print("Order shipped") def deliver(self, order): raise RuntimeError("Cannot deliver before shipping") class ShippedState(OrderState): def confirm(self, order): raise RuntimeError("Cannot confirm a shipped order") def ship(self, order): raise RuntimeError("Order already shipped") def deliver(self, order): order._state = DeliveredState() print("Order delivered") class DeliveredState(OrderState): def confirm(self, order): raise RuntimeError("Order already delivered") def ship(self, order): raise RuntimeError("Order already delivered") def deliver(self, order): raise RuntimeError("Order already delivered") class Order: def __init__(self, item_count=0): self._state = DraftState() self.item_count = item_count def confirm(self): self._state.confirm(self) def ship(self): self._state.ship(self) def deliver(self): self._state.deliver(self) ``` This works — but notice how much code it takes for just four states and three events. ## Pros and cons of the classic pattern **Pros:** - Encapsulates state-specific behavior in dedicated classes, eliminating large `if/elif` chains. - Follows the Open/Closed Principle for adding new states — you create a new class without modifying existing ones. - Each state class is independently testable. **Cons:** - **Class explosion** — every state requires a full class, even if most methods just raise "invalid operation" errors. The example above has 4 state classes and 12 method implementations, 9 of which only raise exceptions. - **Transitions are scattered** — to understand the full workflow you must read every concrete state class. There is no single place showing all transitions at a glance. - **No structural validation** — orphaned states, unreachable states, or missing transitions are only discovered at runtime. - **Guards are manual** — conditions like "only confirm if items > 0" are embedded in method bodies, mixed with transition logic. - **No diagrams** — visualizing the state machine requires manual drawing. - **No async support** — adding async behavior requires rewriting the entire interface. - **Signature duplication** — every state class must implement every method, even the ones that are not valid for that state. ## Porting to python-statemachine The same order workflow expressed declaratively: ```py >>> from statemachine import State, StateChart >>> from statemachine.exceptions import TransitionNotAllowed >>> class OrderMachine(StateChart): ... allow_event_without_transition = False ... ... # States ... draft = State(initial=True) ... confirmed = State() ... shipped = State() ... delivered = State(final=True) ... ... # Transitions (the complete workflow at a glance) ... confirm = draft.to(confirmed, cond="has_items") ... ship = confirmed.to(shipped) ... deliver = shipped.to(delivered) ... ... item_count = 0 ... ... @property ... def has_items(self): ... return self.item_count > 0 >>> sm = OrderMachine() >>> sm.item_count = 3 >>> sm.send("confirm") >>> sm.confirmed.is_active True >>> sm.send("ship") >>> sm.shipped.is_active True >>> sm.send("deliver") >>> sm.delivered.is_active True ``` That is the **entire** state machine — states, transitions, and the guard condition, all in one place. Setting `allow_event_without_transition = False` gives strict behavior equivalent to the GoF pattern — invalid events raise `TransitionNotAllowed`: ```py >>> sm = OrderMachine() >>> sm.item_count = 3 >>> try: ... sm.send("ship") # can't ship from draft ... except TransitionNotAllowed: ... print("Blocked: can't ship from draft") Blocked: can't ship from draft ``` Guards work the same way — when the condition is not met, the transition is rejected: ```py >>> sm = OrderMachine() >>> try: ... sm.send("confirm") # item_count is 0 ... except TransitionNotAllowed: ... print("Cannot confirm an empty order") Cannot confirm an empty order ``` ### Going reactive The strict mode above is a direct equivalent of the GoF pattern. But `StateChart`'s default (`allow_event_without_transition = True`) follows the SCXML specification: events that have no valid transition are **skipped**. This makes the machine reactive — it only responds to events that are meaningful in its current state, without requiring the caller to know which events are valid: ```py >>> class ReactiveOrderMachine(StateChart): ... draft = State(initial=True) ... confirmed = State() ... shipped = State() ... delivered = State(final=True) ... ... confirm = draft.to(confirmed, cond="has_items") ... ship = confirmed.to(shipped) ... deliver = shipped.to(delivered) ... ... item_count = 0 ... ... @property ... def has_items(self): ... return self.item_count > 0 >>> sm = ReactiveOrderMachine() >>> sm.item_count = 3 >>> sm.send("ship") # no transition for "ship" from draft — skipped >>> sm.draft.is_active # still in draft True >>> sm.send("confirm") # this one is valid >>> sm.confirmed.is_active True ``` This is particularly useful when the machine receives events from external sources (message queues, UI frameworks, network protocols) where the sender doesn't track the machine's current state. See {ref}`behaviour` for a comparison of all class-level defaults. ### Adding callbacks State-specific behavior (e.g., sending notifications) uses naming conventions or inline declarations — no need to scatter logic across state classes: ```py >>> from statemachine import State, StateChart >>> class OrderWithCallbacks(StateChart): ... draft = State(initial=True) ... confirmed = State() ... shipped = State() ... delivered = State(final=True) ... ... confirm = draft.to(confirmed, cond="has_items") ... ship = confirmed.to(shipped) ... deliver = shipped.to(delivered) ... ... item_count = 0 ... ... def __init__(self, **kwargs): ... self.log = [] ... super().__init__(**kwargs) ... ... @property ... def has_items(self): ... return self.item_count > 0 ... ... def on_enter_confirmed(self): ... self.log.append("confirmed") ... ... def on_enter_shipped(self): ... self.log.append("shipped") ... ... def on_enter_delivered(self): ... self.log.append("delivered") >>> sm = OrderWithCallbacks() >>> sm.item_count = 2 >>> sm.send("confirm") >>> sm.send("ship") >>> sm.send("deliver") >>> sm.log ['confirmed', 'shipped', 'delivered'] ``` ### Structural validation catches design errors Imagine a new requirement: orders can be cancelled from `draft` or `confirmed`. With the GoF pattern, a developer adds a `CancelledState` class — but forgets to wire the transitions in `DraftState` and `ConfirmedState`. The code compiles and runs fine; the bug only surfaces when someone tries to cancel an order and discovers there is no way to reach `CancelledState`. In a large codebase with dozens of states, this kind of mistake can go unnoticed for a long time. python-statemachine catches this at **class definition time**: ```py >>> from statemachine import State, StateChart >>> from statemachine.exceptions import InvalidDefinition >>> try: ... class BrokenOrderMachine(StateChart): ... draft = State(initial=True) ... confirmed = State() ... shipped = State() ... delivered = State(final=True) ... cancelled = State(final=True) # added but never connected ... ... confirm = draft.to(confirmed) ... ship = confirmed.to(shipped) ... deliver = shipped.to(delivered) ... except InvalidDefinition as e: ... print(e) There are unreachable states. ...Disconnected states: ['cancelled'] ``` The fix is to declare the missing transitions — and now the full workflow is visible in a single glance: ```py >>> class FixedOrderMachine(StateChart): ... draft = State(initial=True) ... confirmed = State() ... shipped = State() ... delivered = State(final=True) ... cancelled = State(final=True) ... ... confirm = draft.to(confirmed) ... ship = confirmed.to(shipped) ... deliver = shipped.to(delivered) ... cancel = draft.to(cancelled) | confirmed.to(cancelled) >>> sm = FixedOrderMachine() >>> sm.send("cancel") >>> sm.cancelled.is_active True ``` ## Side-by-side comparison | Concept | State Pattern (GoF) | python-statemachine | |---|---|---| | State definition | One class per state | `State()` class attribute | | Transition | Method in source state class sets `_state` | `.to()` declaration | | Guard / condition | `if` check inside method body | `cond=` / `unless=` parameter | | Invalid transition | Manual `raise` in every method | `TransitionNotAllowed` or skipped ({ref}`configurable `) | | All transitions | Scattered across state classes | Visible in the class body | | Context / model | Separate `Order` class | `StateChart` itself (or `model=`) | | Adding a new state | New class + update all interfaces | New `State()` attribute + transitions | | Entry / exit actions | Manual in transition methods | `on_enter_()` / `on_exit_()` | | Diagrams | Manual | Built-in `_graph()` | | Validation | None (runtime errors only) | Definition-time structural checks | | Async support | Rewrite entire interface | Auto-detected from `async def` | | Dependency injection | Not available | Built-in via `SignatureAdapter` | ## What you gain By moving from the State Pattern to python-statemachine, you get: - **Declarative definition** — the entire workflow is visible in one class body. - **Structural validation** — unreachable states, missing transitions, and unresolved callbacks are caught before the machine ever runs (see {ref}`validations`). - **Automatic diagrams** — call `_graph()` on any instance to generate a Graphviz diagram (see {ref}`diagrams`). - **Guards and conditions** — use `cond=`, `unless=`, or {ref}`expression strings ` instead of manual `if` checks. - **Dependency injection** — callbacks receive only the parameters they declare (see {ref}`actions`). - **Async support** — define `async def` callbacks and the engine auto-switches to async processing (see {ref}`async`). - **Listeners** — attach cross-cutting concerns (logging, auditing) as separate objects without modifying the state machine (see {ref}`listeners`). - **No class explosion** — four states and three events require one class with a few attributes, not four classes with twelve methods. ================================================ FILE: docs/how-to/coming_from_transitions.md ================================================ (coming-from-transitions)= # Coming from pytransitions This guide helps users of the [*transitions*](https://github.com/pytransitions/transitions) library migrate to python-statemachine (or evaluate the differences). Code examples are shown side by side where possible. For a quick overview, jump to the {ref}`feature matrix `. ## At a glance | Aspect | *transitions* | python-statemachine | |---|---|---| | Definition style | Imperative (dicts/lists passed to `Machine`) | Declarative (class-level `State` and `.to()`) | | State definition | Strings or `State` objects in a list | Class attributes (`State(...)`) | | Transition definition | `add_transition()` / dicts | `.to()` chaining, `\|` composition | | Event triggers | Auto-generated methods on the model | `sm.send("event")` or `sm.event()` | | Callbacks | String names or callables, per-transition | Naming conventions + decorators, {ref}`dependency injection ` | | Conditions | `conditions=`, `unless=` | `cond=`, `unless=`, {ref}`expression strings ` | | Nested states | `HierarchicalMachine` + separator strings | `State.Compound` / `State.Parallel` nested classes | | Completion events | `on_final` callback only | `done.state` / `done.invoke` {ref}`automatic events ` with `donedata` | | Invoke | No | {ref}`Background work ` tied to state lifecycle | | Async | Separate `AsyncMachine` class | {ref}`Auto-detected ` from `async def` callbacks | | API surface | [12 Machine classes](https://github.com/pytransitions/transitions#-extensions) to combine features | {ref}`Single StateChart class ` — all features built in | | Diagrams | `GraphMachine` (separate base class) | Built-in {ref}`_graph() ` on every instance | | Model binding | `Machine(model=obj)` | {ref}`MachineMixin ` or `model=` parameter | | Listeners | Machine-level callbacks only | Full {ref}`observer pattern ` (class-level, constructor, runtime) | | Error handling | Exceptions propagate | Optional {ref}`catch_errors_as_events ` (`error.execution`) | | Validations | None | {ref}`Structural + callback checks ` at definition and creation time | | SCXML compliance | [Not a goal](https://github.com/pytransitions/transitions/issues/446#issuecomment-646837282) | {ref}`W3C conformant ` with automated test suite | | Processing model | Immediate or queued | Always queued ({ref}`run-to-completion `) | ## Defining states In *transitions*, states are defined as strings or dicts passed to the `Machine` constructor. States can exist without any transitions — the library does not validate structural consistency: ```python from transitions import Machine states = ["draft", "producing", "closed"] machine = Machine(states=states, initial="draft") # No transitions defined — "producing" and "closed" are unreachable, but no error is raised ``` In python-statemachine, states are class-level descriptors and **transitions are required**. The library validates structural integrity at class definition time — states without transitions are rejected: ```py >>> from statemachine import State, StateChart >>> from statemachine.exceptions import InvalidDefinition >>> try: ... class BadWorkflow(StateChart): ... draft = State(initial=True) ... producing = State() ... closed = State(final=True) ... except InvalidDefinition as e: ... print(e) There are unreachable states. ...Disconnected states: [...] ``` A valid definition requires transitions connecting all states: ```py >>> class Workflow(StateChart): ... draft = State(initial=True) ... producing = State() ... closed = State(final=True) ... produce = draft.to(producing) ... deliver = producing.to(closed) >>> sm = Workflow() >>> sm.draft.is_active True ``` States are first-class objects with properties like `is_active`, `value`, and `id`. You can set a human-readable name and a persistence value directly on the state. See {ref}`states` for the full reference. ```py >>> producing = State("Being produced", value=2) ``` ### Flat vs compound definitions In *transitions*, flat and hierarchical machines are **separate classes**. To use compound states you must switch from `Machine` to `HierarchicalMachine` and define the hierarchy through nested dicts — states and their children are described far from the transitions that connect them: ```python from transitions.extensions import HierarchicalMachine states = [ "idle", { "name": "active", "children": [ {"name": "working", "on_enter": "start_work"}, {"name": "paused"}, ], "initial": "working", }, "done", ] transitions = [ {"trigger": "start", "source": "idle", "dest": "active"}, {"trigger": "pause", "source": "active_working", "dest": "active_paused"}, {"trigger": "resume", "source": "active_paused", "dest": "active_working"}, {"trigger": "finish", "source": "active", "dest": "done"}, ] machine = HierarchicalMachine(states=states, transitions=transitions, initial="idle") ``` Note how child states are referenced with separator-based names (`active_working`, `active_paused`) and the structure is split across two separate data structures. In python-statemachine, `StateChart` handles both flat and compound machines. Compound states are nested Python classes that act as **namespaces** — children, transitions, and callbacks are declared together in the class body, mirroring the state hierarchy directly in code: ```py >>> from statemachine import State, StateChart >>> class TaskMachine(StateChart): ... idle = State(initial=True) ... ... class active(State.Compound): ... working = State(initial=True) ... paused = State() ... pause = working.to(paused) ... resume = paused.to(working) ... ... def on_enter_working(self): ... self.started = True ... ... done = State(final=True) ... ... start = idle.to(active) ... finish = active.to(done) >>> sm = TaskMachine() >>> sm.send("start") >>> sm.started True >>> sm.send("pause") >>> "paused" in sm.configuration_values True >>> sm.send("resume") >>> sm.send("finish") >>> sm.done.is_active True ``` Each compound class is self-contained: its children, internal transitions, and callbacks live inside the same block. This scales naturally to deeper hierarchies and parallel regions without switching to a different API. python-statemachine also supports hierarchical features not available in *transitions*: - {ref}`History pseudo-states ` (`HistoryState`) — remember and restore previous child states - {ref}`Eventless transitions ` — fire automatically when their guard condition is met See {ref}`compound-states` and {ref}`parallel-states` for the full reference. ### Creating machines from dicts If you prefer the dict-based definition style familiar from *transitions*, you can use {func}`~statemachine.io.create_machine_class_from_definition` to build a `StateChart` dynamically. It supports states, transitions, conditions, and callbacks (`on`, `before`, `after`, `enter`, `exit`): ```py >>> from statemachine.io import create_machine_class_from_definition >>> TrafficLight = create_machine_class_from_definition( ... "TrafficLight", ... states={ ... "green": { ... "initial": True, ... "on": {"change": [{"target": "yellow"}]}, ... }, ... "yellow": { ... "on": {"change": [{"target": "red"}]}, ... }, ... "red": { ... "on": {"change": [{"target": "green"}]}, ... }, ... }, ... ) >>> sm = TrafficLight() >>> sm.send("change") >>> sm.yellow.is_active True >>> sm.send("change") >>> sm.red.is_active True ``` The result is a regular `StateChart` subclass — all features (validations, diagrams, listeners, async) work exactly the same way. See {func}`~statemachine.io.create_machine_class_from_definition` for the full API. ## Defining transitions *transitions* uses dicts or `add_transition()`: ```python transitions = [ {"trigger": "produce", "source": "draft", "dest": "producing"}, {"trigger": "deliver", "source": "producing", "dest": "closed"}, {"trigger": "cancel", "source": ["draft", "producing"], "dest": "cancelled"}, ] machine = Machine(states=states, transitions=transitions, initial="draft") ``` python-statemachine uses `.to()` with `|` for composing multiple origins: ```py >>> from statemachine import State, StateChart >>> class Workflow(StateChart): ... draft = State(initial=True) ... producing = State() ... closed = State(final=True) ... cancelled = State(final=True) ... ... produce = draft.to(producing) ... deliver = producing.to(closed) ... cancel = draft.to(cancelled) | producing.to(cancelled) >>> sm = Workflow() >>> sm.send("produce") >>> sm.producing.is_active True ``` The `|` operator composes transitions from different sources into a single event. You can also use `from_()` to express the same thing from the target's perspective. See {ref}`transitions` for the full reference. ```py >>> class Workflow2(StateChart): ... draft = State(initial=True) ... producing = State() ... closed = State(final=True) ... cancelled = State(final=True) ... ... produce = draft.to(producing) ... deliver = producing.to(closed) ... cancel = cancelled.from_(draft, producing) >>> sm = Workflow2() >>> sm.send("produce") >>> sm.send("cancel") >>> sm.cancelled.is_active True ``` ## Triggering events In *transitions*, events are called as methods on the model: ```python machine.produce() # triggers the "produce" event machine.deliver() # triggers the "deliver" event ``` python-statemachine supports both styles: ```py >>> sm = Workflow() >>> sm.send("produce") # send by name (recommended for dynamic dispatch) >>> sm.producing.is_active True >>> sm.deliver() # call as method (convenient for static usage) >>> sm.closed.is_active True ``` `send()` is preferred when the event name comes from external input (e.g., an API endpoint or message queue). Direct method calls are convenient when you know the event at coding time. See {ref}`events` for the full reference. ## Callbacks and actions ### *transitions* callback order In *transitions*, callbacks execute in this order per transition: `prepare` → `conditions` → `before` → `on_exit_` → `on_enter_` → `after`. Callbacks are specified as strings (method names) or callables: ```python machine = Machine( states=states, transitions=[{ "trigger": "produce", "source": "draft", "dest": "producing", "before": "validate_job", "after": "notify_team", }], initial="draft", ) ``` ### python-statemachine callback order python-statemachine has a similar but more granular order: `prepare` → `validators` → `conditions` → `before` → `on_exit` → `on` → `on_enter` → `after`. The `on` group (between exit and enter) is unique to python-statemachine — it runs the transition's own action, separate from state entry/exit. See {ref}`actions` for the full execution order table. Callbacks are resolved by **naming convention** or by **inline declaration**: ```py >>> from statemachine import State, StateChart >>> class Workflow(StateChart): ... draft = State(initial=True) ... producing = State() ... closed = State(final=True) ... ... produce = draft.to(producing) ... deliver = producing.to(closed) ... ... # naming convention: on_enter_ ... def on_enter_producing(self): ... self.entered = True ... ... # naming convention: after_ ... def after_produce(self): ... self.notified = True >>> sm = Workflow() >>> sm.send("produce") >>> sm.entered True >>> sm.notified True ``` Inline callbacks are also supported: ```py >>> class Workflow2(StateChart): ... draft = State(initial=True) ... producing = State() ... closed = State(final=True) ... ... produce = draft.to(producing, on="do_produce") ... deliver = producing.to(closed) ... ... def do_produce(self): ... return "producing" >>> sm = Workflow2() >>> sm.send("produce") 'producing' ``` ### Dependency injection A key difference: python-statemachine callbacks use **dependency injection** via `SignatureAdapter`. The engine inspects each callback's signature and passes only the parameters it accepts. You never need `**kwargs` unless you want to capture extras: ```py >>> class Workflow(StateChart): ... draft = State(initial=True) ... producing = State() ... closed = State(final=True) ... ... produce = draft.to(producing) ... deliver = producing.to(closed) ... ... def on_produce(self, source, target): ... return f"{source.id} -> {target.id}" >>> sm = Workflow() >>> sm.send("produce") 'draft -> producing' ``` Available parameters include `source`, `target`, `event`, `state`, `error`, and any custom kwargs passed to `send()`. See {ref}`actions` for the complete list of available parameters. In *transitions*, you must accept `**kwargs` or use `EventData`: ```python def on_enter_producing(self, **kwargs): event_data = kwargs.get("event_data") ``` ## Conditions and guards In *transitions*: ```python machine.add_transition( "produce", "draft", "producing", conditions=["is_valid", "has_resources"], unless=["is_locked"], ) ``` In python-statemachine, use `cond=` and `unless=`: ```py >>> from statemachine import State, StateChart >>> class Workflow(StateChart): ... draft = State(initial=True) ... producing = State() ... closed = State(final=True) ... ... produce = draft.to(producing, cond="is_valid", unless="is_locked") ... deliver = producing.to(closed) ... ... is_valid = True ... is_locked = False >>> sm = Workflow() >>> sm.send("produce") >>> sm.producing.is_active True ``` python-statemachine also supports **condition expressions** — boolean strings evaluated at runtime. See {ref}`validators and guards` for the full reference. ```py >>> class Workflow2(StateChart): ... draft = State(initial=True) ... producing = State() ... closed = State(final=True) ... ... produce = draft.to(producing, cond="is_valid and not is_locked") ... deliver = producing.to(closed) ... ... is_valid = True ... is_locked = False >>> sm = Workflow2() >>> sm.send("produce") >>> sm.producing.is_active True ``` ## Completion events (`done.state`) In *transitions*, the `on_final` callback fires when a final state is entered (and propagates upward when all children of a compound are final). However, it is just a **callback** — it cannot trigger transitions automatically. You must wire separate triggers manually. In python-statemachine, when a compound state's final child is entered, the engine automatically dispatches a `done.state.` **event**. You define transitions for it using the `done_state_` naming convention, and the transition fires automatically — no manual wiring needed: ```py >>> from statemachine import State, StateChart >>> class Pipeline(StateChart): ... class processing(State.Compound): ... step1 = State(initial=True) ... step2 = State() ... completed = State(final=True) ... advance = step1.to(step2) ... finish = step2.to(completed) ... done = State(final=True) ... done_state_processing = processing.to(done) >>> sm = Pipeline() >>> sm.send("advance") >>> sm.send("finish") >>> sm.done.is_active True ``` For parallel states, `done.state` fires only when **all** regions have reached a final state. Final states can also carry data via `donedata`, which is forwarded as keyword arguments to the transition handler. See {ref}`done.state events ` and {ref}`DoneData ` for full details. ## Invoke *transitions* does not have a built-in mechanism for spawning background work tied to a state's lifecycle. In python-statemachine, a state can **invoke** external work — API calls, file I/O, child state machines — when it is entered, and automatically cancel that work when the state is exited. Handlers run in a background thread (sync engine) or a thread executor (async engine). When the work completes, a `done.invoke.` event is automatically dispatched: ```py >>> import time >>> from statemachine import State, StateChart >>> class FetchMachine(StateChart): ... loading = State(initial=True, invoke=lambda: {"status": "ok"}) ... ready = State(final=True) ... done_invoke_loading = loading.to(ready) >>> sm = FetchMachine() >>> time.sleep(0.1) >>> sm.ready.is_active True ``` Invoke supports multiple handlers (`invoke=[a, b]`), grouped invocations (`invoke_group`), child state machines, and the full callback naming conventions (`on_invoke_`, `@state.invoke`). See {ref}`invoke` for full documentation. ## Async support *transitions* requires a separate class: ```python from transitions.extensions import AsyncMachine class AsyncModel: async def on_enter_producing(self): await some_async_operation() machine = AsyncMachine(model=AsyncModel(), states=states, initial="draft") await machine.produce() ``` python-statemachine auto-detects async callbacks — no special class needed: ```py >>> import asyncio >>> from statemachine import State, StateChart >>> class AsyncWorkflow(StateChart): ... draft = State(initial=True) ... producing = State(final=True) ... ... produce = draft.to(producing) ... ... async def on_enter_producing(self): ... return "async entered" >>> async def main(): ... sm = AsyncWorkflow() ... await sm.send("produce") ... return sm.producing.is_active >>> asyncio.run(main()) True ``` If any callback is `async def`, the engine automatically switches to the async processing loop. Sync and async callbacks can be mixed freely. See {ref}`async` for the full reference. ## Diagrams In *transitions*, diagram support requires replacing `Machine` with `GraphMachine` — a separate base class. If you also need nested states, you must use `HierarchicalGraphMachine`; add async and it becomes `HierarchicalAsyncGraphMachine`. This is part of the {ref}`class composition problem ` discussed below. ```python from transitions.extensions import GraphMachine machine = GraphMachine(model=model, states=states, transitions=transitions, initial="draft") machine.get_graph().draw("diagram.png", prog="dot") ``` In python-statemachine, diagram generation is available on **every** state machine with no class changes. Every instance has a `_graph()` method built in, and `_repr_svg_()` renders directly in Jupyter notebooks: ```py >>> from statemachine import State, StateChart >>> class Workflow(StateChart): ... draft = State(initial=True) ... producing = State() ... closed = State(final=True) ... produce = draft.to(producing) ... deliver = producing.to(closed) >>> sm = Workflow() >>> graph = sm._graph() >>> type(graph).__name__ 'Dot' ``` For more control, use `DotGraphMachine` directly: ```python from statemachine.contrib.diagram import DotGraphMachine graph = DotGraphMachine(Workflow) graph().write_png("diagram.png") ``` Diagrams automatically render compound and parallel state hierarchies. See {ref}`diagrams` for the full reference. (unified-api)= ## Unified API vs class composition One of the most significant architectural differences between the two libraries is how features are composed. In *transitions*, each feature lives in a separate `Machine` subclass. Combining features requires using pre-built combined classes — the number of variants grows combinatorially: | Class | Nested | Diagrams | Locked | Async | |---|:---:|:---:|:---:|:---:| | `Machine` | | | | | | `HierarchicalMachine` | x | | | | | `GraphMachine` | | x | | | | `LockedMachine` | | | x | | | `AsyncMachine` | | | | x | | `HierarchicalGraphMachine` | x | x | | | | `LockedGraphMachine` | | x | x | | | `LockedHierarchicalMachine` | x | | x | | | `LockedHierarchicalGraphMachine` | x | x | x | | | `AsyncGraphMachine` | | x | | x | | `HierarchicalAsyncMachine` | x | | | x | | `HierarchicalAsyncGraphMachine` | x | x | | x | That is **12 classes** to cover all combinations — and switching from a flat machine to a hierarchical one requires changing the base class across your codebase. In python-statemachine, `StateChart` is the single base class. All features are always available: - **Nested states** — use `State.Compound` / `State.Parallel` in the class body - **Async** — auto-detected from `async def` callbacks - **Diagrams** — built-in `_graph()` on every instance - **Thread safety** — handled by the engine's run-to-completion processing loop ```py >>> import asyncio >>> from statemachine import State, StateChart >>> class FullFeatured(StateChart): ... """Nested + async + diagrams — same single base class.""" ... class phase(State.Compound): ... step1 = State(initial=True) ... step2 = State(final=True) ... advance = step1.to(step2) ... done = State(final=True) ... done_state_phase = phase.to(done) ... ... async def on_enter_done(self): ... self.result = "async action completed" >>> async def main(): ... sm = FullFeatured() ... graph = sm._graph() # diagrams work ... await sm.send("advance") # async works ... return sm.result >>> asyncio.run(main()) 'async action completed' ``` No class swapping, no feature matrices to consult — just `StateChart`. ## Model integration *transitions* binds directly to a model object: ```python class MyModel: pass model = MyModel() machine = Machine(model=model, states=states, transitions=transitions, initial="draft") model.produce() # events are added to the model ``` python-statemachine offers two approaches. See {ref}`domain models` for the full reference. **1. Pass a model to the state machine:** ```py >>> from statemachine import State, StateChart >>> class MyModel: ... pass >>> class Workflow(StateChart): ... draft = State(initial=True) ... producing = State(final=True) ... produce = draft.to(producing) >>> model = MyModel() >>> sm = Workflow(model=model) >>> sm.model is model True ``` **2. Use `MachineMixin` for ORM integration:** ```py >>> from statemachine.mixins import MachineMixin >>> class WorkflowModel(MachineMixin): ... state_machine_name = "__main__.Workflow" ... state_machine_attr = "sm" ... bind_events_as_methods = True ... ... state = 0 # persisted field ``` `MachineMixin` is particularly useful with Django models, where the state field is a database column. See {ref}`integrations ` for details. ## Listeners In *transitions*, cross-cutting concerns like logging or auditing are handled through machine-level callbacks (`prepare_event`, `finalize_event`, `on_exception`). These are callables passed to the `Machine` constructor — not separate objects. All callbacks must live on the model or be passed as functions: ```python machine = Machine( model=model, states=states, transitions=transitions, initial="draft", prepare_event="log_event", finalize_event="cleanup", ) ``` python-statemachine has a full **listener/observer pattern**. A listener is any object with methods matching the callback naming conventions — no base class required. Listeners are first-class: they receive the same callbacks as the state machine itself, with full {ref}`dependency injection `: ```py >>> from statemachine import State, StateChart >>> class AuditListener: ... def __init__(self): ... self.log = [] ... def after_transition(self, event, source, target): ... self.log.append(f"{event}: {source.id} -> {target.id}") >>> class OrderMachine(StateChart): ... listeners = [AuditListener] ... draft = State(initial=True) ... confirmed = State(final=True) ... confirm = draft.to(confirmed) >>> sm = OrderMachine() >>> sm.send("confirm") >>> sm.active_listeners[0].log ['confirm: draft -> confirmed'] ``` Listeners can be declared at the class level (`listeners = [...]`), passed at construction time (`OrderMachine(listeners=[...])`), or attached at runtime (`sm.add_listener(...)`). Multiple independent listeners compose naturally — each receives only the parameters it declares. Class-level listeners support inheritance (child listeners append after parent), a `setup()` protocol for receiving runtime dependencies (DB sessions, Redis clients), and `functools.partial` for configuration. See {ref}`listeners` for the full reference. ## Error handling *transitions* lets exceptions propagate normally: ```python try: machine.produce() except SomeError: # handle error pass ``` python-statemachine supports both styles. With `StateMachine` (the 2.x base class), exceptions propagate as in *transitions*. With `StateChart`, you can opt into structured error handling: ```py >>> from statemachine import State, StateChart >>> class RobustWorkflow(StateChart): ... draft = State(initial=True) ... error_state = State(final=True) ... ... go = draft.to(draft, on="bad_action") ... error_execution = draft.to(error_state) ... ... def bad_action(self): ... raise RuntimeError("something went wrong") >>> sm = RobustWorkflow() >>> sm.send("go") >>> sm.error_state.is_active True ``` When `catch_errors_as_events=True` (default in `StateChart`), runtime exceptions are caught and dispatched as `error.execution` internal events. You can define transitions that handle these errors, keeping the state machine in a consistent state. The error object is available as `error` in callback kwargs. See {ref}`error handling ` for full details. ## Validations *transitions* does not validate the consistency of your state machine definition. You can define unreachable states, trap states (non-final states with no outgoing transitions), or reference nonexistent callback names — and the library will not warn you. Errors only surface at runtime, when an event fails to trigger or a callback is not found. python-statemachine validates the statechart structure at **two stages**: 1. **Class definition time** — structural checks run as soon as the class body is evaluated. If any check fails, the class itself is not created: ```py >>> from statemachine import State, StateChart >>> from statemachine.exceptions import InvalidDefinition >>> try: ... class Bad(StateChart): ... red = State(initial=True) ... green = State() ... hazard = State() ... cycle = red.to(green) | green.to(red) ... blink = hazard.to.itself() ... except InvalidDefinition as e: ... print(e) There are unreachable states. The statemachine graph should have a single component. Disconnected states: ['hazard'] ``` 2. **Instance creation time** — callback resolution, boolean expression parsing, and other runtime checks: ```py >>> class MyChart(StateChart): ... a = State(initial=True) ... b = State(final=True) ... go = a.to(b, on="nonexistent_method") >>> try: ... MyChart() ... except InvalidDefinition as e: ... assert "Did not found name 'nonexistent_method'" in str(e) ``` Built-in validations include: exactly one initial state, no transitions from final states, unreachable states, trap states, final state reachability, internal transition targets, callback resolution, and boolean expression parsing. See {ref}`validations` for the full list. (feature-matrix)= ## Feature matrix | Feature | *transitions* | python-statemachine | |---|:---:|:---:| | Flat state machines | Yes | Yes | | {ref}`Compound (nested) states ` | Yes | Yes | | {ref}`Parallel states ` | Yes | Yes | | {ref}`History pseudo-states ` | No | **Yes** | | {ref}`Eventless transitions ` | No | **Yes** | | {ref}`Final states ` | Yes | Yes | | {ref}`Condition expressions ` | No | **Yes** | | {ref}`In() state checks ` | No | **Yes** | | {ref}`Dependency injection ` | No | **Yes** | | {ref}`Auto async detection ` | No | **Yes** | | {ref}`error.execution handling ` | No | **Yes** | | {ref}`done.state / done.invoke events ` | Callback only | **Yes** | | {ref}`Delayed events ` | No | **Yes** | | {ref}`Internal events (raise_()) ` | No | **Yes** | | {ref}`Invoke (background work) ` | No | **Yes** | | {ref}`Listener/observer pattern ` | No | **Yes** | | {ref}`Definition-time validations ` | No | **Yes** | | {ref}`SCXML conformance ` | No | **Yes** | | {ref}`Diagrams ` | Yes | Yes | | {ref}`Django integration ` | Community | Built-in | | {ref}`Model binding ` | Yes | Yes | | {ref}`Wildcard transitions (*) ` | Yes | Yes | | {ref}`Reflexive transitions ` | Yes | Yes | | Ordered transitions | Yes | Via explicit wiring | | Tags on states | Yes | Via subclassing | | {ref}`Machine nesting (children) ` | Yes | Yes (invoke) | | {ref}`Timeout transitions ` | Yes | Yes | ================================================ FILE: docs/index.md ================================================ ```{include} ../README.md ``` --- ```{toctree} :caption: Getting started :maxdepth: 2 :hidden: installation tutorial ``` ```{toctree} :caption: Core Concepts :maxdepth: 2 :hidden: concepts states transitions events actions guards ``` ```{toctree} :caption: Runtime :maxdepth: 2 :hidden: statechart processing_model error_handling async listeners ``` ```{toctree} :caption: Configuration :maxdepth: 2 :hidden: behaviour validations ``` ```{toctree} :caption: Advanced :maxdepth: 2 :hidden: invoke models integrations weighted_transitions timeout ``` ```{toctree} :caption: How to :maxdepth: 2 :hidden: how-to/coming_from_transitions how-to/coming_from_state_pattern ``` ```{toctree} :caption: Reference :maxdepth: 2 :hidden: api diagram auto_examples/index contributing authors ``` ```{toctree} :caption: Releases :maxdepth: 2 :hidden: releases/3.0.0 releases/upgrade_2x_to_3 releases/index ``` ================================================ FILE: docs/installation.md ================================================ # Installation ## Latest release To install using [uv](https://docs.astral.sh/uv): ```shell uv add python-statemachine ``` To install using [poetry](https://python-poetry.org/): ```shell poetry add python-statemachine ``` Alternatively, if you prefer using [pip](https://pip.pypa.io): ```shell python3 -m pip install python-statemachine ``` For those looking to generate diagrams from your state machines, [pydot](https://github.com/pydot/pydot) and [Graphviz](https://graphviz.org/) are required. Conveniently, you can install python-statemachine along with the `pydot` dependency using the extras option. For more information, please refer to our documentation. ```shell python3 -m pip install "python-statemachine[diagrams]" ``` ## From sources The sources for Python State Machine can be downloaded from the [Github repo](https://github.com/fgmacedo/python-statemachine). You can either clone the public repository: ```shell git clone git://github.com/fgmacedo/python-statemachine ``` Or download the `tarball`: ```shell curl -OL https://github.com/fgmacedo/python-statemachine/tarball/main ``` Once you have a copy of the source, you can install it with: ```shell python3 -m pip install -e . ``` ================================================ FILE: docs/integrations.md ================================================ # Integrations (machinemixin)= ## MachineMixin {ref}`Domain models` can inherit from `MachineMixin` to automatically instantiate and bind a {ref}`StateChart` to any Python class. This is the foundation for integrating state machines with ORMs and other domain objects. ```{seealso} See the [MachineMixin API reference](api.md#machinemixin) for the full list of attributes. ``` ### Example Given this state machine: ```py >>> from statemachine import StateChart, State >>> from statemachine.mixins import MachineMixin >>> class CampaignMachine(StateChart): ... "A workflow machine" ... draft = State('Draft', initial=True, value=1) ... producing = State('Being produced', value=2) ... closed = State('Closed', value=3, final=True) ... cancelled = State('Cancelled', value=4, final=True) ... ... add_job = draft.to.itself() | producing.to.itself() ... produce = draft.to(producing) ... deliver = producing.to(closed) ... cancel = cancelled.from_(draft, producing) ``` You can attach it to a model by inheriting from `MachineMixin` and setting `state_machine_name` to the fully qualified class name: ``` py >>> from statemachine import registry >>> registry.register(CampaignMachine) # register for lookup by qualname >>> registry._initialized = True # skip Django autodiscovery in doctest >>> class Workflow(MachineMixin): ... state_machine_name = '__main__.CampaignMachine' ... state_machine_attr = 'sm' ... state_field_name = 'workflow_step' ... bind_events_as_methods = True ... ... workflow_step = 1 >>> model = Workflow() >>> isinstance(model.sm, CampaignMachine) True >>> model.workflow_step 1 >>> model.sm.draft in model.sm.configuration True ``` With `bind_events_as_methods = True`, events become methods on the model itself: ``` py >>> model = Workflow() >>> model.produce() >>> model.workflow_step 2 >>> model.sm.cancel() # you can still call the SM directly >>> model.workflow_step 4 >>> model.sm.cancelled in model.sm.configuration True ``` ```{note} In this example `state_machine_name` uses a `__main__` prefix because the class is defined inline for doctest purposes. In your code, use the fully qualified path (e.g., `'myapp.statemachines.CampaignMachine'`). ``` (django integration)= ## Django integration When used in a Django App, this library implements an auto-discovery hook similar to how Django's built-in **admin** [autodiscover](https://docs.djangoproject.com/en/dev/ref/contrib/admin/#django.contrib.admin.autodiscover). > This library attempts to import a **statemachine** or **statemachines** module in each installed > application. Such modules are expected to register `StateChart` classes to be used with > the {ref}`MachineMixin`. ```{hint} We advise keeping {ref}`StateChart` definitions in their own modules to avoid circular references. If you place state machines in modules named `statemachine` or `statemachines` inside installed Django Apps, they will be automatically imported and registered. That said, nothing stops you from declaring your state machine alongside your models. ``` ### Django example ```py # campaign/statemachines.py from statemachine import StateChart from statemachine import State class CampaignMachine(StateChart): "A workflow machine" draft = State('Draft', initial=True, value=1) producing = State('Being produced', value=2) closed = State('Closed', value=3) cancelled = State('Cancelled', value=4) add_job = draft.to.itself() | producing.to.itself() produce = draft.to(producing) deliver = producing.to(closed) cancel = cancelled.from_(draft, producing) ``` Integrate with your Django model using `MachineMixin`: ```py # campaign/models.py from django.db import models from statemachine.mixins import MachineMixin class Campaign(models.Model, MachineMixin): state_machine_name = 'campaign.statemachines.CampaignMachine' state_machine_attr = 'sm' state_field_name = 'step' name = models.CharField(max_length=30) step = models.IntegerField() ``` ### Data migrations Django's `apps.get_model()` returns **historical model** classes that are dynamically created and don't carry user-defined class attributes like `state_machine_name`. Since version 2.6.0, `MachineMixin` detects these historical models and gracefully skips state machine initialization, so data migrations that use `apps.get_model()` work without errors. ```{note} The state machine instance will **not** be available on historical model objects. If your data migration needs to interact with the state machine, set the attributes manually on the historical model class: def backfill_data(apps, schema_editor): MyModel = apps.get_model("myapp", "MyModel") MyModel.state_machine_name = "myapp.statemachines.MyStateMachine" for obj in MyModel.objects.all(): obj.statemachine # now available ``` ================================================ FILE: docs/invoke.md ================================================ (invoke)= # Invoke Invoke lets a state spawn external work — API calls, file I/O, child state machines — when it is entered, and automatically cancel that work when the state is exited. This follows the [SCXML `` semantics](https://www.w3.org/TR/scxml/#invoke) and is similar to the **do activity** (`do/`) concept in UML Statecharts — an ongoing behavior that runs for the duration of a state and is cancelled when the state is exited. ## Execution model Invoke handlers run **outside** the main state machine processing loop: - **Sync engine**: each invoke handler runs in a **daemon thread**. - **Async engine**: each invoke handler runs in a **thread executor** (`loop.run_in_executor`), wrapped in an `asyncio.Task`. The executor is used because invoke handlers are expected to perform blocking I/O (network calls, file access, subprocess communication) that would freeze the event loop if run directly. When a handler completes, a `done.invoke..` event is automatically sent back to the machine. If the handler raises an exception, an `error.execution` event is sent instead. If the owning state is exited before the handler finishes, the invocation is **cancelled** — `ctx.cancelled` is set and `on_cancel()` is called on `IInvoke` handlers. ## Callback group Invoke is a first-class callback group, just like `enter` and `exit`. This means convention naming (`on_invoke_`), decorators (`@state.invoke`), inline callables, and the full {ref}`SignatureAdapter ` dependency injection all work out of the box. See the {ref}`actions` page for how invoke fits into the overall callback {ref}`Ordering` and the available {ref}`dependency injection ` parameters. ## Quick start The simplest invoke is a plain callable passed to the `invoke` parameter. Here we read a config file in a background thread and transition to `ready` when the data is available: ```py >>> import json >>> import tempfile >>> import time >>> from pathlib import Path >>> from statemachine import State, StateChart >>> config_file = Path(tempfile.mktemp(suffix=".json")) >>> _ = config_file.write_text('{"db_host": "localhost", "db_port": 5432}') >>> def load_config(): ... return json.loads(config_file.read_text()) >>> class ConfigLoader(StateChart): ... loading = State(initial=True, invoke=load_config) ... ready = State(final=True) ... done_invoke_loading = loading.to(ready) ... ... def on_enter_ready(self, data=None, **kwargs): ... self.config = data >>> sm = ConfigLoader() >>> time.sleep(0.2) >>> "ready" in sm.configuration_values True >>> sm.config {'db_host': 'localhost', 'db_port': 5432} >>> config_file.unlink() ``` When `loading` is entered, `load_config()` runs in a background thread. When it returns, a `done.invoke.loading.` event is automatically sent to the machine, triggering the `done_invoke_loading` transition. The return value is available as the `data` keyword argument in callbacks on the target state. ## Naming conventions Like `on_enter_` and `on_exit_`, invoke supports naming conventions (see {ref}`State actions` for the general pattern): - `on_invoke_state` — generic, called for every state with invoke - `on_invoke_` — specific to a state ```py >>> config_file = Path(tempfile.mktemp(suffix=".json")) >>> _ = config_file.write_text('{"feature_flags": ["dark_mode", "beta_api"]}') >>> class FeatureLoader(StateChart): ... loading = State(initial=True) ... ready = State(final=True) ... done_invoke_loading = loading.to(ready) ... ... def on_invoke_loading(self, **kwargs): ... """Naming convention: on_invoke_.""" ... return json.loads(config_file.read_text()) ... ... def on_enter_ready(self, data=None, **kwargs): ... self.features = data >>> sm = FeatureLoader() >>> time.sleep(0.2) >>> "ready" in sm.configuration_values True >>> sm.features["feature_flags"] ['dark_mode', 'beta_api'] >>> config_file.unlink() ``` ## Decorator syntax Use the `@state.invoke` decorator (same pattern as `@state.enter` and `@state.exit` — see {ref}`Bind state actions using decorator syntax`): ```py >>> config_file = Path(tempfile.mktemp(suffix=".txt")) >>> _ = config_file.write_text("line 1\nline 2\nline 3\n") >>> class LineCounter(StateChart): ... counting = State(initial=True) ... done = State(final=True) ... done_invoke_counting = counting.to(done) ... ... @counting.invoke ... def count_lines(self, **kwargs): ... text = config_file.read_text() ... return len(text.splitlines()) ... ... def on_enter_done(self, data=None, **kwargs): ... self.total_lines = data >>> sm = LineCounter() >>> time.sleep(0.2) >>> "done" in sm.configuration_values True >>> sm.total_lines 3 >>> config_file.unlink() ``` ## `done.invoke` transitions Use the `done_invoke_` naming convention to declare transitions that fire when an invoke handler completes: ```py >>> config_file = Path(tempfile.mktemp(suffix=".json")) >>> _ = config_file.write_text('{"version": "3.0.0"}') >>> class VersionChecker(StateChart): ... checking = State(initial=True, invoke=lambda: json.loads(config_file.read_text())) ... checked = State(final=True) ... done_invoke_checking = checking.to(checked) ... ... def on_enter_checked(self, data=None, **kwargs): ... self.version = data["version"] >>> sm = VersionChecker() >>> time.sleep(0.2) >>> "checked" in sm.configuration_values True >>> sm.version '3.0.0' >>> config_file.unlink() ``` The `done_invoke_` prefix maps to the `done.invoke.` event family, matching any invoke completion for that state regardless of the specific invoke ID. ## IInvoke protocol For advanced use cases, implement the `IInvoke` protocol. This gives you access to the `InvokeContext` — with the invoke ID, cancellation signal, event kwargs, and a reference to the parent machine: ```py >>> from statemachine.invoke import IInvoke, InvokeContext >>> class FileReader: ... """Reads a file and returns its content. Supports cancellation.""" ... def run(self, ctx: InvokeContext): ... # ctx.invokeid — unique ID for this invocation ... # ctx.state_id — the state that triggered invoke ... # ctx.cancelled — threading.Event, set when state exits ... # ctx.send — send events to parent machine ... # ctx.machine — reference to parent machine ... # ctx.kwargs — keyword arguments from the triggering event ... path = ctx.machine.file_path ... return Path(path).read_text() ... ... def on_cancel(self): ... pass # cleanup resources if needed >>> isinstance(FileReader(), IInvoke) True ``` Pass a class to the `invoke` parameter — each state machine instance gets a fresh handler: ```py >>> config_file = Path(tempfile.mktemp(suffix=".csv")) >>> _ = config_file.write_text("name,age\nAlice,30\nBob,25\n") >>> class CSVLoader(StateChart): ... loading = State(initial=True, invoke=FileReader) ... ready = State(final=True) ... done_invoke_loading = loading.to(ready) ... ... def __init__(self, file_path, **kwargs): ... self.file_path = file_path ... super().__init__(**kwargs) ... ... def on_enter_ready(self, data=None, **kwargs): ... self.content = data >>> sm = CSVLoader(file_path=str(config_file)) >>> time.sleep(0.2) >>> "ready" in sm.configuration_values True >>> sm.content 'name,age\nAlice,30\nBob,25\n' >>> config_file.unlink() ``` ## Cancellation When a state with active invoke handlers is exited: 1. `ctx.cancelled` is set (a `threading.Event`) — handlers should poll this 2. `on_cancel()` is called on `IInvoke` handlers (if defined) 3. For the async engine, the asyncio Task is cancelled Events from cancelled invocations are silently ignored. ```py >>> cancel_called = [] >>> class SlowFileReader: ... def run(self, ctx: InvokeContext): ... ctx.cancelled.wait(timeout=5.0) ... ... def on_cancel(self): ... cancel_called.append(True) >>> class CancelMachine(StateChart): ... loading = State(initial=True, invoke=SlowFileReader) ... stopped = State(final=True) ... cancel = loading.to(stopped) >>> sm = CancelMachine() >>> time.sleep(0.05) >>> sm.send("cancel") >>> time.sleep(0.05) >>> cancel_called [True] ``` ## Event data propagation When a state with invoke handlers is entered via an event, the keyword arguments from that event are forwarded to the invoke handlers. Plain callables receive them via {ref}`SignatureAdapter ` dependency injection; `IInvoke` handlers receive them via `ctx.kwargs`: ```py >>> config_file = Path(tempfile.mktemp(suffix=".json")) >>> _ = config_file.write_text('{"debug": true}') >>> class ConfigByName(StateChart): ... idle = State(initial=True) ... loading = State() ... ready = State(final=True) ... start = idle.to(loading) ... done_invoke_loading = loading.to(ready) ... ... def on_invoke_loading(self, file_name=None, **kwargs): ... """file_name comes from send('start', file_name=...).""" ... return json.loads(Path(file_name).read_text()) ... ... def on_enter_ready(self, data=None, **kwargs): ... self.config = data >>> sm = ConfigByName() >>> sm.send("start", file_name=str(config_file)) >>> time.sleep(0.2) >>> "ready" in sm.configuration_values True >>> sm.config {'debug': True} >>> config_file.unlink() ``` For initial states, any extra keyword arguments passed to the `StateChart` constructor are forwarded as event data. This makes self-contained machines that start processing immediately especially useful: ```py >>> config_file = Path(tempfile.mktemp(suffix=".json")) >>> _ = config_file.write_text('{"theme": "dark"}') >>> class AppLoader(StateChart): ... loading = State(initial=True) ... ready = State(final=True) ... done_invoke_loading = loading.to(ready) ... ... def on_invoke_loading(self, config_path=None, **kwargs): ... """config_path comes from the constructor: AppLoader(config_path=...).""" ... return json.loads(Path(config_path).read_text()) ... ... def on_enter_ready(self, data=None, **kwargs): ... self.config = data >>> sm = AppLoader(config_path=str(config_file)) >>> time.sleep(0.2) >>> "ready" in sm.configuration_values True >>> sm.config {'theme': 'dark'} >>> config_file.unlink() ``` ## Error handling If an invoke handler raises an exception, `error.execution` is sent to the machine's internal queue (when `catch_errors_as_events=True`, the default for `StateChart`). You can handle it with a transition for `error.execution`: ```py >>> class MissingFileLoader(StateChart): ... loading = State( ... initial=True, ... invoke=lambda: Path("/tmp/nonexistent_file_12345.json").read_text(), ... ) ... error_state = State(final=True) ... error_execution = loading.to(error_state) ... ... def on_enter_error_state(self, error=None, **kwargs): ... self.error_type = type(error).__name__ >>> sm = MissingFileLoader() >>> time.sleep(0.2) >>> "error_state" in sm.configuration_values True >>> sm.error_type 'FileNotFoundError' ``` ## Multiple invokes ### Independent invokes (one event each) Pass a list to run multiple handlers concurrently. Each handler is an independent invocation that sends its own `done.invoke..` completion event. This means that the **first** handler to complete triggers the `done_invoke_` transition, which exits the owning state and **cancels all remaining invocations**. If you need all handlers to complete before transitioning, use {func}`~statemachine.invoke.invoke_group` instead (see below). ```py >>> file_a = Path(tempfile.mktemp(suffix=".txt")) >>> file_b = Path(tempfile.mktemp(suffix=".txt")) >>> _ = file_a.write_text("hello") >>> _ = file_b.write_text("world") >>> class MultiLoader(StateChart): ... loading = State( ... initial=True, ... invoke=[lambda: file_a.read_text(), lambda: file_b.read_text()], ... ) ... ready = State(final=True) ... done_invoke_loading = loading.to(ready) >>> sm = MultiLoader() >>> time.sleep(0.2) >>> "ready" in sm.configuration_values True >>> file_a.unlink() >>> file_b.unlink() ``` This follows the [SCXML spec](https://www.w3.org/TR/scxml/#invoke): each `` is independent and generates its own completion event. Use this when you only need **any one** of the handlers to complete, or when each invoke is handled by a separate transition. ### Grouped invokes (wait for all) Use {func}`~statemachine.invoke.invoke_group` to run multiple callables concurrently and wait for **all** of them to complete before sending a single `done.invoke` event. Unlike independent invokes (list), the transition only fires after every callable finishes, and the `data` is a list of results in the same order as the input callables: ```py >>> from statemachine.invoke import invoke_group >>> file_a = Path(tempfile.mktemp(suffix=".txt")) >>> file_b = Path(tempfile.mktemp(suffix=".txt")) >>> _ = file_a.write_text("hello") >>> _ = file_b.write_text("world") >>> class BatchLoader(StateChart): ... loading = State( ... initial=True, ... invoke=invoke_group( ... lambda: file_a.read_text(), ... lambda: file_b.read_text(), ... ), ... ) ... ready = State(final=True) ... done_invoke_loading = loading.to(ready) ... ... def on_enter_ready(self, data=None, **kwargs): ... self.results = data >>> sm = BatchLoader() >>> time.sleep(0.2) >>> "ready" in sm.configuration_values True >>> sm.results ['hello', 'world'] >>> file_a.unlink() >>> file_b.unlink() ``` If any callable raises, the remaining ones are cancelled and an `error.execution` event is sent. If the owning state is exited before all callables finish, the group is cancelled. ## Child state machines Pass a `StateChart` subclass to spawn a child machine: ```py >>> class ChildMachine(StateChart): ... start = State(initial=True) ... end = State(final=True) ... go = start.to(end) ... ... def on_enter_start(self, **kwargs): ... self.send("go") >>> class ParentMachine(StateChart): ... loading = State(initial=True, invoke=ChildMachine) ... ready = State(final=True) ... done_invoke_loading = loading.to(ready) >>> sm = ParentMachine() >>> time.sleep(0.2) >>> "ready" in sm.configuration_values True ``` The child machine is instantiated and run when the parent's `loading` state is entered. When the child terminates (reaches a final state), a `done.invoke` event is sent to the parent, triggering the `done_invoke_loading` transition. ================================================ FILE: docs/listeners.md ================================================ (observers)= (listeners)= # Listeners A **listener** is an external object that observes a state machine's lifecycle without modifying its class definition. Listeners receive the same {ref}`generic callbacks ` as the state machine itself — `on_enter_state()`, `after_transition()`, `on_exit_state()`, and so on — making them ideal for cross-cutting concerns like logging, persistence, telemetry, or UI updates. Under the hood, the `StateChart` class itself is registered as a listener — this is how naming-convention callbacks like `on_enter_idle()` are discovered. {ref}`Domain models ` are also registered as listeners. This means that an external listener has the **same level of access** to callbacks as methods defined directly on the state machine class. ```{tip} Why use a listener instead of defining callbacks directly on the class? Listeners keep concerns **separate and reusable** — the same logging listener can observe any state machine, and you can attach multiple independent listeners without them interfering with each other. ``` ## Defining a listener A listener is any object with methods that match the {ref}`callback naming conventions `. The library inspects the method signatures and calls them with {ref}`dependency injection `, so each listener receives only the parameters it declares: ```py >>> from statemachine import State, StateChart >>> class LogListener: ... def __init__(self, name): ... self.name = name ... ... def after_transition(self, event, source, target): ... print(f"{self.name} after: {source.id}--({event})-->{target.id}") ... ... def on_enter_state(self, target, event): ... print(f"{self.name} enter: {target.id} from {event}") ``` No base class or interface is required — any object with matching method names works. ## Class-level declarations The most common way to attach listeners is at the class level, using the `listeners` class attribute. This ensures listeners are automatically created for every instance: ```py >>> class AuditListener: ... def __init__(self): ... self.log = [] ... ... def after_transition(self, event, source, target): ... self.log.append(f"{event}: {source.id} -> {target.id}") >>> class OrderMachine(StateChart): ... listeners = [AuditListener] ... ... draft = State(initial=True) ... confirmed = State(final=True) ... confirm = draft.to(confirmed) >>> sm = OrderMachine() >>> sm.send("confirm") >>> sm.active_listeners[0].log ['confirm: draft -> confirmed'] ``` The `listeners` attribute accepts two forms: - **Callable** (class, `functools.partial`, lambda): acts as a **factory** — called once per instance to produce a fresh listener. Use this for listeners that accumulate state. - **Instance** (pre-built object): **shared** across all instances. Use this for stateless listeners like a global logger. ### Configuration with `functools.partial` Use `functools.partial` to pass configuration to listener factories: ```py >>> from functools import partial >>> class HistoryListener: ... def __init__(self, max_size=50): ... self.max_size = max_size ... self.entries = [] ... ... def after_transition(self, event, source, target): ... self.entries.append(f"{source.id} -> {target.id}") ... if len(self.entries) > self.max_size: ... self.entries.pop(0) >>> class TrackedMachine(StateChart): ... listeners = [partial(HistoryListener, max_size=10)] ... ... s1 = State(initial=True) ... s2 = State(final=True) ... go = s1.to(s2) >>> sm = TrackedMachine() >>> sm.send("go") >>> sm.active_listeners[0].entries ['s1 -> s2'] ``` ### Inheritance Child class listeners are appended after parent listeners. The full MRO chain is respected: ```py >>> class SimpleLogListener: ... def after_transition(self, event, source, target): ... pass >>> class BaseMachine(StateChart): ... listeners = [SimpleLogListener] ... ... s1 = State(initial=True) ... s2 = State(final=True) ... go = s1.to(s2) >>> class ChildMachine(BaseMachine): ... listeners = [AuditListener] >>> sm = ChildMachine() >>> [type(l).__name__ for l in sm.active_listeners] ['SimpleLogListener', 'AuditListener'] ``` To **replace** parent listeners instead of extending, set `listeners_inherit = False`: ```py >>> class ReplacedMachine(BaseMachine): ... listeners_inherit = False ... listeners = [AuditListener] >>> sm = ReplacedMachine() >>> [type(l).__name__ for l in sm.active_listeners] ['AuditListener'] ``` ## Attaching at construction Pass listeners to the constructor for instance-specific observers. Runtime listeners are appended **after** class-level listeners: ```py >>> runtime_listener = AuditListener() >>> sm = OrderMachine(listeners=[runtime_listener]) >>> sm.send("confirm") >>> [type(l).__name__ for l in sm.active_listeners] ['AuditListener', 'AuditListener'] >>> runtime_listener.log ['confirm: draft -> confirmed'] ``` ## Attaching at runtime Use `add_listener()` to attach listeners to an already running instance. This is useful when the listener depends on runtime context or when you want to start observing after initialization: ```py >>> class LedPanel: ... def __init__(self, color): ... self.color = color ... self.is_on = False ... ... def on_enter_state(self, target, **kwargs): ... if target.id == self.color: ... self.is_on = True ... ... def on_exit_state(self, source, **kwargs): ... if source.id == self.color: ... self.is_on = False >>> class TrafficLight(StateChart): ... green = State(initial=True) ... yellow = State() ... red = State() ... ... cycle = green.to(yellow) | yellow.to(red) | red.to(green) >>> sm = TrafficLight() >>> green_led = LedPanel("green") >>> yellow_led = LedPanel("yellow") >>> sm.add_listener(green_led, yellow_led) # doctest: +ELLIPSIS TrafficLight... >>> green_led.is_on, yellow_led.is_on (False, False) >>> sm.send("cycle") >>> green_led.is_on, yellow_led.is_on (False, True) ``` ## The `setup()` protocol Listeners that need runtime dependencies (e.g., a database session, a Redis client) can define a `setup()` method. It is called during the state machine's `__init__` with the instance and any extra `**kwargs` passed to the constructor. {ref}`Dependency injection ` ensures each listener receives only the kwargs it declares: ```py >>> class DBListener: ... def __init__(self): ... self.session = None ... ... def setup(self, sm, session=None, **kwargs): ... self.session = session >>> class CacheListener: ... def __init__(self): ... self.redis = None ... ... def setup(self, sm, redis=None, **kwargs): ... self.redis = redis >>> class PersistentMachine(StateChart): ... listeners = [DBListener, CacheListener] ... ... s1 = State(initial=True) ... s2 = State(final=True) ... go = s1.to(s2) >>> sm = PersistentMachine(session="db_conn", redis="redis_conn") >>> sm.active_listeners[0].session 'db_conn' >>> sm.active_listeners[1].redis 'redis_conn' ``` Multiple listeners with different dependencies compose naturally — each `setup()` picks only the kwargs it needs. ```{note} The `setup()` method is only called on **factory-created** instances (callable entries in the `listeners` list). Shared instances (pre-built objects) do not receive `setup()` calls — they are assumed to be already configured. ``` ```{seealso} See {ref}`actions` for the full list of callback groups and {ref}`dependency injection ` for how method signatures are matched. ``` ================================================ FILE: docs/models.md ================================================ (domain models)= (models)= # Domain models If you need to use any other object to persist the current state, or you're using the state machine to control the flow of another object, you can pass this object to the `StateChart` constructor. If you don't pass an explicit model instance, this simple `Model` will be used: ```{literalinclude} ../statemachine/model.py :language: python :linenos: ``` ```{seealso} See the {ref}`sphx_glr_auto_examples_order_control_rich_model_machine.py` as example of using a domain object to hold attributes and methods to be used on the `StateChart` definition. ``` ```{hint} Domain models are registered as {ref}`listeners`, so you can have the same level of functionalities provided to the built-in {ref}`StateChart`, such as implementing all {ref}`actions` and {ref}`guards` on your domain model and keeping only the definition of {ref}`states` and {ref}`transitions` on the {ref}`StateChart`. ``` ## Typed models `StateChart` supports a generic type parameter so that type checkers (mypy, pyright) and IDEs can infer the type of `sm.model` and provide code completion. Declare your model class and pass it as a type parameter to `StateChart`: ```python >>> from statemachine import State, StateChart >>> class OrderModel: ... order_id: str = "" ... total: float = 0.0 ... def confirm(self): ... return f"Order {self.order_id} confirmed: ${self.total}" >>> class OrderWorkflow(StateChart["OrderModel"]): ... draft = State(initial=True) ... confirmed = State(final=True) ... confirm = draft.to(confirmed, on="on_confirm") ... def on_confirm(self): ... return self.model.confirm() >>> model = OrderModel() >>> model.order_id = "A-123" >>> model.total = 49.90 >>> sm = OrderWorkflow(model=model) >>> sm.send("confirm") 'Order A-123 confirmed: $49.9' ``` With this declaration, `sm.model` is typed as `OrderModel` instead of `Any`, so `sm.model.order_id`, `sm.model.total`, and `sm.model.confirm()` all get full autocompletion and type checking in your IDE. ```{note} When no type parameter is given (e.g. `class MySM(StateChart)`), the model defaults to `Any`, preserving full backward compatibility. ``` ================================================ FILE: docs/processing_model.md ================================================ (processing-model)= (processing model)= # Processing model The engine processes events following the [SCXML](https://www.w3.org/TR/scxml/#AlgorithmforSCXMLInterpretation) **run-to-completion** (RTC) model: each event is fully processed — all callbacks executed, all states entered/exited — before the next event starts. This guarantees the system is always in a consistent state when a new event arrives. > **Run to completion** — SCXML adheres to a run to completion semantics > in the sense that an external event can only be processed when the > processing of the previous external event has completed, i.e. when all > microsteps (involving all triggered transitions) have been completely > taken. > > — [W3C SCXML Specification](https://www.w3.org/TR/scxml/#AlgorithmforSCXMLInterpretation) ```{seealso} See {ref}`actions` for the callback execution order within each step, {ref}`sending-events` for how to trigger events, and {ref}`behaviour` for customizations that affect how the engine processes transitions. ``` (macrostep-microstep)= ## Macrosteps and microsteps The processing loop is organized into two levels: ### Microstep A **microstep** is the smallest unit of processing. It takes a set of enabled transitions and walks them through a fixed sequence of callback groups defined in the {ref}`execution order `: 1. **Prepare** — enrich event kwargs. 2. **Validators / Conditions** — check if the transition is allowed. 3. **Before** — run pre-transition callbacks. 4. **Exit** — leave source states (innermost first). 5. **On** — execute transition actions. 6. **Enter** — enter target states (outermost first). 7. **Invoke** — spawn background work. 8. **After** — run post-transition callbacks (always runs, even on error). ```{tip} If an error occurs during steps 3–6 and `catch_errors_as_events` is enabled, the error is caught at the **block level** — remaining actions in that block are skipped, but the microstep continues. See {ref}`error-execution` and the {ref}`cleanup / finalize pattern `. ``` ### Macrostep A **macrostep** is a complete processing cycle triggered by a single external event. It consists of one or more microsteps and only ends when the machine reaches a **stable configuration** — no eventless transitions are enabled and the internal queue is empty. Within a single macrostep, the engine repeats: 1. **Check eventless transitions** — transitions without an event that fire automatically when their guard conditions are met. 2. **Drain the internal queue** — events placed by `raise_()` are processed immediately, before any external events. 3. If neither step produced a transition, the macrostep is **done**. After the macrostep completes, the engine picks the next event from the **external queue** (placed by `send()`) and starts a new macrostep. ### Event queues The engine maintains two separate FIFO queues: | Queue | How to enqueue | When processed | |---|---|---| | **Internal** | {func}`raise_() ` or `send(..., internal=True)` | Within the current macrostep | | **External** | {func}`send() ` | After the current macrostep ends | This distinction matters when you trigger events from inside callbacks. Using `raise_()` ensures the event is handled as part of the current processing cycle, while `send()` defers it to after the machine reaches a stable configuration. ```{seealso} See {ref}`sending-events` for examples of `send()` vs `raise_()`. ``` ### Processing loop overview The following diagram shows the complete processing loop: ``` send("event") │ ▼ ┌──────────────┐ │ External │ │ Queue │◄─────────────────────────────┐ └──────┬───────┘ │ │ pop event │ ▼ │ ┌──────────────────────────────────────┐ │ │ Macrostep │ │ │ │ │ │ ┌──────────────────────┐ │ │ │ │ Eventless transitions│◄──┐ │ │ │ │ enabled? │ │ │ │ │ └──────┬───────────────┘ │ │ │ │ yes │ no │ │ │ │ │ │ │ │ │ │ │ ▼ │ │ │ │ │ ┌──────────────┐ │ │ │ │ │ │ Internal │ │ │ │ │ │ │ queue empty? │ │ │ │ │ │ └──┬───────┬───┘ │ │ │ │ │ no │ yes │ │ │ │ │ │ │ │ │ │ │ │ │ │ ▼ │ │ │ │ │ │ Stable │ │ │ │ │ │ config ───┼───────┼──────┘ │ │ │ │ │ │ ▼ ▼ │ │ │ ┌──────────────┐ │ │ │ │ Microstep │────────┘ │ │ │ (execute │ │ │ │ transitions)│ │ │ └──────────────┘ │ │ │ └─────────────────────────────────────┘ ``` (rtc-model)= (rtc model)= (non-rtc model)= ## Run-to-completion in practice Consider a state machine where one transition triggers another via an `after` callback: ```py >>> from statemachine import StateChart, State >>> class ServerConnection(StateChart): ... disconnected = State(initial=True) ... connecting = State() ... connected = State(final=True) ... ... connect = disconnected.to(connecting, after="connection_succeed") ... connection_succeed = connecting.to(connected) ... ... def on_connect(self): ... return "on_connect" ... ... def on_enter_state(self, event: str, state: State, source: State): ... print(f"enter '{state.id}' from '{source.id if source else ''}' given '{event}'") ... ... def on_exit_state(self, event: str, state: State, target: State): ... print(f"exit '{state.id}' to '{target.id}' given '{event}'") ... ... def on_transition(self, event: str, source: State, target: State): ... print(f"on '{event}' from '{source.id}' to '{target.id}'") ... return "on_transition" ... ... def after_transition(self, event: str, source: State, target: State): ... print(f"after '{event}' from '{source.id}' to '{target.id}'") ... return "after_transition" ``` When `connect` is sent, the `after` callback triggers `connection_succeed`. Under the RTC model, `connection_succeed` is enqueued and processed only after `connect` completes: ```py >>> sm = ServerConnection() enter 'disconnected' from '' given '__initial__' >>> sm.send("connect") exit 'disconnected' to 'connecting' given 'connect' on 'connect' from 'disconnected' to 'connecting' enter 'connecting' from 'disconnected' given 'connect' after 'connect' from 'disconnected' to 'connecting' exit 'connecting' to 'connected' given 'connection_succeed' on 'connection_succeed' from 'connecting' to 'connected' enter 'connected' from 'connecting' given 'connection_succeed' after 'connection_succeed' from 'connecting' to 'connected' ['on_transition', 'on_connect'] ``` Notice that `connect` runs all its phases (exit → on → enter → after) before `connection_succeed` starts. The `after` callback of `connect` fires while the machine is still in `connecting` — and only then does `connection_succeed` begin its own microstep. ```{note} The `__initial__` event is a synthetic event that the engine fires during initialization to enter the initial state. It follows the same RTC model as any other event. ``` (continuous-machines)= ## Chaining transitions Some use cases require a machine that processes multiple steps automatically within a single macrostep, driven by internal events or eventless transitions rather than external calls. ### With `raise_()` Using {func}`raise_() ` inside callbacks places events on the **internal queue**, so they are processed within the current macrostep. This lets you chain multiple transitions from a single `send()` call: ```py >>> from statemachine import State, StateChart >>> class Pipeline(StateChart): ... start = State("Start", initial=True) ... step1 = State("Step 1") ... step2 = State("Step 2") ... done = State("Done", final=True) ... ... begin = start.to(step1) ... advance_1 = step1.to(step2) ... advance_2 = step2.to(done) ... ... def on_enter_step1(self): ... print(" step 1: extract") ... self.raise_("advance_1") ... ... def on_enter_step2(self): ... print(" step 2: transform") ... self.raise_("advance_2") ... ... def on_enter_done(self): ... print(" done: load complete") >>> sm = Pipeline() >>> sm.send("begin") step 1: extract step 2: transform done: load complete >>> [s.id for s in sm.configuration] ['done'] ``` All three steps execute within a single macrostep — the caller receives control back only after the pipeline reaches a stable configuration. ### With eventless transitions {ref}`Eventless transitions ` fire automatically whenever their guard condition is satisfied. Combined with a self-transition, this creates a loop that keeps running within the macrostep until the condition becomes false: ```py >>> from statemachine import State, StateChart >>> class RetryMachine(StateChart): ... trying = State("Trying", initial=True) ... success = State("Success", final=True) ... failed = State("Failed", final=True) ... ... # Eventless transitions: fire automatically based on guards ... trying.to.itself(cond="can_retry") ... trying.to(failed, cond="max_retries_reached") ... ... # Event-driven transition (external input) ... succeed = trying.to(success) ... ... def __init__(self, max_retries=3): ... self.attempts = 0 ... self.max_retries = max_retries ... super().__init__() ... ... def can_retry(self): ... return self.attempts < self.max_retries ... ... def max_retries_reached(self): ... return self.attempts >= self.max_retries ... ... def on_enter_trying(self): ... self.attempts += 1 ... print(f" attempt {self.attempts}") >>> sm = RetryMachine(max_retries=3) attempt 1 attempt 2 attempt 3 >>> [s.id for s in sm.configuration] ['failed'] ``` The machine starts, enters `trying` (attempt 1), and the eventless self-transition keeps firing as long as `can_retry()` returns `True`. Once the limit is reached, the second eventless transition fires — all within a single macrostep triggered by initialization. (thread-safety)= ## Thread safety State machines are **thread-safe** for concurrent event sending. Multiple threads can call `send()` or trigger events on the **same state machine instance** simultaneously — the engine guarantees correct behavior through its internal locking mechanism. ### How it works The processing loop uses a non-blocking lock (`threading.Lock`). When a thread sends an event: 1. The event is placed on the **external queue** (backed by a thread-safe `PriorityQueue` from the standard library). 2. If no other thread is currently running the processing loop, the sending thread acquires the lock and processes all queued events. 3. If another thread is already processing, the event is simply enqueued and will be processed by the thread that holds the lock — no event is lost. This means that **at most one thread executes transitions at any time**, preserving the run-to-completion (RTC) guarantee while allowing safe concurrent access. ### What is safe - **Multiple threads sending events** to the same state machine instance. - **Reading state** (`current_state_value`, `configuration`) from any thread while events are being processed. Note that transient `None` values may be observed for `current_state_value` during configuration updates when using [`atomic_configuration_update`](behaviour.md#atomic_configuration_update) `= False` (the default on `StateChart`, SCXML-compliant). With `atomic_configuration_update = True` (the default on `StateMachine`), the configuration is updated atomically at the end of the microstep, so `None` is not observed. - **Invoke handlers** running in background threads or thread executors communicate with the parent machine via the thread-safe event queue. ### What to avoid - **Do not share a state machine instance across threads with the async engine** unless you ensure only one event loop drives the machine. The async engine is designed for `asyncio` concurrency, not thread-based concurrency. - **Callbacks execute in the processing thread**, not in the thread that sent the event. Design callbacks accordingly (e.g., use locks if they access shared external state). ================================================ FILE: docs/releases/0.1.0.md ================================================ # StateMachine 0.1.0 *2017-03-21* * First release on PyPI. ================================================ FILE: docs/releases/0.2.0.md ================================================ # StateMachine 0.2.0 *2017-03-22* - ``State`` can hold a value that will be assigned to the model as the state value. - Travis-CI integration. - RTD integration. ================================================ FILE: docs/releases/0.3.0.md ================================================ # StateMachine 0.3.0 *2017-03-22* - README getting started section. - Tests to state machine without model. ================================================ FILE: docs/releases/0.4.2.md ================================================ # StateMachine 0.4.2 *2017-07-10* ## Python compatibility on 0.4.2 - Python 3.6 support. - Drop official support for Python 3.3. - `Transition` can be used as decorator for `on_execute` callback definition. - `Transition` can point to multiple destination states. ================================================ FILE: docs/releases/0.5.0.md ================================================ # StateMachine 0.5.0 *2017-07-13* - Custom exceptions. - Duplicated definition of ``on_execute`` callback is not allowed. - Fix bug on ``StateMachine.on_`` being called with extra ``self`` param. ================================================ FILE: docs/releases/0.5.1.md ================================================ # StateMachine 0.5.1 *2017-07-24* - Fix bug on ``CombinedTransition._can_run`` not allowing transitions to run if there are more than two transitions combined. ================================================ FILE: docs/releases/0.6.0.md ================================================ # StateMachine 0.6.0 *2017-08-25* - Auto-discovering `statemachine`/`statemachines` under a Django project when they are requested using the mixin/registry feature. ================================================ FILE: docs/releases/0.6.1.md ================================================ # StateMachine 0.6.1 *2017-08-25* - Fix deploy issues. ================================================ FILE: docs/releases/0.6.2.md ================================================ # StateMachine 0.6.2 *2017-08-25* - Fix README. ================================================ FILE: docs/releases/0.7.0.md ================================================ # StateMachine 0.7.0 *2018-04-01* - New event callbacks: `on_enter_` and `on_exit_`. ================================================ FILE: docs/releases/0.7.1.md ================================================ # StateMachine 0.7.1 *2019-01-18* - Fix Django integration for registry loading statemachine modules on Django1.7+. ================================================ FILE: docs/releases/0.8.0.md ================================================ # StateMachine 0.8.0 *2020-01-23* ## Python compatibility on 0.8.0 - Add support for Python 3.7 and 3.8 (adding to test matrix). - Drop official support for Python 3.4 (removing from test matrix, code may still work). ## What's new in 0.8 - Update development requirements. - State machine names should now be fully qualified for mixins, simple names are deprecated and will no longer be supported on a future version. - Development: Adding mypy linter. - Add support for State machine inheritance. Thanks @rschrader. - Add support for reverse transitions: ``transition = state_a.from_(state_b)``. Thanks @romulorosa. - Fix current state equal to destination on enter events. Thanks @robnils and @joshuacc1. - Check: StateMachine now validates if it's states/transitions graph has only one component. Thanks @rafaelrds. ================================================ FILE: docs/releases/0.9.0.md ================================================ # StateMachine 0.9.0 *2022-12-21* ## Python compatibility 0.9.0 StateMachine 0.9 supports Python 2.7, 3.5, 3.6, 3.7, 3.8. ## What's new in 0.9 ### Args and kwargs now are passed to bounded transitions Parameters sent with the event trigger will now be propagated to the transition handlers. ```py >>> from statemachine import StateMachine, State >>> class CampaignMachine(StateMachine): ... draft = State("Draft", initial=True) ... producing = State("Being produced") ... ... produce = draft.to(producing) | producing.to(producing) ... ... def on_enter_producing(self, approver=None): ... print(f"Approved by: {approver}") >>> sm = CampaignMachine() >>> sm.produce(approver="Aragorn") # imperative Approved by: Aragorn ``` ### State machine declarations now with final states Now you can declare `final` states and the machine will make sure they have no transitions. ```py >>> from statemachine import StateMachine, State >>> class ApprovalMachine(StateMachine): ... """A workflow machine""" ... requested = State("Requested", initial=True) ... accepted = State("Accepted") ... rejected = State("Rejected") ... completed = State("Completed", final=True) ... ... validate = requested.to(accepted, cond="is_ok") | requested.to(rejected) ... release = accepted.to(completed) ... reopen = completed.to(requested) Traceback (most recent call last): ... InvalidDefinition: Cannot declare transitions from final state. Invalid state(s): ['completed'] ``` See {ref}`final-state` for more details. ## Minor features and bug fixes - Doctesting all documentation including README (with issues on examples fixed). - Fix state value misjudged when state value is an "boolean False value" (tks @the5fire) - Fix returning dict as result of transitions callback. ================================================ FILE: docs/releases/1.0.0.md ================================================ # StateMachine 1.0.0 *January 11, 2023* This release tag was replaced by [1.0.1](1.0.1.md) due to an error on the metadata when uploading to pypi. ================================================ FILE: docs/releases/1.0.1.md ================================================ # StateMachine 1.0.1 *January 11, 2023* Welcome to StateMachine 1.0.1! This version is a huge refactoring adding a lot of new and exciting features. We hope that you enjoy it. These release notes cover the [new features in 1.0](#whats-new-in-10), as well as some [backward incompatible changes](#backward-incompatible-changes-in-10) you'll want to be aware of when upgrading from StateMachine 0.9.0 or earlier. We've [begun the deprecation process for some features](#deprecated-features-in-10). ## Python compatibility in 1.0 StateMachine 1.0 supports Python 2.7, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10, and 3.11. This is the last release to support Python 2.7, 3.5, and 3.6. ## What's new in 1.0 ### Added validators and Guards Transitions now support `cond` and `unless` parameters, to restrict the execution. ```python class ApprovalMachine(StateMachine): "A workflow machine" requested = State("Requested", initial=True) accepted = State("Accepted") rejected = State("Rejected") completed = State("Completed") validate = requested.to(accepted, cond="is_ok") | requested.to(rejected) ``` ```{seealso} See {ref}`validators and guards` for more details. ``` ### Support for diagrams You can generate diagrams from your state machine. Example: ```{statemachine-diagram} tests.examples.order_control_machine.OrderControl :caption: OrderControl ``` ```{seealso} See {ref}`diagrams` for more details. ``` ### Unified dispatch mechanism for callbacks (actions and guards) Every single callback, being {ref}`actions` or {ref}`guards`, is now handled equally by the library. Also, we've improved the internals in a way that you can implement your callbacks with any number of arbitrary positional or keyword arguments (`*args, **kwargs`), and the dispatch will match the available arguments with your method signature. This means that if on your `on_enter_()` or `on_execute_()` method, you also need to know the `source` ({ref}`state`), or the `event` ({ref}`event`), or access a keyword argument passed with the trigger, you're covered. Just add this parameter to the method and It will be passed by the dispatch mechanics. Example of what's available: ```py def action_or_guard_method_name(self, *args, event_data, event, source, state, model, **kwargs): pass ``` ```{seealso} See {ref}`dynamic-dispatch` for more details. ``` ### Add observers to a running StateMachine Observers are a way do generically add behavior to a StateMachine without changing it's internal implementation. The `StateMachine` itself is registered as an observer, so by using `StateMachine.add_observer()` an external object can have the same level of functionalities provided to the built-in class. ```{seealso} See {ref}`observers` for more details. ``` ## Minor features in 1.0 - Fixed mypy complaining about incorrect type for ``StateMachine`` class. - The initial {ref}`state` is now entered when the machine starts. The {ref}`actions`, if defined, `on_enter_state` and `on_enter_` are now called. ## Backward incompatible changes in 1.0 ### Multiple targets from the same origin state Prior to this release, as we didn't have {ref}`validators and guards`, there wasn't an elegant way to declare multiple target states starting from the same pair (event, state). But the library allowed a near-hackish way, by declaring a target state as the result of the `on_` callback. So, the previous code (not valid anymore): ```py class ApprovalMachine(StateMachine): "A workflow machine" requested = State('Requested', initial=True) accepted = State('Accepted') rejected = State('Rejected') validate = requested.to(accepted, rejected) def on_validate(self, current_time): if self.model.is_ok(): self.model.accepted_at = current_time return self.accepted else: return self.rejected ``` Should be rewritten to use {ref}`guards`, like this: ``` py class ApprovalMachine(StateMachine): "A workflow machine" requested = State("Requested", initial=True) accepted = State("Accepted") rejected = State("Rejected") validate = requested.to(accepted, conditions="is_ok") | requested.to(rejected) def on_validate(self, current_time): self.model.accepted_at = current_time ``` ```{seealso} See {ref}`validators and guards` of more details. ``` ### StateMachine now enters the initial state This issue was reported at [#265](https://github.com/fgmacedo/python-statemachine/issues/265). Now StateMachine will execute the actions associated with the `on_enter_state` and `on_enter_` when initialized if they exist. ```{seealso} See {ref}`State actions` for more details. ``` ### Integrity is checked at class definition Statemachine integrity checks are now performed at class declaration (import time) instead of on instance creation. This allows early feedback on invalid definitions. This was the previous behavior, you only got an error when trying to instantiate a StateMachine: ```py class CampaignMachine(StateMachine): "A workflow machine" draft = State('Draft', initial=True) producing = State('Being produced') closed = State('Closed', initial=True) # Should raise an Exception when instantiated add_job = draft.to(draft) | producing.to(producing) produce = draft.to(producing) deliver = producing.to(closed) with pytest.raises(exceptions.InvalidDefinition): CampaignMachine() ``` Not this is performed as the class definition is performed: ```py with pytest.raises(exceptions.InvalidDefinition): class CampaignMachine(StateMachine): "A workflow machine" draft = State("Draft", initial=True) producing = State("Being produced") closed = State( "Closed", initial=True ) # Should raise an Exception right after the class is defined add_job = draft.to(draft) | producing.to(producing) produce = draft.to(producing) deliver = producing.to(closed) ``` ### Other backward incompatible changes in 1.0 - Due to the check validations and setup performed at the machine initialization, it's now harder to perform monkey-patching to add callbacks at runtime (not a bad thing after all). - `TransitionNotAllowed` changed internal attr from `transition` to `event`. - `CombinedTransition` does not exist anymore. {ref}`State` now holds a flat {ref}`Transition` list called `TransitionList` that implements de `OR` operator. This turns a valid StateMachine traversal much easier: `[transition for state in machine.states for transition in state.transitions]`. - `StateMachine.get_transition` is removed. See {ref}`event`. - The previous exceptions `MultipleStatesFound` and `MultipleTransitionCallbacksFound` are removed. Since now you can have more than one callback defined to the same transition. - `on_enter_state` and `on_exit_state` now accepts any combination of parameters following the {ref}`dynamic-dispatch` rules. Previously it only accepted the `state` param. - `Transition.__init__` param `on_execute` renamed to simply `on`, and now follows the {ref}`dynamic-dispatch`. - `Transition.destinations` removed in favor of `Transition.target` (following SCXML convention). Now each transition only points to a unique target. Each `source->target` pair is held by a single `Transition`. ## Deprecated features in 1.0 ### Statemachine class deprecations - `StateMachine.run` is deprecated in favor of `StateMachine.send`. - `StateMachine.allowed_transitions` is deprecated in favor of `StateMachine.allowed_events`. - `Statemachine.is_` is deprecated in favor of `StateMachine..is_active`. ### State class - `State.identification` is deprecated in favor of `State.id`. ================================================ FILE: docs/releases/1.0.2.md ================================================ # StateMachine 1.0.2 *January 12, 2023* StateMachine 1.0.2 fixes a regression bug blocking the library usage on Python 3.11. ## Bugfixes - Fixes [#316](https://github.com/fgmacedo/python-statemachine/issues/316) a bad import of 'inspect.getargspec' that was removed on Python 3.11, still backwards compatible with older versions. ================================================ FILE: docs/releases/1.0.3.md ================================================ # StateMachine 1.0.3 *January 27, 2023* StateMachine 1.0.3 fixes a bug between {ref}`State` and {ref}`transition` instances sharing references of callbacks when there were multiple concurrent instances of the same `StateMachine` class. ## Bugfixes in 1.0.3 - [#334](https://github.com/fgmacedo/python-statemachine/issues/334): Fixed a shared reference of callbacks when there were multiple concurrent instances of the same `StateMachine` class. ================================================ FILE: docs/releases/2.0.0.md ================================================ # StateMachine 2.0.0 *March 5, 2023* Welcome to StateMachine 2.0.0! This version is the first to take advantage of the Python3 improvements and is a huge internal refactoring removing the deprecated features on 1.*. We hope that you enjoy it. These release notes cover the [](#whats-new-in-20), as well as some [backward incompatible changes](#backward-incompatible-changes-in-20) you'll want to be aware of when upgrading from StateMachine 1.*. ## Python compatibility in 2.0 StateMachine 2.0 supports Python 3.7, 3.8, 3.9, 3.10, and 3.11. ## What's new in 2.0 ### Run to completion (RTC) by default There are now two distinct methods for processing events in the library. The **new default** is to run in {ref}`RTC model` to be compliant with the specs, where the {ref}`event` is put on a queue before processing. You can also configure your state machine to run back in {ref}`Non-RTC model`, where the {ref}`event` will be run immediately and nested events will be chained. This means that the state machine now completes all the actions associated with an event before moving on to the next event. Even if you trigger an event inside an action. ```{seealso} See {ref}`processing model` for more details. ``` ### State names are now optional {ref}`State` names are now by default derived from the class variable that they are assigned to. You can keep declaring explicit names, but we encourage you to only assign a name when it is different than the one derived from its id. ```py >>> from statemachine import StateMachine, State >>> class ApprovalMachine(StateMachine): ... pending = State(initial=True) ... waiting_approval = State() ... approved = State(final=True) ... ... start = pending.to(waiting_approval) ... approve = waiting_approval.to(approved) ... >>> ApprovalMachine.pending.name 'Pending' >>> ApprovalMachine.waiting_approval.name 'Waiting approval' >>> ApprovalMachine.approved.name 'Approved' ``` ### Added support for internal transitions An internal transition is like a {ref}`self transition`, but in contrast, no entry or exit actions are ever executed as a result of an internal transition. ```py >>> from statemachine import StateMachine, State >>> class TestStateMachine(StateMachine): ... initial = State(initial=True) ... ... loop = initial.to.itself(internal=True) ``` ```{seealso} See {ref}`internal transition` for more details. ``` ### Added option to ignore unknown events You can now instantiate a {ref}`StateMachine` with `allow_event_without_transition=True`, so the state machine will allow triggering events that may not lead to a state {ref}`transition`, including tolerance to unknown {ref}`event` triggers. The default value is ``False``, that keeps the backward compatible behavior of when an event does not result in a {ref}`transition`, an exception ``TransitionNotAllowed`` will be raised. ``` >>> import pytest >>> pytest.skip("Since 3.0.0 `allow_event_without_transition` is now a class attribute.") >>> sm = ApprovalMachine(allow_event_without_transition=True) >>> sm.send("unknow_event_name") >>> sm.pending.is_active True >>> sm.send("approve") >>> sm.pending.is_active True >>> sm.send("start") >>> sm.waiting_approval.is_active True ``` ### Added support for translations (i18n) Now the library messages can be translated into any language. See {ref}`Add a translation` on how to contribute with translations. ## Minor features in 2.0 - Modernization of the development tools to use linters and improved mypy support. - [#342](https://github.com/fgmacedo/python-statemachine/pull/342): Guards now supports the evaluation of **truthy** and **falsy** values. - [#342](https://github.com/fgmacedo/python-statemachine/pull/342): Assignment of `Transition` guards using decorators is now possible. - [#331](https://github.com/fgmacedo/python-statemachine/pull/331): Added a way to generate diagrams using [QuickChart.io](https://quickchart.io) instead of GraphViz. See {ref}`diagrams` for more details. - [#353](https://github.com/fgmacedo/python-statemachine/pull/353): Support for abstract state machine classes, so you can subclass `StateMachine` to add behavior on your own base class. Abstract `StateMachine` cannot be instantiated. - [#355](https://github.com/fgmacedo/python-statemachine/pull/355): Now is possible to trigger an event as an action by registering the event name as the callback param. ## Bugfixes in 2.0 - [#341](https://github.com/fgmacedo/python-statemachine/issues/341): Fix dynamic dispatch on methods with default parameters. - [#365](https://github.com/fgmacedo/python-statemachine/pull/365): Fix transition with multiple events was calling actions of all events. ## Backward incompatible changes in 2.0 - Dropped support for Django <= `1.6` for auto-discovering and registering `StateMachine` classes to be used on {ref}`django integration`. ### Transitions with multiple events only execute actions associated to the triggered event Prior to [#365](https://github.com/fgmacedo/python-statemachine/pull/365), when you {ref}`Declare transition actions by naming convention`, all callbacks of the transition were called even if the triggered event was not the one that originated the transition. This behavior was fixed in this release. Now, only the transitions associated with the triggered event or directly assigned to the transition are called. Consider the following state machine as an example: ```py >>> from statemachine import State >>> from statemachine import StateMachine >>> class TrafficLightMachine(StateMachine): ... "A traffic light machine" ... green = State(initial=True) ... yellow = State() ... red = State() ... ... slowdown = green.to(yellow) ... stop = yellow.to(red) ... go = red.to(green) ... ... cycle = slowdown | stop | go ... ... def before_slowdown(self): ... print("Slowdown") ... ... def before_cycle(self, event: str, source: State, target: State): ... print(f"Running {event} from {source.id} to {target.id}") ``` Before, if you send the `cycle` event, the behavior was to also trigger actions associated with `slowdown`, because they're sharing the same instance of {ref}`Transition`: ```py >>> sm = TrafficLightMachine() >>> sm.send("cycle") # doctest: +SKIP Slowdown Running cycle from green to yellow ``` Now the behavior is to only execute actions bound to the triggered {ref}`event` or directly associated to the {ref}`Transition`: ```py >>> sm = TrafficLightMachine() >>> sm.send("cycle") Running cycle from green to yellow ``` If you want to emulate the previous behavior, consider one of these alternatives. You can {ref}`Bind transition actions using params` or {ref}`Bind transition actions using decorator syntax`: ```py >>> from statemachine import State >>> from statemachine import StateMachine >>> class TrafficLightMachine(StateMachine): ... "A traffic light machine" ... green = State(initial=True) ... yellow = State() ... red = State() ... ... slowdown = green.to(yellow, before="do_before_slowdown") # assign to the transition ... stop = yellow.to(red) ... go = red.to(green) ... ... cycle = slowdown | stop | go ... ... def do_before_slowdown(self): ... print("Slowdown") ... ... @stop.before # assign to the transition ... def do_before_stop(self): ... print("Stop") ... ... def before_cycle(self, event: str, source: State, target: State): ... print(f"Running {event} from {source.id} to {target.id}") ``` You can go an step further and if the events are not called externally, get rid of them and put the actions directly on the transitions: ```py >>> from statemachine import State >>> from statemachine import StateMachine >>> class TrafficLightMachine(StateMachine): ... "A traffic light machine" ... green = State(initial=True) ... yellow = State() ... red = State() ... ... cycle = ( ... green.to(yellow, before="slowdown") ... | yellow.to(red, before="stop") ... | red.to(green, before="go") ... ) ... ... def slowdown(self): ... print("Slowdown") ... ... def stop(self): ... print("Stop") ... ... def go(self): ... print("Go") ... ... def before_cycle(self, event: str, source: State, target: State): ... print(f"Running {event} from {source.id} to {target.id}") ``` ```py >>> sm = TrafficLightMachine() >>> [sm.send("cycle") for _ in range(3)] Slowdown Running cycle from green to yellow Stop Running cycle from yellow to red Go Running cycle from red to green [[None, None], [None, None], [None, None]] ``` ### Statemachine class changes in 2.0 #### The new processing model (RTC) by default While we've figured out a way to keep near complete backwards compatible changes to the new {ref}`Run to completion (RTC) by default` feature (all built-in examples run without change), if you encounter problems when upgrading to this version, you can still switch back to the old {ref}`Non-RTC model`. Be aware that we may remove the {ref}`Non-RTC model` in the future. #### `StateMachine.run` removed in favor of `StateMachine.send` ```py from tests.examples.traffic_light_machine import TrafficLightMachine sm = TrafficLightMachine() sm.run("cycle") ``` Should become: ```py >>> from tests.examples.traffic_light_machine import TrafficLightMachine >>> sm = TrafficLightMachine() >>> sm.send("cycle") Running cycle from green to yellow ``` #### `StateMachine.allowed_transitions` removed in favor of `StateMachine.allowed_events` ```py from tests.examples.traffic_light_machine import TrafficLightMachine sm = TrafficLightMachine() assert [t.name for t in sm.allowed_transitions] == ["cycle"] ``` Should become: ```py >>> from tests.examples.traffic_light_machine import TrafficLightMachine >>> sm = TrafficLightMachine() >>> assert [t.name for t in sm.allowed_events] == ["cycle"] ``` #### `Statemachine.is_` removed in favor of `StateMachine..is_active` ```py from tests.examples.traffic_light_machine import TrafficLightMachine sm = TrafficLightMachine() assert sm.is_green ``` Should become: ```py >>> from tests.examples.traffic_light_machine import TrafficLightMachine >>> sm = TrafficLightMachine() >>> assert sm.green.is_active ``` ### State class changes in 2.0 #### `State.identification` removed in favor of `State.id` ```py from tests.examples.traffic_light_machine import TrafficLightMachine sm = TrafficLightMachine() assert sm.current_state.identification == "green" ``` Should become: ```py >>> from tests.examples.traffic_light_machine import TrafficLightMachine >>> sm = TrafficLightMachine() >>> assert sm.current_state.id == "green" ``` ================================================ FILE: docs/releases/2.1.0.md ================================================ # StateMachine 2.1.0 *June 11, 2023* ## What's new in 2.1.0 ### Added support for declaring states using Enum Given an ``Enum`` type that declares our expected states: ```py >>> from enum import Enum >>> class Status(Enum): ... pending = 1 ... completed = 2 ``` A {ref}`StateMachine` can be declared as follows: ```py >>> from statemachine import StateMachine >>> from statemachine.states import States >>> class ApprovalMachine(StateMachine): ... ... _ = States.from_enum(Status, initial=Status.pending, final=Status.completed) ... ... finish = _.pending.to(_.completed) ... ... def on_enter_completed(self): ... print("Completed!") ``` See {ref}`States from Enum types`. ## Bugfixes in 2.1.0 - Fixes [#369](https://github.com/fgmacedo/python-statemachine/issues/369) adding support to wrap methods used as {ref}`Actions` decorated with `functools.partial`. - Fixes [#384](https://github.com/fgmacedo/python-statemachine/issues/384) so multiple observers can watch the same callback. ================================================ FILE: docs/releases/2.1.1.md ================================================ # StateMachine 2.1.1 *August 3, 2023* ## Bugfixes in 2.1.1 - Fixes [#391](https://github.com/fgmacedo/python-statemachine/issues/391) adding support to [pytest-mock](https://pytest-mock.readthedocs.io/en/latest/index.html) `spy` method. - Improved factory type hints [#399](https://github.com/fgmacedo/python-statemachine/pull/399). ================================================ FILE: docs/releases/2.1.2.md ================================================ # StateMachine 2.1.2 *October 6, 2023* This release improves the setup performance of the library by a 10x factor, with a major refactoring on how we handle the callbacks registry and validations. See [#401](https://github.com/fgmacedo/python-statemachine/issues/401) for the technical details. ## Python compatibility 2.1.2 StateMachine 2.1.2 supports Python 3.7, 3.8, 3.9, 3.10, 3.11 and 3.12. On the next major release (3.0.0), we will drop support for Python 3.7. ## Bugfixes in 2.1.2 - Fixes [#406](https://github.com/fgmacedo/python-statemachine/issues/406) action callback being called twice when mixing decorator syntax combined with the naming convention. ================================================ FILE: docs/releases/2.2.0.md ================================================ # StateMachine 2.2.0 *May 6, 2024* ## What's new in 2.2.0 In this release, we conducted a general cleanup and refactoring across various modules to enhance code readability and maintainability. We improved exception handling and reduced code redundancy. As a result, we achieved a **~2.2x** faster setup in our performance tests and significantly simplified the callback machinery. ### Check of unreachable and non-final states We included one more state machine definition validation for non-final states. We already check if any states are unreachable from the initial state, if not, an `InvalidDefinition` exception is thrown. ```py >>> from statemachine import StateMachine, State >>> class TrafficLightMachine(StateMachine): ... "A workflow machine" ... red = State('Red', initial=True, value=1) ... green = State('Green', value=2) ... orange = State('Orange', value=3) ... hazard = State('Hazard', value=4) ... ... cycle = red.to(green) | green.to(orange) | orange.to(red) ... blink = hazard.to.itself() Traceback (most recent call last): ... InvalidDefinition: There are unreachable states. The statemachine graph should have a single component. Disconnected states: ['hazard'] ``` From this release, `StateMachine` will also check that all non-final states have an outgoing transition, and warn you if any states would result in the statemachine becoming trapped in a non-final state with no further transitions possible. ```{note} This will currently issue a warning, but can be turned into an exception by setting `strict_states=True` on the class. ``` ```python from statemachine import StateMachine, State class TrafficLightMachine(StateMachine, strict_states=True): "A workflow machine" red = State('Red', initial=True, value=1) green = State('Green', value=2) orange = State('Orange', value=3) hazard = State('Hazard', value=4) cycle = red.to(green) | green.to(orange) | orange.to(red) fault = red.to(hazard) | green.to(hazard) | orange.to(hazard) # InvalidDefinition: All non-final states should have at least one outgoing transition. # These states have no outgoing transition: ['hazard'] ``` ```{warning} `strict_states=True` will become the default behaviour in the next major release. ``` See {ref}`State Transitions`. ## Bugfixes in 2.2.0 - Fixes [#424](https://github.com/fgmacedo/python-statemachine/issues/424) allowing `deepcopy` of state machines. - **Dispatch Mechanism**: Resolved issues in the dispatch mechanism in `statemachine/dispatcher.py` that affected the reliability of event handling across different states. This fix ensures consistent behavior when events are dispatched in complex state machine configurations. ================================================ FILE: docs/releases/2.3.0.md ================================================ # StateMachine 2.3.0 *June 7, 2024* ## What's new in 2.3.0 This release has a high expected feature, we're adding [asynchronous support](../async.md), and enhancing overall functionality. In fact, the approach we took was to go all the way down changing the internals of the library to be fully async, keeping only the current external API as a thin sync/async adapter. ### Python compatibility 2.3.0 StateMachine 2.3.0 supports Python 3.7, 3.8, 3.9, 3.10, 3.11 and 3.12. We've fixed a bug on the package declaration that was preventing users from Python 3.7 to install the latest version. ### Asynchronous Support in 2.3.0 This release introduces native coroutine support using asyncio, enabling seamless integration with asynchronous code. Now you can send and await for events, and also write async {ref}`Actions`, {ref}`Conditions` and {ref}`Validators`. ```{seealso} See {ref}`sphx_glr_auto_examples_air_conditioner_machine.py` for an example of async code with a state machine. ``` ```python class AsyncStateMachine(StateMachine): initial = State('Initial', initial=True) final = State('Final', final=True) advance = initial.to(final) async def on_advance(self): return 42 async def run_sm(): sm = AsyncStateMachine() res = await sm.advance() return (42, sm.current_state.name) asyncio.run(run_sm()) # (42, 'Final') ``` ================================================ FILE: docs/releases/2.3.1.md ================================================ # StateMachine 2.3.1 *June 10, 2024* ## Bugfixes in 2.3.1 - Fixes [#443](https://github.com/fgmacedo/python-statemachine/issues/443) regression that caused `RuntimeError` when running SM with threads. Thanks [@gwidion](https://x.com/gwidion)! ================================================ FILE: docs/releases/2.3.2.md ================================================ # StateMachine 2.3.2 *July 01, 2024* ## What's new in 2.3.2 Observers are now rebranded to {ref}`listeners`. With expanted support for adding listeners when instantiating a state machine. This allows covering more use cases. We also improved the async support. ### Improved async support Since version 2.3.0, we have added async support. However, we encountered use cases, such as the [async safety on Django ORM](https://docs.djangoproject.com/en/5.0/topics/async/#async-safety), which expects no running event loop and blocks if it detects one on the current thread. To address this issue, we developed a solution that maintains a unified API for both synchronous and asynchronous operations while effectively handling these scenarios. This is achieved through a new concept called "engine," an internal strategy pattern abstraction that manages transitions and callbacks. There are two engines: SyncEngine : Activated if there are no async callbacks. All code runs exactly as it did before version 2.3.0. AsyncEngine : Activated if there is at least one async callback. The code runs asynchronously and requires a running event loop, which it will create if none exists. These engines are internal and are activated automatically by inspecting the registered callbacks in the following scenarios: ```{seealso} See {ref}`async` for more details. ``` ### Listeners at class initialization Listeners are a way to generically add behavior to a state machine without changing its internal implementation. Example: ```py >>> from tests.examples.traffic_light_machine import TrafficLightMachine >>> class LogListener(object): ... def __init__(self, name): ... self.name = name ... ... def after_transition(self, event, source, target): ... print(f"{self.name} after: {source.id}--({event})-->{target.id}") ... ... def on_enter_state(self, target, event): ... print(f"{self.name} enter: {target.id} from {event}") >>> sm = TrafficLightMachine(listeners=[LogListener("Paulista Avenue")]) Paulista Avenue enter: green from __initial__ >>> sm.cycle() Running cycle from green to yellow Paulista Avenue enter: yellow from cycle Paulista Avenue after: green--(cycle)-->yellow ``` ```{seealso} See {ref}`listeners` for more details. ``` ### Binding event triggers to external objects Now it's possible to bind events to external objets. One expected use case is in conjunction with the {ref}`Mixins` models, that wrap state machines internally. This way you don't need to expose the state machine. ```{seealso} See {ref}`sphx_glr_auto_examples_user_machine.py` for an example binding event triggers with a state machine. ``` ## Bugfixes in 2.3.2 - Fixes [#446](https://github.com/fgmacedo/python-statemachine/issues/446): Regression that broke sync callbacks interacting with Django ORM due to the added async support and [Django's async safety guards](https://docs.djangoproject.com/en/5.1/topics/async/#async-safety). - Fixes [#449](https://github.com/fgmacedo/python-statemachine/issues/449): Regression that did not trigger events in nested calls within an already running transition. ## Deprecation notes ### Statemachine class deprecations in 2.3.2 Deprecations that will be removed on the next major release: - `StateMachine.add_observer` is deprecated in favor of `StateMachine.add_listener`. - `StateMachine.rtc` option is deprecated. We'll keep only the **run-to-completion** (RTC) model. ================================================ FILE: docs/releases/2.3.3.md ================================================ # StateMachine 2.3.3 *July 3, 2024* ## Bugfixes in 2.3.3 - Fixes [#457](https://github.com/fgmacedo/python-statemachine/issues/457) regression that caused backwards incomplatible changes when using Enums. Thanks [@hperrey](https://github.com/hperrey)! ## Deprecation notes in 2.3.3 Deprecations that will be removed on the next major release: - The `States.from_enum(..., use_enum_instance=True)` will be the default. ```{seealso} See {ref}`States from Enum types` for more details. ``` ================================================ FILE: docs/releases/2.3.4.md ================================================ # StateMachine 2.3.4 *July 11, 2024* ## Bugfixes in 2.3.4 - Fixes [#465](https://github.com/fgmacedo/python-statemachine/issues/465) regression that caused exception when registering a listener with unbounded callbacks. ================================================ FILE: docs/releases/2.3.5.md ================================================ # StateMachine 2.3.5 *September 9, 2024* ### Python compatibility 2.3.5 Added Python 3.13 on the test matrix. StateMachine 2.3.5 supports Python 3.7, 3.8, 3.9, 3.10, 3.11, 3.12 and 3.13. ## Bugfixes in 2.3.5 - Fixes [#469](https://github.com/fgmacedo/python-statemachine/issues/469) compatibility with pydot 3.0.0+. - Fixes [#473](https://github.com/fgmacedo/python-statemachine/issues/473) property support for type checking. ================================================ FILE: docs/releases/2.3.6.md ================================================ # StateMachine 2.3.6 *September 11, 2024* ## Bugfixes in 2.3.6 - Fixes [#474](https://github.com/fgmacedo/python-statemachine/issues/474) install with extra was not working to install `pydot`. - Fixes [#480](https://github.com/fgmacedo/python-statemachine/issues/480) error when trying to trigger an event inside the initial callback. ================================================ FILE: docs/releases/2.4.0.md ================================================ # StateMachine 2.4.0 *November 5, 2024* ## What's new in 2.4.0 This release introduces powerful new features for the `StateMachine` library: {ref}`Condition expressions` and explicit definition of {ref}`Events`. These updates make it easier to define complex transition conditions and enhance performance, especially in workflows with nested or recursive event structures. ### Python compatibility in 2.4.0 StateMachine 2.4.0 supports Python 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, and 3.13. ### Conditions expressions in 2.4.0 This release introduces support for conditionals with Boolean algebra. You can now use expressions like `or`, `and`, and `not` directly within transition conditions, simplifying the definition of complex state transitions. This allows for more flexible and readable condition setups in your state machine configurations. Example (with a spoiler of the next highlight): ```python from statemachine import StateMachine, State, Event class AnyConditionSM(StateMachine): start = State(initial=True) end = State(final=True) submit = Event( start.to(end, cond="used_money or used_credit"), name="finish order", ) used_money: bool = False used_credit: bool = False sm = AnyConditionSM() sm.submit() # TransitionNotAllowed: Can't finish order when in Start. sm.used_credit = True sm.submit() sm.current_state.id # 'end' ``` ```{seealso} See {ref}`Condition expressions` for more details or take a look at the {ref}`sphx_glr_auto_examples_lor_machine.py` example. ``` ### Explicit event creation in 2.4.0 Now you can explicit declare {ref}`Events` using the {ref}`event` class. This allows custom naming, translations, and also helps your IDE to know that events are callable. ```py >>> from statemachine import StateMachine, State, Event >>> class StartMachine(StateMachine): ... created = State(initial=True) ... started = State(final=True) ... ... start = Event(created.to(started), name="Launch the machine") ... >>> [e.id for e in StartMachine.events] ['start'] >>> [e.name for e in StartMachine.events] ['Launch the machine'] >>> StartMachine.start.name 'Launch the machine' ``` ```{seealso} See {ref}`Events` for more details. ``` ### Recursive state machines (infinite loop) We removed a note from the docs saying to avoid recursion loops. Since the {ref}`StateMachine 2.0.0` release we've turned the RTC model enabled by default, allowing nested events to occour as all events are put on an internal queue before being executed. ```{seealso} See {ref}`sphx_glr_auto_examples_recursive_event_machine.py` for an example of an infinite loop state machine declaration using `after` action callback to call the same event over and over again. ``` ## Bugfixes in 2.4.0 - Fixes [#484](https://github.com/fgmacedo/python-statemachine/issues/484) issue where nested events inside loops could leak memory by incorrectly referencing previous `event_data` when queuing the next event. This fix improves performance and stability in event-heavy workflows. ================================================ FILE: docs/releases/2.5.0.md ================================================ # StateMachine 2.5.0 *December 3, 2024* ## What's new in 2.5.0 This release improves {ref}`Condition expressions` and explicit definition of {ref}`Events` and introduces the helper `State.from_.any()`. ### Python compatibility in 2.5.0 StateMachine 2.5.0 supports Python 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, and 3.13. ### Helper to declare transition from any state You can now declare that a state is accessible from any other state with a simple constructor. Using `State.from_.any()`, the state machine meta class automatically creates transitions from all non-final states to the target state. Furthermore, both `State.from_.itself()` and `State.to.itself()` have been refactored to support type hints and are now fully visible for code completion in your preferred editor. ``` py >>> from statemachine import Event >>> class AccountStateMachine(StateMachine): ... active = State("Active", initial=True) ... suspended = State("Suspended") ... overdrawn = State("Overdrawn") ... closed = State("Closed", final=True) ... ... suspend = Event(active.to(suspended)) ... activate = Event(suspended.to(active)) ... overdraft = Event(active.to(overdrawn)) ... resolve_overdraft = Event(overdrawn.to(active)) ... ... close_account = Event(closed.from_.any(cond="can_close_account")) ... ... can_close_account: bool = True ... ... def on_close_account(self): ... print("Account has been closed.") >>> sm = AccountStateMachine() >>> sm.close_account() Account has been closed. >>> sm.closed.is_active True ``` ### Allowed events are now bounded to the state machine instance Since 2.0, the state machine can return a list of allowed events given the current state: ``` >>> sm = AccountStateMachine() >>> [str(e) for e in sm.allowed_events] ['suspend', 'overdraft', 'close_account'] ``` `Event` instances are now bound to the state machine instance, allowing you to pass the event by reference and call it like a method, which triggers the event in the state machine. You can think of the event as an implementation of the **command** design pattern. On this example, we iterate until the state machine reaches a final state, listing the current state allowed events and executing the simulated user choice: ```python import random random.seed("15") sm = AccountStateMachine() while not sm.current_state.final: allowed_events = sm.allowed_events print("Choose an action: ") for idx, event in enumerate(allowed_events): print(f"{idx} - {event.name}") user_input = random.randint(0, len(allowed_events)-1) print(f"User input: {user_input}") event = allowed_events[user_input] print(f"Running the option {user_input} - {event.name}") event() print(f"SM is in {sm.current_state.name} state.") # SM is in Closed state. ``` ### Conditions expressions in 2.5.0 This release adds support for comparison operators into {ref}`Condition expressions`. The following comparison operators are supported: 1. `>` — Greather than. 2. `>=` — Greather than or equal. 3. `==` — Equal. 4. `!=` — Not equal. 5. `<` — Lower than. 6. `<=` — Lower than or equal. Example: ```python from statemachine import StateMachine, State, Event class AnyConditionSM(StateMachine): start = State(initial=True) end = State(final=True) submit = Event( start.to(end, cond="order_value > 100"), name="finish order", ) order_value: float = 0 sm = AnyConditionSM() sm.submit() # TransitionNotAllowed: Can't finish order when in Start. sm.order_value = 135.0 sm.submit() sm.current_state.id # 'end' ``` ```{seealso} See {ref}`Condition expressions` for more details or take a look at the {ref}`sphx_glr_auto_examples_lor_machine.py` example. ``` ### Decorator callbacks with explicit event creation in 2.5.0 Now you can add callbacks using the decorator syntax using {ref}`Events`. Note that this syntax is also available without the explicit `Event`. ```py >>> from statemachine import StateMachine, State, Event >>> class StartMachine(StateMachine): ... created = State(initial=True) ... started = State(final=True) ... ... start = Event(created.to(started), name="Launch the machine") ... ... @start.on ... def call_service(self): ... return "calling..." ... >>> sm = StartMachine() >>> sm.start() 'calling...' ``` ## Bugfixes in 2.5.0 - Fixes [#500](https://github.com/fgmacedo/python-statemachine/issues/500) issue adding support for Pickle. ## Misc in 2.5.0 - We're now using `uv` [#491](https://github.com/fgmacedo/python-statemachine/issues/491). - Simplification of the engines code [#498](https://github.com/fgmacedo/python-statemachine/pull/498). - The dispatcher and callback modules where refactored with improved separation of concerns [#490](https://github.com/fgmacedo/python-statemachine/pull/490). ================================================ FILE: docs/releases/2.6.0.md ================================================ # StateMachine 2.6.0 *February 2026* ## What's new in 2.6.0 This release adds the {ref}`StateMachine.enabled_events` method, Python 3.14 support, a significant performance improvement for callback dispatch, and several bugfixes for async condition expressions, type checker compatibility, and Django integration. ### Python compatibility in 2.6.0 StateMachine 2.6.0 supports Python 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, 3.13, and 3.14. ### Checking enabled events A new {ref}`StateMachine.enabled_events` method lets you query which events have their `cond`/`unless` guards currently satisfied, going beyond {ref}`StateMachine.allowed_events` which only checks reachability from the current state. This is particularly useful for **UI scenarios** where you want to enable or disable buttons based on whether an event's conditions are met at runtime. ```{testsetup} >>> from statemachine import StateMachine, State ``` ```py >>> class ApprovalMachine(StateMachine): ... pending = State(initial=True) ... approved = State(final=True) ... rejected = State(final=True) ... ... approve = pending.to(approved, cond="is_manager") ... reject = pending.to(rejected) ... ... is_manager = False >>> sm = ApprovalMachine() >>> [e.id for e in sm.allowed_events] ['approve', 'reject'] >>> [e.id for e in sm.enabled_events()] ['reject'] >>> sm.is_manager = True >>> [e.id for e in sm.enabled_events()] ['approve', 'reject'] ``` Since conditions may depend on runtime arguments, any `*args`/`**kwargs` passed to `enabled_events()` are forwarded to the condition callbacks: ```py >>> class TaskMachine(StateMachine): ... idle = State(initial=True) ... running = State(final=True) ... ... start = idle.to(running, cond="has_enough_resources") ... ... def has_enough_resources(self, cpu=0): ... return cpu >= 4 >>> sm = TaskMachine() >>> sm.enabled_events() [] >>> [e.id for e in sm.enabled_events(cpu=8)] ['start'] ``` ```{seealso} See {ref}`Checking enabled events` in the Guards documentation for more details. ``` ### Performance: cached signature binding Callback dispatch is now significantly faster thanks to cached signature binding in `SignatureAdapter`. The first call to a callback computes the argument binding and caches a fast-path template; subsequent calls with the same argument shape skip the full binding logic. This results in approximately **60% faster** `bind_expected()` calls and around **30% end-to-end improvement** on hot transition paths. See [#548](https://github.com/fgmacedo/python-statemachine/issues/548) for benchmarks. ## Bugfixes in 2.6.0 - Fixes [#531](https://github.com/fgmacedo/python-statemachine/issues/531) domain model with falsy `__bool__` was being replaced by the default `Model()`. - Fixes [#535](https://github.com/fgmacedo/python-statemachine/issues/535) async predicates in condition expressions (`not`, `and`, `or`) were not being awaited, causing guards to silently return incorrect results. - Fixes [#548](https://github.com/fgmacedo/python-statemachine/issues/548) `VAR_POSITIONAL` and kwargs precedence bugs in the signature binding cache introduced by the performance optimization. - Fixes [#511](https://github.com/fgmacedo/python-statemachine/issues/511) Pyright/Pylance false positive "Argument missing for parameter f" when calling events. Static analyzers could not follow the metaclass transformation from `TransitionList` to `Event`. - Fixes [#551](https://github.com/fgmacedo/python-statemachine/issues/551) `MachineMixin` now gracefully skips state machine initialization for Django historical models in data migrations, instead of raising `ValueError`. - Fixes [#526](https://github.com/fgmacedo/python-statemachine/issues/526) sanitize project path on Windows for documentation builds. ## Misc in 2.6.0 - Added Python 3.14 support [#552](https://github.com/fgmacedo/python-statemachine/pull/552). - Upgraded dev dependencies: ruff to 0.15.0, mypy to 1.14.1 [#552](https://github.com/fgmacedo/python-statemachine/pull/552). - Clarified conditional transition evaluation order in documentation [#546](https://github.com/fgmacedo/python-statemachine/pull/546). - Added pydot DPI resolution settings to diagram documentation [#514](https://github.com/fgmacedo/python-statemachine/pull/514). - Fixed miscellaneous typos in documentation [#522](https://github.com/fgmacedo/python-statemachine/pull/522). - Removed Python 3.7 from CI build matrix [ef351d5](https://github.com/fgmacedo/python-statemachine/commit/ef351d5). ================================================ FILE: docs/releases/3.0.0.md ================================================ # StateMachine 3.0.0 *February 24, 2026* ```{seealso} Upgrading from 2.x? See [](upgrade_2x_to_3.md) for a step-by-step migration guide. ``` ## What's new in 3.0.0 **Statecharts are here!** 🎉 Version 3.0 brings full statechart support to the library — compound states, parallel states, history pseudo-states, and an SCXML-compliant processing model. It also introduces a new `StateChart` base class with modern defaults, a richer event dispatch system (delayed events, internal queues, cancellation), structured error handling, and several developer-experience improvements. The implementation follows the [SCXML specification](https://www.w3.org/TR/scxml/) (W3C), which defines a standard for statechart semantics. This ensures predictable behavior on edge cases and compatibility with other SCXML-based tools. The automated test suite now includes W3C-provided `.scxml` test cases to verify conformance. While this is a major version with backward-incompatible changes, the existing `StateMachine` class preserves 2.x defaults. See the [upgrade guide](upgrade_2x_to_3.md) for a smooth migration path. ### Compound states **Compound states** have inner child states. Use `State.Compound` to define them with Python class syntax — the class body becomes the state's children: ```py >>> from statemachine import State, StateChart >>> class ShireToRoad(StateChart): ... class shire(State.Compound): ... bag_end = State(initial=True) ... green_dragon = State() ... visit_pub = bag_end.to(green_dragon) ... ... road = State(final=True) ... depart = shire.to(road) >>> sm = ShireToRoad() >>> set(sm.configuration_values) == {"shire", "bag_end"} True >>> sm.send("visit_pub") >>> "green_dragon" in sm.configuration_values True >>> sm.send("depart") >>> set(sm.configuration_values) == {"road"} True ``` Entering a compound activates both the parent and its initial child. Exiting removes the parent and all descendants. See {ref}`compound-states` for full details. ### Parallel states **Parallel states** activate all child regions simultaneously. Use `State.Parallel`: ```py >>> from statemachine import State, StateChart >>> class WarOfTheRing(StateChart): ... class war(State.Parallel): ... class frodos_quest(State.Compound): ... shire = State(initial=True) ... mordor = State(final=True) ... journey = shire.to(mordor) ... class aragorns_path(State.Compound): ... ranger = State(initial=True) ... king = State(final=True) ... coronation = ranger.to(king) >>> sm = WarOfTheRing() >>> "shire" in sm.configuration_values and "ranger" in sm.configuration_values True >>> sm.send("journey") >>> "mordor" in sm.configuration_values and "ranger" in sm.configuration_values True ``` Events in one region don't affect others. See {ref}`parallel-states` for full details. ### History pseudo-states The **History pseudo-state** records the configuration of a compound state when it is exited. Re-entering via the history state restores the previously active child. Supports both shallow (`HistoryState()`) and deep (`HistoryState(type="deep")`) history: ```py >>> from statemachine import HistoryState, State, StateChart >>> class GollumPersonality(StateChart): ... class personality(State.Compound): ... smeagol = State(initial=True) ... gollum = State() ... h = HistoryState() ... dark_side = smeagol.to(gollum) ... light_side = gollum.to(smeagol) ... outside = State() ... leave = personality.to(outside) ... return_via_history = outside.to(personality.h) >>> sm = GollumPersonality() >>> sm.send("dark_side") >>> "gollum" in sm.configuration_values True >>> sm.send("leave") >>> sm.send("return_via_history") >>> "gollum" in sm.configuration_values True ``` See {ref}`history-states` for full details on shallow vs deep history. ### Eventless (automatic) transitions Transitions without an event trigger fire automatically when their guard condition is met: ```py >>> from statemachine import State, StateChart >>> class BeaconChain(StateChart): ... class beacons(State.Compound): ... first = State(initial=True) ... second = State() ... last = State(final=True) ... first.to(second) ... second.to(last) ... signal_received = State(final=True) ... done_state_beacons = beacons.to(signal_received) >>> sm = BeaconChain() >>> set(sm.configuration_values) == {"signal_received"} True ``` The entire eventless chain cascades in a single macrostep. See {ref}`eventless` for full details. ### DoneData on final states Final states can provide data to `done.state` handlers via the `donedata` parameter: ```py >>> from statemachine import Event, State, StateChart >>> class QuestCompletion(StateChart): ... class quest(State.Compound): ... traveling = State(initial=True) ... completed = State(final=True, donedata="get_result") ... finish = traveling.to(completed) ... def get_result(self): ... return {"hero": "frodo", "outcome": "victory"} ... epilogue = State(final=True) ... done_state_quest = Event(quest.to(epilogue, on="capture_result")) ... def capture_result(self, hero=None, outcome=None, **kwargs): ... self.result = f"{hero}: {outcome}" >>> sm = QuestCompletion() >>> sm.send("finish") >>> sm.result 'frodo: victory' ``` The `done_state_` naming convention automatically registers the `done.state.{suffix}` form — no explicit `id=` needed. See {ref}`done-state-convention` for details. ### Invoke States can now spawn external work when entered and cancel it when exited, following the SCXML `` semantics (similar to UML's `do/` activity). Handlers run in a daemon thread (sync engine) or a thread executor wrapped in an asyncio Task (async engine). Invoke is a first-class callback group — convention naming (`on_invoke_`), decorators (`@state.invoke`), inline callables, and the full `SignatureAdapter` dependency injection all work out of the box. ```py >>> from statemachine import State, StateChart >>> class FetchMachine(StateChart): ... loading = State(initial=True, invoke=lambda: {"status": "ok"}) ... ready = State(final=True) ... done_invoke_loading = loading.to(ready) >>> sm = FetchMachine() >>> import time; time.sleep(0.1) # wait for background invoke to complete >>> "ready" in sm.configuration_values True ``` Passing a list of callables (`invoke=[a, b]`) creates independent invocations — each sends its own `done.invoke` event, so the first to complete triggers the transition and cancels the rest. Use {func}`~statemachine.invoke.invoke_group` when you need all callables to complete before transitioning: ```py >>> from statemachine.invoke import invoke_group >>> class BatchFetch(StateChart): ... loading = State(initial=True, invoke=invoke_group(lambda: "a", lambda: "b")) ... ready = State(final=True) ... done_invoke_loading = loading.to(ready) ... ... def on_enter_ready(self, data=None, **kwargs): ... self.results = data >>> sm = BatchFetch() >>> import time; time.sleep(0.2) >>> sm.results ['a', 'b'] ``` Invoke also supports child state machines (pass a `StateChart` subclass) and SCXML `` with ``, autoforward, and `#_` / `#_parent` send targets for parent-child communication. See {ref}`invoke` for full documentation. ### Event dispatch #### Event matching following SCXML spec Event matching now follows the [SCXML spec](https://www.w3.org/TR/scxml/#events) — a transition's event descriptor is a prefix match against the dot-separated event name. For example, a transition with `event="error"` matches `error`, `error.send`, `error.send.failed`, etc. An event designator consisting solely of `*` can be used as a wildcard matching any event. See {ref}`events` for full details. #### Delayed events Events can be scheduled for future processing using `delay` (in milliseconds). The engine tracks execution time and processes the event only when the delay has elapsed. ```python sm.send("light_beacons", delay=500) # fires after 500ms ``` Delayed events can be cancelled before they fire using `send_id` and `cancel_event()`. Cancellation is most useful in async codebases, where other coroutines can cancel the event while the delay is pending. In the sync engine, the delay is **blocking** — the processing loop sleeps until the delay elapses. ```python sm.send("light_beacons", delay=5000, send_id="beacon_signal") sm.cancel_event("beacon_signal") # cancel from another coroutine or callback ``` See {ref}`delayed-events` for details. #### `raise_()` — internal events A new `raise_()` method sends events to the internal queue, equivalent to `send(..., internal=True)`. Internal events are processed immediately within the current macrostep, before any external events. See {ref}`sending-events`. #### New `send()` parameters The `send()` method now accepts additional optional parameters: - `delay` (float): Time in milliseconds before the event is processed. - `send_id` (str): Identifier for the event, useful for cancelling delayed events. - `internal` (bool): If `True`, the event is placed in the internal queue and processed in the current macrostep. Existing calls to `send()` are fully backward compatible. ### Error handling with `error.execution` When `catch_errors_as_events` is enabled (default in `StateChart`), runtime exceptions during transitions are caught and result in an internal `error.execution` event. This follows the [SCXML error handling specification](https://www.w3.org/TR/scxml/#errorsAndEvents). A naming convention makes this easy to use: any event attribute starting with `error_` automatically matches both the underscore and dot-notation forms: ```py >>> from statemachine import State, StateChart >>> class MyChart(StateChart): ... s1 = State("s1", initial=True) ... error_state = State("error_state", final=True) ... ... go = s1.to(s1, on="bad_action") ... error_execution = s1.to(error_state) # matches "error.execution" automatically ... ... def bad_action(self): ... raise RuntimeError("something went wrong") >>> sm = MyChart() >>> sm.send("go") >>> sm.configuration == {sm.error_state} True ``` Errors are caught at the **block level**: each microstep phase (exit, transition `on`, enter) is an independent block. An error in one block does not prevent subsequent blocks from executing — in particular, `after` callbacks always run, making `after_()` a natural finalize hook. The error object is available as `error` in handler kwargs. See {ref}`error-execution` for full details. ### New API #### `configuration` and `configuration_values` Due to compound and parallel states, the state machine can now have multiple active states. The new `configuration` property returns an `OrderedSet[State]` of all currently active states, and `configuration_values` returns their values. These replace the deprecated `current_state` property. See {ref}`querying-configuration`. #### `is_terminated` property A new read-only property that returns `True` when the state machine has reached a final state and the engine is no longer running. Works correctly for all topologies — flat, compound, and parallel. See {ref}`checking-termination`. ```py >>> from statemachine import State, StateChart >>> class SimpleSM(StateChart): ... idle = State(initial=True) ... done = State(final=True) ... finish = idle.to(done) >>> sm = SimpleSM() >>> sm.is_terminated False >>> sm.send("finish") >>> sm.is_terminated True ``` #### `In(state)` condition checks Conditions can now check if a state is in the current configuration using the `In('')` syntax. This is particularly useful in parallel regions where a transition depends on the state of another region. See {ref}`condition expressions`. ```py >>> from statemachine import State, StateChart >>> class Spaceship(StateChart): ... class systems(State.Parallel): ... class engine(State.Compound): ... off = State(initial=True) ... on = State() ... ignite = off.to(on) ... class hatch(State.Compound): ... open = State(initial=True) ... sealed = State() ... seal = open.to(sealed) ... orbit = State(final=True) ... launch = systems.to(orbit, cond="In('on') and In('sealed')") >>> sm = Spaceship() >>> sm.send("launch") # engine off, hatch open — guard fails >>> "off" in sm.configuration_values True >>> sm.send("ignite") >>> sm.send("launch") # engine on, hatch still open — guard fails >>> "on" in sm.configuration_values and "open" in sm.configuration_values True >>> sm.send("seal") >>> sm.send("launch") # both conditions met — launches! >>> sm.is_terminated True ``` #### `prepare_event()` callback The `prepare_event` callback lets you inject custom data into `**kwargs` for all other callbacks in the same event processing cycle. See {ref}`preparing-events`. ```py >>> from statemachine import State, StateMachine >>> class ExampleStateMachine(StateMachine): ... initial = State(initial=True) ... ... loop = initial.to.itself() ... ... def prepare_event(self): ... return {"foo": "bar"} ... ... def on_loop(self, foo): ... return f"On loop: {foo}" >>> sm = ExampleStateMachine() >>> sm.loop() 'On loop: bar' ``` #### Constructor kwargs forwarded to initial state callbacks Constructor keyword arguments are forwarded to initial state callbacks, so self-contained machines can receive context at creation time: ```py >>> from statemachine import State, StateChart >>> class Greeter(StateChart): ... idle = State(initial=True) ... done = State(final=True) ... idle.to(done) ... ... def on_enter_idle(self, name=None, **kwargs): ... self.greeting = f"Hello, {name}!" >>> sm = Greeter(name="Alice") >>> sm.greeting 'Hello, Alice!' ``` ### Developer experience #### `StateChart` base class The new `StateChart` class is the recommended base for all new state machines. It enables SCXML-compliant defaults: `catch_errors_as_events`, `enable_self_transition_entries`, and non-atomic configuration updates. The existing `StateMachine` class is now a subclass with backward-compatible defaults. See {ref}`behaviour` for a comparison table. #### Typed models with `Generic[TModel]` `StateChart` now supports a generic type parameter for the model, enabling full type inference and IDE autocompletion on `sm.model`: ```py >>> from statemachine import State, StateChart >>> class MyModel: ... name: str = "" ... value: int = 0 >>> class MySM(StateChart["MyModel"]): ... idle = State(initial=True) ... active = State(final=True) ... go = idle.to(active) >>> sm = MySM(model=MyModel()) >>> sm.model.name '' ``` With this declaration, type checkers infer `sm.model` as `MyModel` (not `Any`), so accessing `sm.model.name` or `sm.model.value` gets full autocompletion and type safety. When no type parameter is given, `StateChart` defaults to `StateChart[Any]` for backward compatibility. See {ref}`domain models` for details. #### Improved type checking with pyright The library now supports [pyright](https://github.com/microsoft/pyright) in addition to mypy. Type annotations have been improved throughout the codebase, and a catch-all `__getattr__` that previously returned `Any` has been removed — type checkers can now detect misspelled attribute names and unresolved references on `StateChart` subclasses. #### Self-transition entry/exit behavior In `StateChart`, self-transitions now execute entry and exit actions, following the SCXML spec. The `enable_self_transition_entries` class attribute controls this behavior. `StateMachine` preserves the 2.x default (no entry/exit on self-transitions). See {ref}`self-transition`. #### Class-level listener declarations Listeners can now be declared at the class level using the `listeners` attribute, so they are automatically attached to every instance. The list accepts callables (classes, `partial`, lambdas) as factories that create a fresh listener per instance, or pre-built instances that are shared. A `setup()` protocol allows factory-created listeners to receive runtime dependencies (DB sessions, Redis clients, etc.) via `**kwargs` forwarded from the SM constructor. Inheritance is supported: child listeners are appended after parent listeners, unless `listeners_inherit = False` is set to replace them entirely. See {ref}`observers` for full documentation. #### Weighted (probabilistic) transitions A new contrib module `statemachine.contrib.weighted` provides `weighted_transitions()`, enabling probabilistic transition selection based on relative weights. This works entirely through the existing condition system — no engine changes required. See {ref}`weighted-transitions` for full documentation. #### State timeouts A new contrib module `statemachine.contrib.timeout` provides a `timeout()` invoke helper for per-state watchdog timers. When a state is entered, a background timer starts; if the state is not exited before the timer expires, an event is sent automatically. The timer is cancelled on state exit, with no manual cleanup needed. ```py >>> from statemachine import State, StateChart >>> from statemachine.contrib.timeout import timeout >>> class WaitingMachine(StateChart): ... waiting = State(initial=True, invoke=timeout(5, on="expired")) ... timed_out = State(final=True) ... expired = waiting.to(timed_out) >>> sm = WaitingMachine() >>> sm.waiting.is_active True ``` See {ref}`timeout` for full documentation. #### Create state machine from a dict definition Dynamically create state machine classes using {func}`~statemachine.io.create_machine_class_from_definition`: ``` py >>> from statemachine.io import create_machine_class_from_definition >>> machine = create_machine_class_from_definition( ... "TrafficLightMachine", ... **{ ... "states": { ... "green": {"initial": True, "on": {"change": [{"target": "yellow"}]}}, ... "yellow": {"on": {"change": [{"target": "red"}]}}, ... "red": {"on": {"change": [{"target": "green"}]}}, ... }, ... } ... ) >>> sm = machine() >>> sm.green.is_active True >>> sm.send("change") >>> sm.yellow.is_active True ``` #### Async concurrent event result routing When multiple coroutines send events concurrently via `asyncio.gather`, each caller now receives its own event's result (or exception). Previously, only the first caller to acquire the processing lock would get a result — subsequent callers received `None` and exceptions could leak to the wrong caller. This is implemented by attaching an `asyncio.Future` to each externally enqueued event in the async engine. See {ref}`async` for details. Fixes [#509](https://github.com/fgmacedo/python-statemachine/issues/509). #### Migration guide from pytransitions A new {ref}`Coming from pytransitions ` guide helps users of the [*transitions*](https://github.com/pytransitions/transitions) library evaluate the differences and migrate their state machines. It includes side-by-side code comparisons and a feature matrix. #### Migration guide from the GoF State Pattern A new {ref}`Coming from the State Pattern ` guide helps developers familiar with the classic Gang of Four State Pattern understand how to port their hand-rolled state implementations to python-statemachine. It walks through a complete example, compares the two approaches, and highlights what you gain from the declarative style. #### Validation flags The `strict_states` class parameter has been replaced by two independent, always-on class-level attributes: - `validate_trap_states`: non-final states must have at least one outgoing transition. - `validate_final_reachability`: when final states exist, all non-final states must have a path to at least one final state. - `validate_disconnected_states`: all states must be reachable from the initial state. See {ref}`validations` for details. ## Known limitations The following SCXML features are **not yet implemented** and are deferred to a future release: - HTTP and other external communication targets (only `#_internal`, `#_parent`, and `#_` send targets are supported) ```{seealso} For a step-by-step migration guide with before/after examples, see {ref}`Upgrading from 2.x to 3.0 `. ``` ## Backward incompatible changes in 3.0 This section summarizes the breaking changes. For detailed before/after examples and migration instructions, see the [upgrade guide](upgrade_2x_to_3.md). - **Python 3.7 and 3.8 dropped.** StateMachine 3.0 supports Python 3.9 through 3.14. - **Non-RTC model removed.** The `rtc` parameter (deprecated since 2.3.2) has been removed. All events are now queued before being processed. - **`current_state` deprecated.** Use `configuration` / `configuration_values` instead. With compound and parallel states, multiple states can be active simultaneously. - **Configuration update timing.** In `StateChart`, states are exited *before* `on` callbacks and entered *after*, following the SCXML spec. Two new kwargs — `previous_configuration` and `new_configuration` — are available in `on` callbacks. Use `atomic_configuration_update=True` or the `StateMachine` class to restore the 2.x behavior. - **Self-transition entry/exit.** In `StateChart`, self-transitions now trigger `on_enter_*` / `on_exit_*` callbacks. Set `enable_self_transition_entries = False` to restore the old behavior. - **`add_observer()` removed.** Use `add_listener()` instead. - **`TransitionNotAllowed` changes.** Now stores `configuration` (a set) instead of `state`, and `event` can be `None`. - **`allow_event_without_transition` moved to class level.** No longer an `__init__` parameter. - **`States.from_enum` default changed.** `use_enum_instance` now defaults to `True`. - **Short registry names removed.** Use fully-qualified names with `get_machine_cls()`. - **`strict_states` removed.** Replaced by `validate_trap_states` and `validate_final_reachability` (both default to `True`). - **`__repr__` output changed.** Now shows `configuration=[...]` instead of `current_state=...`. ================================================ FILE: docs/releases/3.1.0.md ================================================ # StateChart 3.1.0 *Not released yet* ## What's new in 3.1.0 ### Text representations with `format()` State machines now support Python's built-in `format()` protocol. Use f-strings or `format()` to get text representations — on both classes and instances: ```python f"{TrafficLightMachine:md}" f"{sm:mermaid}" format(sm, "rst") ``` Supported formats: | Format | Output | Requires | |-----------|---------------------------|-----------------------| | `dot` | Graphviz DOT source | `pydot` | | `svg` | SVG markup (via Graphviz) | `pydot` + `graphviz` | | `mermaid` | Mermaid stateDiagram-v2 | — | | `md` | Markdown transition table | — | | `rst` | RST transition table | — | See {ref}`diagram:Text representations` for details. ### Formatter facade A new `Formatter` facade with decorator-based registration unifies all text format rendering behind a single API. Adding a new format requires only registering a render function — no changes to `__format__`, the CLI, or the Sphinx directive: ```python from statemachine.contrib.diagram import formatter formatter.render(sm, "mermaid") formatter.supported_formats() @formatter.register_format("custom") def _render_custom(machine_or_class): ... ``` See {ref}`formatter-api` for details. ### Mermaid diagram support State machines can now be rendered as [Mermaid `stateDiagram-v2`](https://mermaid.js.org/syntax/stateDiagram.html) source text — no Graphviz installation required. Supports compound states, parallel regions, history states, guards, and active-state highlighting. Three ways to use it: - **f-strings:** `f"{sm:mermaid}"` - **CLI:** `python -m statemachine.contrib.diagram MyMachine - --format mermaid` - **Sphinx directive:** `:format: mermaid` renders via `sphinxcontrib-mermaid`. See {ref}`diagram:Mermaid format` for details. ### Auto-expanding docstrings Use `{statechart:FORMAT}` placeholders in your class docstring to embed a live representation of the state machine. The placeholder is replaced at class definition time, so the docstring always stays in sync with the code: ```python class TrafficLight(StateChart): """A traffic light. {statechart:md} """ green = State(initial=True) yellow = State() red = State() cycle = green.to(yellow) | yellow.to(red) | red.to(green) ``` Any registered format works: `md`, `rst`, `mermaid`, `dot`, etc. Works with Sphinx autodoc — the expanded docstring is what gets rendered. See {ref}`diagram:Auto-expanding docstrings` for details. ### Sphinx directive for inline diagrams A new Sphinx extension renders state machine diagrams directly in your documentation from an importable class path — no manual image generation needed. Add `"statemachine.contrib.diagram.sphinx_ext"` to your `conf.py` extensions, then use the directive in any MyST Markdown page: ````markdown ```{statemachine-diagram} myproject.machines.OrderControl :events: receive_payment :caption: After payment :target: ``` ```` The directive supports the same options as the standard `image`/`figure` directives (`:width:`, `:height:`, `:scale:`, `:align:`, `:target:`, `:class:`, `:name:`), plus `:events:` to instantiate the machine and send events before rendering (highlighting the current state). Using `:target:` without a value makes the diagram clickable, opening the full SVG in a new browser tab for zooming — useful for large statecharts. The `:format: mermaid` option renders via `sphinxcontrib-mermaid` instead of Graphviz. See {ref}`diagram:Sphinx directive` for full documentation. [#589](https://github.com/fgmacedo/python-statemachine/pull/589). ### Diagram CLI `--events` and `--format` options The `python -m statemachine.contrib.diagram` command now accepts: - `--events` to instantiate the machine and send events before rendering, highlighting the current active state. - `--format` to choose the output format (`mermaid`, `md`, `rst`, `dot`, `svg`, or image formats via Graphviz). Use `-` as the output path to write text formats to stdout. See {ref}`diagram:Command line` for details. [#593](https://github.com/fgmacedo/python-statemachine/pull/593). ### Performance: 5x–7x faster event processing The engine's hot paths have been systematically profiled and optimized, resulting in **4.7x–7.7x faster event throughput** and **1.9x–2.6x faster setup** across all machine types. All optimizations are internal — no public API changes. See [#592](https://github.com/fgmacedo/python-statemachine/pull/592) for details. ### Thread safety documentation The sync engine is thread-safe: multiple threads can send events to the same state machine instance concurrently. This is now documented in the {ref}`processing model ` and verified by stress tests. [#592](https://github.com/fgmacedo/python-statemachine/pull/592). ### Bugfixes in 3.1.0 - Fixes silent misuse of `Event()` with multiple positional arguments. Passing more than one transition to `Event()` (e.g., `Event(t1, t2)`) now raises `InvalidDefinition` with a clear message suggesting the `|` operator. Previously, the second argument was silently interpreted as the event `id`, leaving the extra transitions eventless (auto-firing). [#588](https://github.com/fgmacedo/python-statemachine/pull/588). - `Event.name` is now auto-humanized from the `id` (e.g., `cycle` → `Cycle`, `pick_up` → `Pick up`). Diagrams, Mermaid output, and text tables all display the human-readable name. Explicit `name=` values are preserved. The same `humanize_id()` helper is now shared by `Event` and `State`. [#601](https://github.com/fgmacedo/python-statemachine/pull/601), fixes [#600](https://github.com/fgmacedo/python-statemachine/issues/600). ## Misc in 3.1.0 ================================================ FILE: docs/releases/index.md ================================================ # Release notes Versions follow [Semantic Versioning](https://semver.org/) (`..`). Backward incompatible (breaking) changes will only be introduced in major versions with advance notice in the **Deprecations** section of releases. ```{seealso} Upgrading from 2.x? See [](upgrade_2x_to_3.md) for a step-by-step migration guide. ``` ## 3.x releases Requires Python 3.9+. ```{toctree} :maxdepth: 2 3.1.0 3.0.0 ``` ## 2.x releases Last series to support Python 3.7 and 3.8. ```{toctree} :maxdepth: 2 2.6.0 2.5.0 2.4.0 2.3.6 2.3.5 2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.0 2.1.2 2.1.1 2.1.0 2.0.0 ``` ## 1.x releases Last series to support Python 2.x. ```{toctree} :maxdepth: 2 1.0.3 1.0.2 1.0.1 1.0.0 ``` ## 0.x releases ```{toctree} :maxdepth: 1 0.9.0 0.8.0 0.7.1 0.7.0 0.6.2 0.6.1 0.6.0 0.5.1 0.5.0 0.4.2 0.3.0 0.2.0 0.1.0 ``` ================================================ FILE: docs/releases/upgrade_2x_to_3.md ================================================ # Upgrading from 2.x to 3.0 This guide covers all backward-incompatible changes in python-statemachine 3.0 and provides step-by-step migration instructions from the 2.x series. ```{tip} Most 2.x code continues to work unchanged — the `StateMachine` class preserves backward-compatible defaults. Review this guide to understand what changed and adopt the new APIs at your own pace. ``` ```{tip} **Using an AI coding assistant?** You can use this guide as context for automated migration. Try a prompt like: > Update my usage of python-statemachine following this upgrade guide: > https://python-statemachine.readthedocs.io/en/latest/releases/upgrade_2x_to_3.html > > Apply only the changes that are relevant to my codebase. Do not change working behavior. ``` ## Quick checklist 1. Upgrade Python to 3.9+ (3.7 and 3.8 are no longer supported). 2. Replace `rtc=True/False` in constructors — the non-RTC model has been removed. 3. Replace `allow_event_without_transition` init parameter with a class-level attribute. 4. Replace `sm.current_state` with `sm.configuration` / `sm.configuration_values`. 5. Replace `sm.current_state.final` with `sm.is_terminated`. 6. Replace `sm.add_observer(...)` with `sm.add_listener(...)`. 7. Update code that catches `TransitionNotAllowed` and accesses `.state` → use `.configuration`. 8. Review `on` callbacks that query `is_active` or `current_state` during transitions. 9. If using `StateChart`, note that self-transitions now trigger entry/exit callbacks. 10. If using `States.from_enum`, note that `use_enum_instance` now defaults to `True`. 11. If using `get_machine_cls()` with short names, switch to fully-qualified names. 12. Remove `strict_states=True/False` — replace with `validate_trap_states` / `validate_final_reachability`. 13. Update code that parses `__repr__` output — format changed to `configuration=[...]`. --- ## Python compatibility Support for Python 3.7 and 3.8 has been dropped. If you need these versions, stay on the 2.x series. StateMachine 3.0 supports Python 3.9, 3.10, 3.11, 3.12, 3.13, and 3.14. ## `StateChart` vs `StateMachine` Version 3.0 introduces `StateChart` as the new base class. The existing `StateMachine` class is now a subclass of `StateChart` with defaults that preserve 2.x behavior: | Attribute | `StateChart` | `StateMachine` | |-----------------------------------|:------------:|:--------------:| | `allow_event_without_transition` | `True` | `False` | | `enable_self_transition_entries` | `True` | `False` | | `atomic_configuration_update` | `False` | `True` | | `catch_errors_as_events` | `True` | `False` | **Recommendation:** Use `StateChart` for new code. It follows the [SCXML specification](https://www.w3.org/TR/scxml/) defaults — structured error handling, self-transition entry/exit, and non-atomic configuration updates. For existing code, you can continue using `StateMachine` — it works as before. You can also adopt individual `StateChart` behaviors granularly by overriding class-level attributes: **Before (2.x):** ```python class MyMachine(StateMachine): ... ``` **After (3.0) — gradual adoption:** ```python # Adopt SCXML error handling without switching to StateChart class MyMachine(StateMachine): catch_errors_as_events = True # ... rest of your definition unchanged ``` See {ref}`behaviour` for full details on each attribute. ## Remove the `rtc` parameter The `rtc` parameter was deprecated in 2.3.2 and has been removed. All events are now queued before processing (Run-to-Completion semantics). See {ref}`rtc-model`. **Before (2.x):** ```python sm = MyMachine(rtc=False) # synchronous, non-queued processing ``` **After (3.0):** ```python sm = MyMachine() # RTC is always enabled, remove the parameter ``` If you were passing `rtc=True` (the default), simply remove the parameter. ## `allow_event_without_transition` moved to class level This was previously an `__init__` parameter and is now a class-level attribute. **Before (2.x):** ```python sm = MyMachine(allow_event_without_transition=True) ``` **After (3.0):** ```python class MyMachine(StateMachine): allow_event_without_transition = True # ... states and transitions ``` ```{note} `StateMachine` defaults to `False` (same as 2.x). `StateChart` defaults to `True`. ``` ## `current_state` deprecated — use `configuration` Due to compound and parallel states, the state machine can now have multiple active states. The `current_state` property is deprecated in favor of `configuration`, which always returns an `OrderedSet[State]`. See {ref}`querying-configuration`. **Before (2.x):** ```python state = sm.current_state # returns a single State value = sm.current_state.value # get the value ``` **After (3.0):** ```python states = sm.configuration # returns OrderedSet[State] values = sm.configuration_values # returns OrderedSet of values # If you know you have a single active state (flat machine): state = next(iter(sm.configuration)) # get the single State ``` ```{tip} For flat state machines (no compound/parallel states), `current_state_value` still returns a single value and works as before. But we strongly recommend using `configuration` / `configuration_values` for forward compatibility. ``` ## Replace `current_state.final` with `is_terminated` The old `current_state.final` pattern still works for flat state machines, but `is_terminated` is the recommended replacement — it works correctly for all topologies (flat, compound, and parallel), where "terminated" means all regions have reached a final state. See {ref}`checking-termination`. **Before (2.x):** ```python if sm.current_state.final: print("done") while not sm.current_state.final: sm.send("next") ``` **After (3.0):** ```python if sm.is_terminated: print("done") while not sm.is_terminated: sm.send("next") ``` ## Replace `add_observer()` with `add_listener()` The method `add_observer` has been removed in v3.0. Use `add_listener` instead. For new code, consider using class-level listener declarations — they attach listeners automatically to every instance and support a `setup()` protocol for dependency injection. See {ref}`listeners`. **Before (2.x):** ```python sm.add_observer(my_listener) ``` **After (3.0) — runtime attachment:** ```python sm.add_listener(my_listener) ``` **After (3.0) — class-level declaration (recommended for new code):** ```python class MyMachine(StateChart): listeners = [MyListener] # ... states and transitions ``` ## Update `TransitionNotAllowed` exception handling `TransitionNotAllowed` is raised when an event has no valid transition from the current configuration. Note that this exception only applies when `allow_event_without_transition` is `False` (the `StateMachine` default). In `StateChart`, events without matching transitions are discarded — this follows the SCXML recommendation, where statecharts are reactive systems and not every event is expected to be handled in every state. The exception now stores a `configuration` attribute (a set of states) instead of a single `state` attribute, and the `event` attribute can be `None`. **Before (2.x):** ```python try: sm.send("go") except TransitionNotAllowed as e: print(e.event) # Event instance print(e.state) # single State ``` **After (3.0):** ```python try: sm.send("go") except TransitionNotAllowed as e: print(e.event) # Event instance or None print(e.configuration) # MutableSet[State] ``` ```{tip} If you are migrating to `StateChart`, consider handling errors as events instead of catching exceptions. With `catch_errors_as_events=True` (the default in `StateChart`), runtime errors are dispatched as `error.execution` events that you can handle with transitions. See {ref}`error-execution`. ``` ## Configuration update timing during transitions This is the most impactful behavioral change for existing code. See {ref}`behaviour` for full details on `atomic_configuration_update`. **In 2.x**, the active state was updated atomically _after_ the transition `on` callbacks, meaning `sm.current_state` and `state.is_active` reflected the **source** state during `on` callbacks. **In 3.0** (SCXML-compliant behavior in `StateChart`), states are exited _before_ `on` callbacks and entered _after_, so during `on` callbacks the configuration may be **empty**. ```{important} If you use `StateMachine` (not `StateChart`), the default `atomic_configuration_update=True` **preserves the 2.x behavior**. This section only affects code using `StateChart` or `StateMachine` with `atomic_configuration_update=False`. ``` **Before (2.x):** ```python def on_validate(self): if self.accepted.is_active: # True during on callback in 2.x return "congrats!" ``` **After (3.0):** Two new keyword arguments are available in `on` callbacks to inspect the transition context: ```python def on_validate(self, previous_configuration, new_configuration): if self.accepted in previous_configuration: return "congrats!" ``` - `previous_configuration`: the set of states that were active before the microstep. - `new_configuration`: the set of states that will be active after the microstep. To restore the old behavior globally, set the class attribute: ```python class MyChart(StateChart): atomic_configuration_update = True # restore 2.x behavior ``` Or simply use `StateMachine`, which has `atomic_configuration_update=True` by default. ## Self-transition entry/exit behavior In `StateChart`, self-transitions (a state transitioning to itself) now execute entry and exit actions, following the SCXML spec. In `StateMachine`, the 2.x behavior is preserved (no entry/exit on self-transitions). See {ref}`self-transition`. **Before (2.x):** ```python # Self-transitions did NOT trigger on_enter_*/on_exit_* callbacks loop = s1.to.itself() ``` **After (3.0 with `StateChart`):** ```python # Self-transitions DO trigger on_enter_*/on_exit_* callbacks loop = s1.to.itself() # To disable (preserve 2.x behavior): class MyChart(StateChart): enable_self_transition_entries = False ``` ## `States.from_enum` default changed to `use_enum_instance=True` In 2.x, `States.from_enum` defaulted to `use_enum_instance=False`, meaning state values were the raw enum values (e.g., integers). In 3.0, the default is `True`, so state values are the enum instances themselves. See {ref}`states from enum types`. **Before (2.x):** ```python states = States.from_enum(MyEnum, initial=MyEnum.start) # states.start.value == 1 (raw value) ``` **After (3.0):** ```python states = States.from_enum(MyEnum, initial=MyEnum.start) # states.start.value == MyEnum.start (enum instance) ``` If your code relies on raw enum values, pass `use_enum_instance=False` explicitly. ## Short registry names removed In 2.x, state machine classes were registered both by their fully-qualified name and their short class name. The short-name lookup was deprecated since v0.8 and has been removed in 3.0. **Before (2.x):** ```python from statemachine.registry import get_machine_cls cls = get_machine_cls("MyMachine") # short name — worked with warning ``` **After (3.0):** ```python from statemachine.registry import get_machine_cls cls = get_machine_cls("myapp.machines.MyMachine") # fully-qualified name ``` ## `strict_states` removed — use `validate_trap_states` / `validate_final_reachability` The `strict_states` class parameter has been removed. The two validations it controlled are now always-on by default, each controlled by its own class-level attribute. See {ref}`validations`. **Before (2.x) — `s2` is a trap state (no outgoing transitions, not marked `final`):** ```python class MyMachine(StateMachine, strict_states=False): s1 = State(initial=True) s2 = State() # trap state — no outgoing transitions, not final go = s1.to(s2) ``` **After (3.0) — recommended: fix the definition by marking terminal states as `final`:** ```python class MyMachine(StateMachine): s1 = State(initial=True) s2 = State(final=True) # was State() — now correctly marked as final go = s1.to(s2) ``` **After (3.0) — opt out if you intentionally have non-final trap states:** ```python class MyMachine(StateMachine): validate_trap_states = False # allow non-final states without outgoing transitions validate_final_reachability = False # allow non-final states without path to final s1 = State(initial=True) s2 = State() go = s1.to(s2) ``` The two flags are independent — you can disable one while keeping the other enabled. ## `send()` method — new parameters The `send()` method has new optional parameters for delayed events and internal events. Existing code calling `sm.send("event")` works unchanged. See {ref}`sending-events`. **Before (2.x):** ```python sm.send("event_name", *args, **kwargs) ``` **After (3.0) — fully backward compatible:** ```python sm.send("event_name", *args, delay=0, send_id=None, internal=False, **kwargs) ``` - `delay`: Time in milliseconds before the event is processed. - `send_id`: Identifier for the event, used to cancel delayed events with `sm.cancel_event(send_id)`. - `internal`: If `True`, the event is placed in the internal queue (processed in the current macrostep). ## `__repr__` output changed The string representation now shows `configuration=[...]` instead of `current_state=...`. **Before (2.x):** ``` MyMachine(model=Model(), state_field='state', current_state='initial') ``` **After (3.0):** ``` MyMachine(model=Model(), state_field='state', configuration=['initial']) ``` ## New public exports The package now exports two additional symbols: **Before (2.x):** ```python from statemachine import StateMachine, State, Event ``` **After (3.0):** ```python from statemachine import StateChart # new base class from statemachine import HistoryState # history pseudo-state for compound states from statemachine import StateMachine # unchanged from statemachine import State # unchanged from statemachine import Event # unchanged ``` ## What's new For full details on all new features in 3.0 — including compound states, parallel states, invoke, error handling, and more — see the {ref}`3.0.0 release notes `. ================================================ FILE: docs/statechart.md ================================================ (statechart-instance)= (statechart)= (statemachine)= # StateChart ```{seealso} New to statecharts? See [](concepts.md) for an overview of how states, transitions, events, and actions fit together. ``` Once you define a `StateChart` class with states, transitions, and events, you work with **instances** of that class. Each instance is a live, running machine with its own configuration, event queues, and listeners. This page documents what you can do with that instance at runtime. ## Creating an instance ```py >>> from statemachine import State, StateChart >>> class TrafficLight(StateChart): ... green = State(initial=True) ... yellow = State() ... red = State() ... ... cycle = green.to(yellow) | yellow.to(red) | red.to(green) >>> sm = TrafficLight() ``` The constructor activates the initial state and runs any `on_enter` callbacks. You can pass a `model` object to store state externally (see {ref}`models`) and `listeners` to observe the machine (see {ref}`listeners`). (sending-events)= ## Sending events The primary way to drive a state machine is by sending events. **`send(event, **kwargs)`** places the event on the **external queue**. External events are processed after the current macrostep completes: ```py >>> sm.send("cycle") >>> sm.yellow.is_active True ``` Events can also be called as methods — `sm.cycle()` is equivalent to `sm.send("cycle")`: ```py >>> sm.cycle() >>> sm.red.is_active True ``` **`raise_(event, **kwargs)`** places the event on the **internal queue**. Internal events are processed within the current macrostep, before any pending external events. This is useful inside callbacks when you need to trigger follow-up transitions immediately: ```py >>> from statemachine import State, StateChart >>> class WithInternalEvent(StateChart): ... a = State(initial=True) ... b = State() ... c = State(final=True) ... ... go = a.to(b) ... finish = b.to(c) ... ... def on_enter_b(self): ... self.raise_("finish") >>> sm = WithInternalEvent() >>> sm.send("go") >>> sm.c.is_active True ``` Both methods accept arbitrary keyword arguments that are forwarded to all callbacks via {ref}`dependency injection `. ```{seealso} See {ref}`processing model` for the full macrostep/microstep lifecycle and how internal and external queues interact. ``` (delayed-events)= ### Delayed events Events can be scheduled to fire after a delay (in milliseconds): ```python sm.send("timeout", delay=5000) ``` Delayed events can be cancelled before firing by providing a `send_id`: ```python sm.send("timeout", delay=5000, send_id="my_timeout") sm.cancel_event("my_timeout") ``` ```{note} The delay is **blocking** in the sync engine — the processing loop sleeps until the delay elapses, holding the calling thread. In the async engine, delays are scheduled with `asyncio` and do not block the event loop. ``` (querying-events)= ## Querying events Not every event is relevant in every state. The instance provides two levels of event introspection: **`allowed_events`** — events that have at least one transition **from the current configuration**, regardless of whether guards pass: ```py >>> from statemachine import State, StateChart >>> class Turnstile(StateChart): ... locked = State(initial=True) ... unlocked = State() ... ... coin = locked.to(unlocked) ... push = unlocked.to(locked) >>> sm = Turnstile() >>> [e.id for e in sm.allowed_events] ['coin'] ``` **`enabled_events(**kwargs)`** — a subset of `allowed_events` where at least one transition's guards are **satisfied** given the provided arguments. Use this when guards depend on runtime data: ```py >>> from statemachine import State, StateChart >>> class Gate(StateChart): ... closed = State(initial=True) ... open = State() ... ... enter = closed.to(open, cond="has_badge") ... close = open.to(closed) ... ... def has_badge(self, badge: bool = False): ... return badge >>> sm = Gate() >>> [e.id for e in sm.allowed_events] ['enter'] >>> [e.id for e in sm.enabled_events()] [] >>> [e.id for e in sm.enabled_events(badge=True)] ['enter'] ``` `allowed_events` is cheap — it only checks the state topology. `enabled_events` evaluates guards, so pass the same keyword arguments you would pass to `send()`. (querying-configuration)= ## Querying the configuration The **configuration** is the set of currently active states. In a flat machine this is a single state; with compound and parallel states, multiple states are active simultaneously. **`configuration`** returns the active states as an `OrderedSet[State]`: ```py >>> from statemachine import State, StateChart >>> class Journey(StateChart): ... class shire(State.Compound): ... bag_end = State(initial=True) ... green_dragon = State() ... visit_pub = bag_end.to(green_dragon) ... road = State(final=True) ... depart = shire.to(road) >>> sm = Journey() >>> {s.id for s in sm.configuration} == {"shire", "bag_end"} True ``` **`configuration_values`** returns the values (or IDs when no custom `value` is set) instead of `State` objects — useful for serialization or quick checks: ```py >>> set(sm.configuration_values) == {"shire", "bag_end"} True ``` (checking-termination)= ## Checking termination **`is_terminated`** returns `True` when the machine has completed its work. In a flat machine this means a final state is active. With compound and parallel states, the condition is structural — all parallel regions must have completed, nested compounds must have reached their final children, and so on: ```py >>> sm.send("visit_pub") >>> sm.is_terminated False >>> sm.send("depart") >>> sm.is_terminated True ``` Use `is_terminated` instead of checking individual states — it handles arbitrarily nested structures for you. **`final_states`** lists all top-level states marked as `final`: ```py >>> sm.final_states [State('Road', id='road', value='road', initial=False, final=True, parallel=False)] ``` (runtime-listeners)= ## Managing listeners at runtime Class-level listeners are declared on the class (see {ref}`listeners`). You can also add listeners to a running instance: ```py >>> class Logger: ... def after_transition(self, source: State, target: State, event: str): ... print(f"[log] {source.id} →({event})→ {target.id}") >>> sm = Turnstile() >>> _ = sm.add_listener(Logger()) >>> sm.send("coin") [log] locked →(coin)→ unlocked ``` `add_listener()` returns the instance for chaining. Use `active_listeners` to inspect all currently attached listeners. ## Class-level attributes These are set by the metaclass at class definition time and are available on both the class and its instances: | Attribute | Type | Description | |---|---|---| | `name` | `str` | The class name (e.g., `"TrafficLight"`) | | `states` | `States` | Collection of all top-level states | | `final_states` | `list[State]` | Top-level states marked as `final` | | `events` | `list[Event]` | All events declared on the class | | `initial_state` | `State` | The top-level initial state | | `states_map` | `dict` | Mapping from state values to `State` objects (all nesting levels) | ================================================ FILE: docs/states.md ================================================ (states)= (state)= # States ```{seealso} New to statecharts? See [](concepts.md) for an overview of how states, transitions, events, and actions fit together. ``` A **state** represents a distinct mode or condition of the system at a given point in time. States are the building blocks of a statechart — you define them as class attributes, and the library handles initialization, validation, and lifecycle management. ```py >>> from statemachine import State, StateChart >>> class TrafficLight(StateChart): ... green = State(initial=True) ... yellow = State() ... red = State() ... ... cycle = green.to(yellow) | yellow.to(red) | red.to(green) >>> sm = TrafficLight() >>> "green" in sm.configuration_values True ``` ## State parameters | Parameter | Default | Description | |---|---|---| | `name` | `""` | Human-readable display name. Defaults to the attribute name, capitalized. | | `value` | `None` | Custom value for this state, accessible via `configuration_values`. | | `initial` | `False` | Marks this as the initial state. Exactly one per machine (or per compound). | | `final` | `False` | Marks this as a final (accepting) state. No outgoing transitions allowed. | | `enter` | `None` | Callback(s) to run when entering this state. See {ref}`state-actions`. | | `exit` | `None` | Callback(s) to run when leaving this state. See {ref}`state-actions`. | | `invoke` | `None` | Background work spawned on entry, cancelled on exit. See {ref}`invoke-actions`. | ```py >>> class CampaignMachine(StateChart): ... draft = State("Draft", value=1, initial=True) ... producing = State("Being produced", value=2) ... closed = State("Closed", value=3, final=True) ... ... produce = draft.to(producing) ... deliver = producing.to(closed) >>> sm = CampaignMachine() >>> sm.send("produce") >>> list(sm.configuration_values) [2] ``` ## Initial state A {ref}`StateChart` must have exactly one `initial` state. The initial state is entered when the machine starts, and the corresponding {ref}`enter actions ` are called. (final-state)= ## Final state A **final** state signals that the machine has completed its work. No outgoing transitions are allowed from a final state. ```py >>> sm = CampaignMachine() >>> sm.send("produce") >>> sm.send("deliver") >>> sm.is_terminated True ``` You can query the list of all declared final states: ```py >>> sm.final_states [State('Closed', id='closed', value=3, initial=False, final=True, parallel=False)] ``` ```{seealso} See {ref}`validations` for the checks the library performs at class definition time — including final state reachability, unreachable states, and trap states. ``` (compound-states)= ## Compound states ```{versionadded} 3.0.0 ``` Compound states contain inner child states, enabling hierarchical state machines. Define them using the `State.Compound` inner class syntax: ```py >>> from statemachine import State, StateChart >>> class Journey(StateChart): ... class shire(State.Compound): ... bag_end = State(initial=True) ... green_dragon = State() ... visit_pub = bag_end.to(green_dragon) ... road = State(final=True) ... depart = shire.to(road) >>> sm = Journey() >>> set(sm.configuration_values) == {"shire", "bag_end"} True ``` Entering a compound activates both the parent and its `initial` child. You can query whether a state is compound using the `is_compound` property. ```{seealso} See {ref}`done-state-events` for completion events when a compound state's final child is reached. ``` (parallel-states)= ## Parallel states ```{versionadded} 3.0.0 ``` Parallel states activate all child regions simultaneously. Each region operates independently. Define them using `State.Parallel`: ```py >>> from statemachine import State, StateChart >>> class WarOfTheRing(StateChart): ... class war(State.Parallel): ... class quest(State.Compound): ... start = State(initial=True) ... end = State(final=True) ... go = start.to(end) ... class battle(State.Compound): ... fighting = State(initial=True) ... won = State(final=True) ... victory = fighting.to(won) >>> sm = WarOfTheRing() >>> "start" in sm.configuration_values and "fighting" in sm.configuration_values True ``` ```{seealso} See {ref}`done-state-events` for how `done.state` events work with parallel states (all regions must reach a final state). ``` (history-states)= ## History pseudo-states ```{versionadded} 3.0.0 ``` A history pseudo-state records the active child of a compound state when it is exited. Re-entering via the history state restores the previously active child. Import and use `HistoryState` inside a `State.Compound`: ```py >>> from statemachine import HistoryState, State, StateChart >>> class WithHistory(StateChart): ... class mode(State.Compound): ... a = State(initial=True) ... b = State() ... h = HistoryState() ... switch = a.to(b) ... outside = State() ... leave = mode.to(outside) ... resume = outside.to(mode.h) >>> sm = WithHistory() >>> sm.send("switch") >>> sm.send("leave") >>> sm.send("resume") >>> "b" in sm.configuration_values True ``` Use `HistoryState(type="deep")` for deep history that remembers the exact leaf state in nested compounds. ```{seealso} See {ref}`querying-configuration` for how to inspect which states are currently active at runtime. ``` (states from enum types)= ## States from Enum types {ref}`States` can also be declared from standard `Enum` classes. For this, use {ref}`States (class)` to convert your `Enum` type to a list of {ref}`State` objects. ```{eval-rst} .. automethod:: statemachine.states.States.from_enum :noindex: ``` ```{seealso} See the example {ref}`sphx_glr_auto_examples_enum_campaign_machine.py`. ``` ================================================ FILE: docs/timeout.md ================================================ (timeout)= # State timeouts A common need is preventing a state machine from getting stuck — for example, a "waiting for response" state that should time out after a few seconds. The {func}`~statemachine.contrib.timeout.timeout` helper makes this easy by leveraging the {ref}`invoke ` system: a background timer starts when the state is entered and is automatically cancelled when the state is exited. ## Basic usage When the timeout expires and no custom event is specified, the standard `done.invoke.` event fires — just like any other invoke completion: ```py >>> from statemachine import State, StateChart >>> from statemachine.contrib.timeout import timeout >>> class WaitingMachine(StateChart): ... waiting = State(initial=True, invoke=timeout(5)) ... done = State(final=True) ... done_invoke_waiting = waiting.to(done) >>> sm = WaitingMachine() >>> sm.waiting.is_active True ``` In this example, if the machine stays in `waiting` for 5 seconds, `done.invoke.waiting` fires and the machine transitions to `done`. If any other event causes a transition out of `waiting` first, the timer is cancelled automatically. ## Custom timeout event Use the `on` parameter to send a specific event name instead of `done.invoke.`. This is useful when you want to distinguish timeouts from normal completions: ```py >>> from statemachine import State, StateChart >>> from statemachine.contrib.timeout import timeout >>> class RequestMachine(StateChart): ... requesting = State(initial=True, invoke=timeout(30, on="request_timeout")) ... timed_out = State(final=True) ... request_timeout = requesting.to(timed_out) >>> sm = RequestMachine() >>> sm.requesting.is_active True ``` ## Composing with other invoke handlers Since `timeout()` returns a standard invoke handler, you can combine it with other handlers in a list. The first handler to complete and trigger a transition wins — the state exit cancels everything else: ```py >>> from statemachine import State, StateChart >>> from statemachine.contrib.timeout import timeout >>> def fetch_data(): ... return {"status": "ok"} >>> class LoadingMachine(StateChart): ... loading = State(initial=True, invoke=[fetch_data, timeout(30, on="too_slow")]) ... ready = State(final=True) ... stuck = State(final=True) ... done_invoke_loading = loading.to(ready) ... too_slow = loading.to(stuck) >>> sm = LoadingMachine() >>> sm.ready.is_active True ``` In this example: - If `fetch_data` completes within 30 seconds, `done.invoke.loading` fires and transitions to `ready`, cancelling the timeout. - If 30 seconds pass first, `too_slow` fires and transitions to `stuck`, cancelling the `fetch_data` invoke. ## API reference See {func}`~statemachine.contrib.timeout.timeout` in the {ref}`API docs `. ================================================ FILE: docs/transitions.md ================================================ (transitions)= (transition)= # Transitions ```{seealso} New to statecharts? See [](concepts.md) for an overview of how states, transitions, events, and actions fit together. ``` A transition describes a valid state change: it connects a **source** state to a **target** state and is triggered by an {ref}`event `. Transitions can carry {ref}`actions` (side-effects) and {ref}`conditions ` that control whether the transition fires. ## Declaring transitions Link states using `source.to(target)` and assign the result to a class attribute — the attribute name becomes the event: ```py >>> from statemachine import State, StateChart >>> class OrderSM(StateChart): ... pending = State(initial=True) ... confirmed = State(final=True) ... ... confirm = pending.to(confirmed) >>> sm = OrderSM() >>> sm.send("confirm") >>> "confirmed" in sm.configuration_values True ``` ### Transition parameters | Parameter | Description | |---|---| | `on` | Action callback(s) to run during the transition. See {ref}`transition-actions`. | | `before` | Callback(s) to run before exit/on/enter. | | `after` | Callback(s) to run after the transition completes. | | `cond` | Guard condition(s). See {ref}`validators and guards`. | | `unless` | Negative guard — transition fires when this returns `False`. | | `validators` | Validation callback(s) that raise on failure. | | `event` | Override the event for this transition. See {ref}`event-parameter`. | | `internal` | If `True`, no exit/enter actions fire. See {ref}`internal transition`. | ### Combining transitions with `|` The `|` operator merges transitions under a single event. Each transition is evaluated in declaration order — the first whose conditions are met wins: ```py >>> class TrafficLight(StateChart): ... green = State(initial=True) ... yellow = State() ... red = State() ... ... cycle = green.to(yellow) | yellow.to(red) | red.to(green) >>> sm = TrafficLight() >>> sm.send("cycle") >>> "yellow" in sm.configuration_values True ``` Combine `|` with guards to route the same event to different targets: ```py >>> class OrderReview(StateChart): ... pending = State(initial=True) ... approved = State(final=True) ... rejected = State(final=True) ... ... review = ( ... pending.to(approved, cond="is_valid") ... | pending.to(rejected) ... ) ... ... def is_valid(self, score: int = 0): ... return score >= 70 >>> sm = OrderReview() >>> sm.send("review", score=50) >>> "rejected" in sm.configuration_values True >>> sm = OrderReview() >>> sm.send("review", score=85) >>> "approved" in sm.configuration_values True ``` The first transition whose guard passes wins. When `score < 70`, `is_valid` returns `False`, so the second transition (no guard — always matches) fires. ### `from_()` and `from_.any()` `target.from_(source)` declares the same transition from the target's perspective — useful when multiple sources converge on one target: ```py >>> class OrderSM(StateChart): ... pending = State(initial=True) ... processing = State() ... shipped = State(final=True) ... ... process = pending.to(processing) ... ship = shipped.from_(pending, processing) ``` `target.from_.any()` creates a transition from **every non-final state** — useful for global events like "cancel" that should be reachable from anywhere: ```py >>> class OrderWorkflow(StateChart): ... pending = State(initial=True) ... processing = State() ... done = State() ... completed = State(final=True) ... cancelled = State(final=True) ... ... process = pending.to(processing) ... complete = processing.to(done) ... finish = done.to(completed) ... cancel = cancelled.from_.any() >>> sm = OrderWorkflow() >>> sm.send("cancel") >>> "cancelled" in sm.configuration_values True ``` With {ref}`compound states `, there is another way to model the same workflow: group the cancellable states under a compound parent, and define a single transition out of it. The `cancel` event exits the compound regardless of which child is active: ```py >>> class OrderWorkflowCompound(StateChart): ... class active(State.Compound): ... pending = State(initial=True) ... processing = State() ... done = State(final=True) ... ... process = pending.to(processing) ... complete = processing.to(done) ... completed = State(final=True) ... cancelled = State(final=True) ... done_state_active = active.to(completed) ... cancel = active.to(cancelled) >>> sm = OrderWorkflowCompound() >>> sm.send("process") >>> sm.send("cancel") >>> "cancelled" in sm.configuration_values True ``` Compare the diagrams — both model the same behavior, but the compound version makes the "cancellable" grouping explicit in the hierarchy: ```{statemachine-diagram} tests.machines.transition_from_any.OrderWorkflow :caption: from_.any() ``` ```{statemachine-diagram} tests.machines.transition_from_any.OrderWorkflowCompound :caption: Compound :target: ``` The compound approach scales better as you add more states — no need to remember to include each new state in a `from_()` list. (self-transition)= (self transition)= ## Self-transitions and internal transitions A **self-transition** goes from a state back to itself. It exits and re-enters the state, running all exit and entry actions: ```py >>> class RetryOrder(StateChart): ... processing = State(initial=True) ... done = State(final=True) ... ... retry = processing.to.itself(on="do_retry") ... finish = processing.to(done) ... ... attempts: int = 0 ... ... def do_retry(self): ... self.attempts += 1 >>> sm = RetryOrder() >>> sm.send("retry") >>> sm.send("retry") >>> sm.attempts 2 ``` (internal transition)= (internal-transition)= An **internal transition** stays in the same state **without** running exit or entry actions — only the `on` callback executes. Use `internal=True`: ```py >>> class OrderCart(StateChart): ... shopping = State(initial=True) ... checkout = State(final=True) ... ... add_item = shopping.to.itself(internal=True, on="do_add_item") ... pay = shopping.to(checkout) ... ... total: float = 0 ... ... def do_add_item(self, price: float = 0): ... self.total += price >>> sm = OrderCart() >>> sm.send("add_item", price=9.99) >>> sm.send("add_item", price=4.50) >>> sm.total 14.49 ``` The key difference: self-transitions fire exit/enter callbacks (useful when entering a state has side-effects like resetting a timer), while internal transitions skip them (useful for pure data updates that shouldn't re-trigger entry logic). ```{seealso} The `enable_self_transition_entries` flag in {ref}`behaviour` controls whether self-transitions run exit/enter actions. `StateChart` defaults to `True` (SCXML semantics); `StateMachine` defaults to `False` (legacy behavior). ``` (eventless)= ## Eventless (automatic) transitions ```{versionadded} 3.0.0 ``` Eventless transitions have no event trigger — they fire automatically when their guard condition evaluates to `True`. If no guard is specified, they fire immediately (unconditional). Declare them as bare statements, without assigning to a variable: ```py >>> from statemachine import State, StateChart >>> class AutoEscalation(StateChart): ... normal = State(initial=True) ... escalated = State(final=True) ... normal.to(escalated, cond="should_escalate") ... report = normal.to.itself(internal=True, on="add_report") ... report_count = 0 ... def should_escalate(self): ... return self.report_count >= 3 ... def add_report(self): ... self.report_count += 1 >>> sm = AutoEscalation() >>> sm.send("report") >>> sm.send("report") >>> "normal" in sm.configuration_values True >>> sm.send("report") >>> "escalated" in sm.configuration_values True ``` The eventless transition fires automatically after the third report pushes `report_count` past the threshold. ```{seealso} See {ref}`continuous-machines` for chains, compound interactions, and `In()` guards. ``` (cross-boundary-transitions)= ## Cross-boundary transitions ```{versionadded} 3.0.0 ``` In statecharts, transitions can cross compound state boundaries — going from a state inside one compound to a state outside, or into a different compound. The engine automatically determines which states to exit and enter by computing the **transition domain**: the smallest compound ancestor that contains both the source and all target states. ```py >>> from statemachine import State, StateChart >>> class OrderFulfillment(StateChart): ... class picking(State.Compound): ... locating = State(initial=True) ... packing = State() ... locate = locating.to(packing) ... class shipping(State.Compound): ... labeling = State(initial=True) ... dispatched = State(final=True) ... dispatch = labeling.to(dispatched) ... ship = picking.to(shipping) >>> sm = OrderFulfillment() >>> set(sm.configuration_values) == {"picking", "locating"} True >>> sm.send("ship") >>> set(sm.configuration_values) == {"shipping", "labeling"} True ``` When `ship` fires, the engine: 1. Computes the transition domain (the root, since `picking` and `shipping` are siblings) 2. Exits `locating` and `picking` (running their exit actions) 3. Enters `shipping` and its initial child `labeling` (running their entry actions) (transition-priority)= ## Transition priority in compound states ```{versionadded} 3.0.0 ``` When an event could match transitions at multiple levels of the state hierarchy, transitions from **descendant states take priority** over transitions from ancestor states. This follows the SCXML specification: the most specific (deepest) matching transition wins. ```py >>> from statemachine import State, StateChart >>> class OrderProcessing(StateChart): ... log = [] ... class fulfillment(State.Compound): ... class picking(State.Compound): ... s1 = State(initial=True) ... s2 = State(final=True) ... go = s1.to(s2, on="log_picking") ... assert isinstance(picking, State) ... packed = State(final=True) ... done_state_picking = picking.to(packed) ... shipped = State(final=True) ... done_state_fulfillment = fulfillment.to(shipped) ... def log_picking(self): ... self.log.append("picking handled it") >>> sm = OrderProcessing() >>> sm.send("go") >>> sm.log ['picking handled it'] ``` If two transitions at the same level would exit overlapping states (a conflict), the one declared first wins. ================================================ FILE: docs/tutorial.md ================================================ # Tutorial This tutorial walks you through python-statemachine from your first flat state machine all the way to full statecharts — compound states, parallel regions, history, and async. Each section builds on the previous one using the same domain: **a coffee shop order system**. By the end you will be comfortable defining states, transitions, guards, actions, and listeners, and you will see how the same declarative API scales from a five-state FSM to a production-grade statechart — no new concepts required. ## Your first state machine A coffee order goes through a few stages: the customer places it, the barista prepares it, and the customer picks it up. ```py >>> from statemachine import StateChart, State >>> class CoffeeOrder(StateChart): ... # Define the states ... pending = State(initial=True) ... preparing = State() ... ready = State() ... picked_up = State(final=True) ... ... # Define events — each one groups one or more transitions ... start = pending.to(preparing) ... finish = preparing.to(ready) ... pick_up = ready.to(picked_up) ``` That's it — states are class attributes, transitions are built with `state.to(target)`, and events are the names you assign them to. Create an instance and start sending events: ```py >>> order = CoffeeOrder() >>> order.pending.is_active True >>> order.send("start") >>> order.preparing.is_active True >>> order.send("finish") >>> order.send("pick_up") >>> order.picked_up.is_active True ``` You can also call events as methods — `order.start()` is equivalent to `order.send("start")`: ```py >>> order = CoffeeOrder() >>> order.start() >>> order.preparing.is_active True ``` ```{tip} Use `sm.send("event_name")` when the event name is dynamic (e.g., comes from user input or a message queue). Use `sm.event_name()` when writing application code where the event is known at development time. ``` ## Adding behavior with actions A state machine without side effects is just a diagram. Actions let you attach behavior to state entries, exits, and transitions. Define actions by naming convention — the library discovers them automatically: ```py >>> from statemachine import StateChart, State >>> class CoffeeOrder(StateChart): ... pending = State(initial=True) ... preparing = State() ... ready = State() ... picked_up = State(final=True) ... ... start = pending.to(preparing) ... finish = preparing.to(ready) ... pick_up = ready.to(picked_up) ... ... # Called when entering the "preparing" state ... def on_enter_preparing(self): ... print("Barista starts making the drink.") ... ... # Called when the "finish" event fires ... def on_finish(self): ... print("Drink is ready!") ... ... # Called when entering the "picked_up" state ... def on_enter_picked_up(self): ... print("Customer picked up the order. Enjoy!") >>> order = CoffeeOrder() >>> order.send("start") Barista starts making the drink. >>> order.send("finish") Drink is ready! >>> order.send("pick_up") Customer picked up the order. Enjoy! ``` The naming conventions are: | Pattern | When it runs | |---------------------------|---------------------------------------| | `on_enter_()` | Every time `` is entered | | `on_exit_()` | Every time `` is exited | | `before_()` | Before any transition for `` | | `on_()` | During the transition for `` | | `after_()` | After the transition for `` | ```{seealso} The full list of action callbacks and their execution order is in [](actions.md). ``` ### Dependency injection in callbacks Callbacks don't need to accept a fixed signature. Declare only the parameters you need, and the library injects them automatically: ```py >>> from statemachine import StateChart, State >>> class CoffeeOrder(StateChart): ... pending = State(initial=True) ... preparing = State() ... ready = State() ... picked_up = State(final=True) ... ... start = pending.to(preparing) ... finish = preparing.to(ready) ... pick_up = ready.to(picked_up) ... ... def on_enter_preparing(self, source: State, target: State): ... print(f"{source.id} → {target.id}") ... ... def on_finish(self): ... print("Done!") >>> order = CoffeeOrder() >>> order.send("start") pending → preparing >>> order.send("finish") Done! ``` `on_enter_preparing` asks for `source` and `target` — it gets them. `on_finish` asks for nothing extra — that's fine too. Available parameters include `event`, `source`, `target`, `state`, and any keyword arguments you pass to `send()`: ```py >>> from statemachine import StateChart, State >>> class CoffeeOrder(StateChart): ... pending = State(initial=True) ... preparing = State() ... ready = State(final=True) ... ... start = pending.to(preparing) ... finish = preparing.to(ready) ... ... def on_start(self, drink: str = "coffee"): ... print(f"Making a {drink}.") >>> order = CoffeeOrder() >>> order.send("start", drink="cappuccino") Making a cappuccino. ``` ## Guards: conditional transitions Not every transition should always be allowed. Guards are conditions that must be satisfied for a transition to fire. A coffee order shouldn't move to `preparing` unless it has been paid for: ```py >>> from statemachine import StateChart, State >>> class CoffeeOrder(StateChart): ... pending = State(initial=True) ... preparing = State() ... ready = State() ... picked_up = State(final=True) ... ... # Two transitions on the same event — checked in declaration order. ... # The first whose guard passes wins. ... start = ( ... pending.to(preparing, cond="is_paid") ... | pending.to(pending) # fallback: stay in pending ... ) ... finish = preparing.to(ready) ... pick_up = ready.to(picked_up) ... ... paid: bool = False ... ... def is_paid(self): ... return self.paid >>> order = CoffeeOrder() >>> order.send("start") # not paid — stays in pending >>> order.pending.is_active True >>> order.paid = True >>> order.send("start") # paid — moves to preparing >>> order.preparing.is_active True ``` Guards receive the same dependency injection as actions — you can accept `event`, `source`, `target`, and any extra keyword arguments: ```py >>> from statemachine import StateChart, State >>> class CoffeeOrder(StateChart): ... pending = State(initial=True) ... preparing = State(final=True) ... ... start = ( ... pending.to(preparing, cond="is_paid") ... | pending.to(pending) ... ) ... ... def is_paid(self, amount: float = 0): ... return amount >= 5.0 >>> order = CoffeeOrder() >>> order.send("start", amount=3.0) >>> order.pending.is_active True >>> order.send("start", amount=5.0) >>> order.preparing.is_active True ``` ```{seealso} See [](guards.md) for `unless=`, validators, boolean expressions in condition strings, and evaluation order details. ``` ## Observing from outside with listeners Listeners let external objects react to state changes without touching the state machine definition. Any object with methods matching the callback naming conventions works as a listener. The preferred way is to declare listeners at the class level — they are automatically attached to every instance: ```py >>> from statemachine import StateChart, State >>> class NotificationService: ... def on_enter_state(self, target: State): ... print(f"[notify] Order is now: {target.id}") >>> class CoffeeOrder(StateChart): ... listeners = [NotificationService] ... ... pending = State(initial=True) ... preparing = State() ... ready = State(final=True) ... ... start = pending.to(preparing) ... finish = preparing.to(ready) >>> order = CoffeeOrder() [notify] Order is now: pending >>> order.send("start") [notify] Order is now: preparing >>> order.send("finish") [notify] Order is now: ready ``` When the `listeners` list contains a **class** (like `NotificationService` above), it acts as a factory — a fresh instance is created for each state machine. Pass an already-built **instance** instead if you want a shared, stateless listener (e.g., a global logger). You can also add listeners at runtime, either via the constructor or on an already running machine: ```py >>> class AuditLog: ... def after_transition(self, source: State, target: State, event: str): ... print(f"[audit] {source.id} →({event})→ {target.id}") >>> order = CoffeeOrder() [notify] Order is now: pending >>> _ = order.add_listener(AuditLog()) >>> order.send("start") [notify] Order is now: preparing [audit] pending →(start)→ preparing ``` The machine knows nothing about the listener, and the listener knows nothing about the machine's internals — only the callback protocol. ```{seealso} See [](listeners.md) for class-level listener configuration, `functools.partial` factories, and the full list of listener callbacks. ``` ## Generating diagrams Visualize any state machine as a diagram: ```{statemachine-diagram} tests.machines.tutorial_coffee_order.CoffeeOrder :alt: CoffeeOrder diagram ``` Generate diagrams programmatically with `_graph()`: ```python order = CoffeeOrder() order._graph().write_png("order.png") ``` Or from the command line: ```bash python -m statemachine.contrib.diagram my_module.CoffeeOrder order.png ``` ### Text representations with `format()` You can also get text representations of any state machine using Python's built-in `format()` or f-strings — no Graphviz needed: ```py >>> from tests.machines.tutorial_coffee_order import CoffeeOrder >>> print(f"{CoffeeOrder:md}") | State | Event | Guard | Target | | --------- | ------- | ----- | --------- | | Pending | Start | | Preparing | | Preparing | Finish | | Ready | | Ready | Pick up | | Picked up | ``` Supported formats include `mermaid`, `md` (markdown table), `rst`, `dot`, and `svg`. Works on both classes and instances: ```py >>> print(f"{CoffeeOrder:mermaid}") stateDiagram-v2 direction LR state "Pending" as pending state "Preparing" as preparing state "Ready" as ready state "Picked up" as picked_up [*] --> pending picked_up --> [*] pending --> preparing : Start preparing --> ready : Finish ready --> picked_up : Pick up ``` ```{tip} Graphviz diagram generation requires [Graphviz](https://graphviz.org/) (`dot` command) and the `diagrams` extra: pip install python-statemachine[diagrams] Text formats (`md`, `rst`, `mermaid`) work without any extra dependencies. ``` ```{seealso} See [](diagram.md) for all formats, highlighting active states, auto-expanding docstrings, Jupyter integration, Sphinx directive, and the `quickchart_write_svg` alternative that doesn't require Graphviz. ``` ## Scaling up with statecharts So far our coffee order has been a flat sequence of states. Real systems are rarely that simple — what happens when preparing a drink involves multiple steps? What if the order includes both a drink *and* a snack prepared in parallel? This is where python-statemachine shines: you scale from a flat FSM to a full statechart using the **exact same API**. No new base class, no configuration flags — just nest your states. ### Compound states: breaking complexity into levels Preparing a drink isn't a single step. Let's model it as a compound state with sub-steps — grinding, brewing, and serving: ```py >>> from statemachine import StateChart, State >>> class CoffeeOrder(StateChart): ... pending = State(initial=True) ... ... class preparing(State.Compound): ... """Drink preparation with internal steps.""" ... grinding = State(initial=True) ... brewing = State() ... serving = State(final=True) ... ... grind = grinding.to(brewing) ... brew = brewing.to(serving) ... ... picked_up = State(final=True) ... ... start = pending.to(preparing) ... done_state_preparing = preparing.to(picked_up) >>> order = CoffeeOrder() >>> order.send("start") >>> set(order.configuration_values) == {"preparing", "grinding"} True >>> order.send("grind") >>> "brewing" in order.configuration_values True >>> order.send("brew") >>> order.picked_up.is_active True ``` Entering `preparing` activates both the compound parent and its initial child (`grinding`). When `serving` — a final child — is reached, `done.state.preparing` fires automatically and transitions to `picked_up`. Notice how nothing changed about the outer API. You still `send("start")` to begin — the compound structure is an internal detail. ### Parallel states: concurrent regions Now let's say the order includes both a drink and a snack, prepared at the same time by different stations: ```py >>> from statemachine import StateChart, State >>> class CoffeeOrder(StateChart): ... pending = State(initial=True) ... ... class preparing(State.Parallel): ... class drink(State.Compound): ... brewing = State(initial=True) ... drink_done = State(final=True) ... brew = brewing.to(drink_done) ... class snack(State.Compound): ... heating = State(initial=True) ... snack_done = State(final=True) ... heat = heating.to(snack_done) ... ... picked_up = State(final=True) ... ... start = pending.to(preparing) ... done_state_preparing = preparing.to(picked_up) >>> order = CoffeeOrder() >>> order.send("start") >>> "brewing" in order.configuration_values and "heating" in order.configuration_values True >>> order.send("brew") # drink done, snack still heating >>> "drink_done" in order.configuration_values and "heating" in order.configuration_values True >>> order.is_terminated # drink region finished, but snack hasn't False >>> order.send("heat") # both done — auto-transitions to picked_up >>> order.picked_up.is_active True >>> order.is_terminated True ``` `State.Parallel` activates all child regions at once. Each region processes events independently. The machine only transitions out when **every** region reaches a final state. ### Checking completion with `is_terminated` In a flat state machine, checking whether you've reached a specific final state is enough. But with compound and parallel states, completion depends on the structure — all regions of a parallel must finish, nested compounds must reach their own final children, and so on. The `is_terminated` property handles this for you: it returns `True` only when the entire machine has completed its work, regardless of how deeply nested the structure is. Use it instead of checking individual states. A common pattern is to consume events from a queue or stream, feeding them to the machine until it terminates: ```py >>> from collections import deque >>> order = CoffeeOrder() >>> queue = deque(["start", "brew", "heat"]) >>> while not order.is_terminated and queue: ... order.send(queue.popleft()) >>> order.is_terminated True ``` This decouples event production from consumption — the queue could come from a message broker, a file, user input, or any other source. ### History states: remember where you left off What if the barista needs to pause preparation (e.g., to handle a rush) and resume later? A history state remembers which child was active when a compound was exited: ```py >>> from statemachine import HistoryState, StateChart, State >>> class CoffeeOrder(StateChart): ... class preparing(State.Compound): ... grinding = State(initial=True) ... brewing = State() ... done = State(final=True) ... h = HistoryState() ... ... grind = grinding.to(brewing) ... brew = brewing.to(done) ... ... paused = State() ... finished = State(final=True) ... ... pause = preparing.to(paused) ... resume = paused.to(preparing.h) # ← return via history ... done_state_preparing = preparing.to(finished) >>> order = CoffeeOrder() >>> order.send("grind") # now in "brewing" >>> "brewing" in order.configuration_values True >>> order.send("pause") # leave preparing >>> order.send("resume") # history restores "brewing", not "grinding" >>> "brewing" in order.configuration_values True >>> order.send("brew") # finish preparation >>> order.finished.is_active True ``` Use `HistoryState(type="deep")` for deep history that remembers the exact leaf state across nested compounds. ### Eventless transitions: react automatically Eventless transitions fire without an explicit event — they trigger automatically when their guard condition is met: ```py >>> from statemachine import StateChart, State >>> class CoffeeOrder(StateChart): ... pending = State(initial=True) ... preparing = State() ... ready = State() ... picked_up = State(final=True) ... ... # Eventless: fires automatically when the guard is satisfied ... pending.to(preparing, cond="is_paid") ... ready.to(picked_up, cond="was_picked_up") ... ... finish = preparing.to(ready) ... ... # A no-op event to re-enter the processing loop ... check = ( ... pending.to.itself(internal=True) ... | ready.to.itself(internal=True) ... ) ... ... paid: bool = False ... picked: bool = False ... ... def is_paid(self): ... return self.paid ... def was_picked_up(self): ... return self.picked >>> order = CoffeeOrder() >>> order.paid = True >>> order.send("check") # triggers the eventless transition >>> order.preparing.is_active True >>> order.send("finish") >>> order.picked = True >>> order.send("check") >>> order.picked_up.is_active True ``` Eventless transitions are evaluated after every macrostep. Combined with guards, they let the machine react to changes in its own data without requiring the outside world to name every event. ### Error handling as events With `StateChart`, runtime exceptions in callbacks don't crash the machine — they become `error.execution` events that you can handle with regular transitions: ```py >>> from statemachine import StateChart, State >>> class CoffeeOrder(StateChart): ... preparing = State(initial=True) ... out_of_stock = State(final=True) ... ... make_drink = preparing.to(preparing, on="do_make_drink") ... error_execution = preparing.to(out_of_stock) ... ... def do_make_drink(self): ... raise RuntimeError("Out of oat milk!") ... ... def on_enter_out_of_stock(self, error=None): ... if error: ... print(f"Problem: {error}") >>> order = CoffeeOrder() >>> order.send("make_drink") Problem: Out of oat milk! >>> order.out_of_stock.is_active True ``` The exception is caught, dispatched as an internal `error.execution` event, and handled by the `error_execution` transition — no try/except needed in your application code. ```{seealso} See [](error_handling.md) for the full `error.execution` lifecycle, block-level error catching, and the cleanup/finalize pattern. ``` ### Async: same API, no changes needed Every example above works with async callbacks too. Just use `async def` and the engine switches automatically: ```py >>> import asyncio >>> from statemachine import StateChart, State >>> class CoffeeOrder(StateChart): ... pending = State(initial=True) ... preparing = State() ... ready = State(final=True) ... ... start = pending.to(preparing) ... finish = preparing.to(ready) ... ... async def on_start(self, drink: str = "coffee"): ... return f"Started making {drink}" ... ... async def on_finish(self): ... return "Drink is ready!" >>> async def main(): ... order = CoffeeOrder() ... result = await order.send("start", drink="latte") ... print(result) ... result = await order.send("finish") ... print(result) >>> asyncio.run(main()) Started making latte Drink is ready! ``` No special async base class. No configuration. The same `StateChart` class, the same `send()` method, the same naming conventions — just `async def` and `await`. ```{seealso} See [](async.md) for the sync vs. async engine selection table, initial state activation in async contexts, and concurrent event sending. ``` ## Next steps You now have a solid foundation. Here are the most useful pages to explore next: - **[States](states.md)** — final states, compound states, parallel states, history, `DoneData` - **[Transitions](transitions.md)** — self-transitions, internal transitions, cross-boundary, delayed events - **[Actions](actions.md)** — the full callback execution order, `prepare_event()` - **[Guards](guards.md)** — `unless=`, validators, boolean expressions, `In()` for cross-region checks - **[Listeners](listeners.md)** — the observer pattern in depth - **[Error handling](error_handling.md)** — `error.execution` events, block-level catching, cleanup patterns - **[Processing model](processing_model.md)** — `send()` vs `raise_()`, microstep/macrostep, run-to-completion - **[Behaviour](behaviour.md)** — `StateChart` vs `StateMachine`, behavioral flags, and migration guide - **[Django integration](integrations.md)** — auto-discovery, `MachineMixin` with Django models - **[Diagrams](diagram.md)** — CLI generation, Jupyter, SVG, DPI settings - **[API reference](api.md)** — full class and method reference ================================================ FILE: docs/validations.md ================================================ (validations)= # Validations The library validates your statechart structure at two stages: **class definition time** (when the Python class body is evaluated) and **instance creation time** (when you call the constructor). These checks catch common mistakes early — before any event is ever processed. All validation errors raise `InvalidDefinition`. ```py >>> from statemachine import StateChart, State >>> from statemachine.exceptions import InvalidDefinition ``` ## Class definition validations These checks run as soon as the class body is evaluated by the `StateMachineMetaclass`. If any check fails, the class itself is not created. ### Exactly one initial state Every statechart must have exactly one `initial` state at the root level: ```py >>> try: ... class Bad(StateChart): ... a = State(initial=True) ... b = State(initial=True) ... go = a.to(b) ... except InvalidDefinition as e: ... print(e) There should be one and only one initial state. Your currently have these: a, b ``` ### No transitions from final states Final states represent completion — outgoing transitions are not allowed: ```py >>> try: ... class Bad(StateChart): ... draft = State(initial=True) ... closed = State(final=True) ... reopen = closed.to(draft) ... close = draft.to(closed) ... except InvalidDefinition as e: ... print(e) Cannot declare transitions from final state. Invalid state(s): ['closed'] ``` (unreachable-states)= ### Unreachable states Every state must be reachable from the initial state. Isolated states indicate a wiring mistake: ```py >>> try: ... class Bad(StateChart): ... red = State(initial=True) ... green = State() ... hazard = State() ... cycle = red.to(green) | green.to(red) ... blink = hazard.to.itself() ... except InvalidDefinition as e: ... print(e) There are unreachable states. The statemachine graph should have a single component. Disconnected states: ['hazard'] ``` Disable with `validate_disconnected_states = False`. (trap-states)= ### Trap states Every non-final state must have at least one outgoing transition. A state with no way out is a "trap" — likely a forgotten transition: ```py >>> try: ... class Bad(StateChart): ... red = State(initial=True) ... green = State() ... hazard = State() ... cycle = red.to(green) | green.to(red) ... fault = red.to(hazard) | green.to(hazard) ... except InvalidDefinition as e: ... print(e) All non-final states should have at least one outgoing transition. These states have no outgoing transition: ['hazard'] ``` Disable with `validate_trap_states = False`: ```py >>> class Accepted(StateChart): ... validate_trap_states = False ... red = State(initial=True) ... green = State() ... hazard = State() ... cycle = red.to(green) | green.to(red) ... fault = red.to(hazard) | green.to(hazard) ``` ### Final state reachability When final states exist, every non-final state must have at least one path to a final state: ```py >>> try: ... class Bad(StateChart): ... draft = State(initial=True) ... abandoned = State() ... closed = State(final=True) ... produce = draft.to(abandoned) | abandoned.to(abandoned) ... close = draft.to(closed) ... except InvalidDefinition as e: ... print(e) All non-final states should have at least one path to a final state. These states have no path to a final state: ['abandoned'] ``` Disable with `validate_final_reachability = False`. ### Internal transition targets Internal transitions must target the same state (self) or a descendant — they cannot cross to external states: ```py >>> try: ... class Bad(StateChart): ... a = State(initial=True) ... b = State(final=True) ... go = a.to(b, internal=True) ... except InvalidDefinition as e: ... assert "Not a valid internal transition" in str(e) ``` ### Initial transitions have no conditions Initial transitions (automatically generated for the initial state) cannot carry conditions or events — they always fire unconditionally. ### `donedata` on final states only The `donedata` parameter can only be used on states marked as `final=True`: ```py >>> try: ... class Bad(StateChart): ... a = State(initial=True, donedata="get_data") ... b = State(final=True) ... go = a.to(b) ... except InvalidDefinition as e: ... print(e) 'donedata' can only be specified on final states. ``` ### Invalid listener entries Entries in the `listeners` class attribute must be classes, callables, or object instances — not primitives like strings or numbers: ```py >>> try: ... class Bad(StateChart): ... listeners = ["not_a_listener"] ... a = State(initial=True) ... b = State(final=True) ... go = a.to(b) ... except InvalidDefinition as e: ... assert "Invalid entry in 'listeners'" in str(e) ``` ## Instance creation validations These checks run when you instantiate a statechart (call `MyChart()`). They verify that the runtime wiring is correct — callbacks resolve to actual methods, boolean expressions parse, etc. ### Callback resolution Every callback name declared on a transition or state (via `on`, `before`, `after`, `enter`, `exit`, `cond`, etc.) must resolve to an actual attribute on the statechart, model, or one of the registered listeners. ```py >>> class MyChart(StateChart): ... a = State(initial=True) ... b = State(final=True) ... go = a.to(b, on="nonexistent_method") >>> try: ... MyChart() ... except InvalidDefinition as e: ... assert "Did not found name 'nonexistent_method'" in str(e) ``` This validation ensures there are no typos in callback names. It checks all sources in order: the statechart class itself, then the model (if provided), then each listener. ```{note} Convention-based callbacks (like `on_enter_` or `before_`) are **not** validated — they are optional by design. Only explicitly declared callback names (passed as strings to `on`, `cond`, etc.) are checked. ``` ### Boolean expression parsing Guard conditions written as boolean expressions must be syntactically valid: ```py >>> try: ... class MyChart(StateChart): ... a = State(initial=True) ... b = State(final=True) ... go = a.to(b, cond="valid_a and valid_b") ... def valid_a(self): ... return True ... def valid_b(self): ... return True ... sm = MyChart() ... sm.send("go") ... except InvalidDefinition: ... pass # would fail if expression didn't parse >>> "b" in sm.configuration_values True ``` Expressions support `and`, `or`, `not`, and parentheses. See {ref}`guards` for the full syntax. ## Summary | Validation | When | Configurable | |-----------------------------------|-----------------|----------------------------| | Exactly one initial state | Class definition| No | | No transitions from final states | Class definition| No | | Unreachable states | Class definition| `validate_disconnected_states` | | Trap states | Class definition| `validate_trap_states` | | Final state reachability | Class definition| `validate_final_reachability` | | Internal transition targets | Class definition| No | | Initial transitions have no cond | Class definition| No | | `donedata` on final states only | Class definition| No | | Invalid listener entries | Class definition| No | | Callback resolution | Instance creation | No | | Boolean expression parsing | Instance creation | No | All configurable flags default to `True`. Set them to `False` on the class to disable the corresponding check. ================================================ FILE: docs/weighted_transitions.md ================================================ (weighted-transitions)= # Weighted transitions ```{seealso} See {ref}`conditions` for how the engine selects transitions, and {ref}`actions` for callbacks that run during transitions. ``` The `weighted_transitions` utility lets you define **probabilistic transitions** — where each transition from a state has a relative weight that determines how likely it is to be selected when the event fires. This is a contrib module that works entirely through the existing {ref}`conditions` system. No engine modifications are needed. ## Basic usage Import `weighted_transitions` and pass a **source state** followed by `(target, weight)` tuples. The result is a regular {ref}`TransitionList` that you assign to a class attribute as an event: ```py >>> from statemachine.contrib.weighted import to, weighted_transitions >>> class GameCharacter(StateChart): ... standing = State(initial=True) ... shift_weight = State() ... adjust_hair = State() ... bang_shield = State() ... ... idle = weighted_transitions( ... standing, ... (shift_weight, 70), ... (adjust_hair, 20), ... (bang_shield, 10), ... seed=42, ... ) ... ... finish = ( ... shift_weight.to(standing) ... | adjust_hair.to(standing) ... | bang_shield.to(standing) ... ) >>> sm = GameCharacter() >>> sm.send("idle") >>> any( ... s in sm.configuration_values ... for s in ("shift_weight", "adjust_hair", "bang_shield") ... ) True ``` When `idle` fires, the engine randomly selects one of the three transitions based on their relative weights: 70% chance for `shift_weight`, 20% for `adjust_hair`, 10% for `bang_shield`. ## Weights Weights can be any **positive number** — integers, floats, or a mix of both. They are relative, not absolute percentages: ```python # These are equivalent (same 70/20/10 ratio): idle = weighted_transitions( standing, (shift_weight, 70), (adjust_hair, 20), (bang_shield, 10), ) idle = weighted_transitions( standing, (shift_weight, 7), (adjust_hair, 2), (bang_shield, 1), ) idle = weighted_transitions( standing, (shift_weight, 0.7), (adjust_hair, 0.2), (bang_shield, 0.1), ) ``` The tuple format `(target, weight)` follows the standard Python pattern used by {py:func}`random.choices`. ## Reproducibility with `seed` Pass a `seed` parameter for deterministic, reproducible sequences — useful for testing: ```python go = weighted_transitions( s1, (s2, 50), (s3, 50), seed=42, # same seed always produces the same sequence ) ``` ```{note} The seed initializes a per-group `random.Random` instance that is shared across all instances of the same state machine class. This means the sequence is deterministic for a given program execution, but different instances advance the same RNG. ``` ## Per-transition options Use the {func}`~statemachine.contrib.weighted.to` helper to pass transition keyword arguments (``cond``, ``unless``, ``before``, ``on``, ``after``, …) as natural kwargs. For simple destinations without extra options, a plain ``(target, weight)`` tuple is enough — ``to()`` is only needed when you want to customize the transition: ```py >>> class GuardedWeighted(StateChart): ... idle = State(initial=True) ... walk = State() ... run = State() ... ... move = weighted_transitions( ... idle, ... (walk, 70), ... to(run, 30, cond="has_energy"), ... ) ... stop = walk.to(idle) | run.to(idle) ... ... has_energy = True >>> sm = GuardedWeighted() ``` ```{important} **No fallback when a guard fails.** If the weighted selection picks a transition whose guard evaluates to ``False``, the event fails — the engine does **not** silently fall back to another transition. This preserves the probability semantics: a 70/30 split means exactly that, not "70/30 unless the 30% is blocked, in which case always 100% for the other". This behavior follows {ref}`conditions` evaluation: the first transition whose **all** conditions pass is executed. ``` ## Combining with callbacks All standard {ref}`actions` work with weighted events — `before`, `on`, `after` callbacks and naming conventions like `on_()`: ```python class WithCallbacks(StateChart): s1 = State(initial=True) s2 = State() s3 = State() go = weighted_transitions(s1, (s2, 60), (s3, 40)) back = s2.to(s1) | s3.to(s1) def on_go(self): print("go event fired!") def after_go(self): print("after go!") ``` ## Multiple independent groups Each call to `weighted_transitions()` creates an independent weighted group with its own RNG. You can have multiple weighted events on the same state machine: ```python class MultiGroup(StateChart): idle = State(initial=True) walk = State() run = State() wave = State() bow = State() move = weighted_transitions(idle, (walk, 70), (run, 30), seed=1) greet = weighted_transitions(idle, (wave, 80), (bow, 20), seed=2) back = walk.to(idle) | run.to(idle) | wave.to(idle) | bow.to(idle) ``` The `move` and `greet` events use separate RNGs and don't interfere with each other. ## Validation `weighted_transitions()` validates inputs at class definition time: - The first argument must be a `State` (the source). - Each destination must be a `(target_state, weight)` or `(target_state, weight, kwargs_dict)` tuple. - Weights must be positive numbers (`int` or `float`). - At least one destination is required. ```py >>> weighted_transitions(State(initial=True)) Traceback (most recent call last): ... ValueError: weighted_transitions() requires at least one (target, weight) destination >>> s1, s2 = State(initial=True), State() >>> weighted_transitions(s1, (s2, -5)) Traceback (most recent call last): ... ValueError: Destination 0: weight must be positive, got -5 >>> weighted_transitions(s1, (s2, "ten")) Traceback (most recent call last): ... TypeError: Destination 0: weight must be a positive number, got str ``` ## How it works Under the hood, `weighted_transitions()`: 1. Creates a `_WeightedGroup` holding the weights and a `random.Random` instance. 2. Calls `source.to(target, **kwargs)` for each destination, creating standard transitions. 3. Attaches a lightweight condition callable to each transition's `cond` list. 4. When the event fires, the engine evaluates conditions in order. The first condition to run rolls the dice (using `random.choices`) and caches the result. Subsequent conditions check against the cache. 5. Only the selected transition's condition returns `True` — the engine picks it. This means weighted transitions are fully compatible with all engine features: {ref}`actions`, {ref}`conditions`, {ref}`listeners`, async engines, and {ref}`diagram generation `. ================================================ FILE: pyproject.toml ================================================ [project] name = "python-statemachine" version = "3.1.0" description = "Python Finite State Machines made easy." authors = [{ name = "Fernando Macedo", email = "fgmacedo@gmail.com" }] maintainers = [{ name = "Fernando Macedo", email = "fgmacedo@gmail.com" }] license = { text = "MIT License" } readme = "README.md" classifiers = [ "Development Status :: 5 - Production/Stable", "Framework :: AsyncIO", "Framework :: Django", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Natural Language :: English", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.9", "Topic :: Home Automation", "Topic :: Software Development :: Libraries", ] requires-python = ">=3.9" [project.urls] homepage = "https://github.com/fgmacedo/python-statemachine" [project.optional-dependencies] diagrams = ["pydot >= 2.0.0"] [dependency-groups] dev = [ "ruff >=0.15.0", "pre-commit", "mypy", "pytest", "pytest-cov >=6.0.0; python_version >='3.9'", "pytest-cov; python_version <'3.9'", "pytest-sugar >=1.0.0", "pytest-mock >=3.14.0", "pytest-benchmark >=4.0.0", "pytest-asyncio >=0.25.0", "pydot", "django >=5.2.11; python_version >='3.10'", "pytest-django >=4.8.0; python_version >'3.8'", "Sphinx; python_version >'3.8'", "sphinx-gallery; python_version >'3.8'", "myst-parser; python_version >'3.8'", "pillow; python_version >'3.8'", "sphinx-autobuild; python_version >'3.8'", "furo >=2024.5.6; python_version >'3.8'", "sphinx-copybutton >=0.5.2; python_version >'3.8'", "sphinxcontrib-mermaid; python_version >'3.8'", "pdbr>=0.8.9; python_version >'3.8'", "babel >=2.16.0; python_version >='3.8'", "pytest-xdist>=3.6.1", "pytest-timeout>=2.3.1", "pyright>=1.1.400", ] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["statemachine/"] [tool.pytest.ini_options] addopts = [ "-s", "--ignore=docs/conf.py", "--ignore=docs/auto_examples/", "--ignore=docs/_build/", "--ignore=tests/examples/", "--doctest-glob=*.md", "--doctest-modules", "--doctest-continue-on-failure", "--benchmark-autosave", "--benchmark-group-by=name", "--pdbcls=pdbr:RichPdb", ] doctest_optionflags = "ELLIPSIS IGNORE_EXCEPTION_DETAIL NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL" asyncio_mode = "auto" markers = [ """slow: marks tests as slow (deselect with '-m "not slow"')""", """scxml: marks a tests as scxml (deselect with '-m "not scxml"')""", ] python_files = ["tests.py", "test_*.py", "*_tests.py"] xfail_strict = true # Log level WARNING by default; the engine caches a no-op for logger.debug at # init time, so DEBUG here would bypass that optimization and slow benchmarks. # To enable DEBUG logging for a specific test run: # uv run pytest -o log_cli_level=DEBUG log_cli_level = "WARNING" log_cli_format = "%(relativeCreated)6.0fms %(threadName)-18s %(name)-35s %(message)s" log_cli_date_format = "%H:%M:%S" asyncio_default_fixture_loop_scope = "module" filterwarnings = ["ignore::pytest_benchmark.logger.PytestBenchmarkWarning"] [tool.coverage.run] branch = true # dynamic_context = "test_function" relative_files = true data_file = ".coverage" source = ["statemachine"] omit = ["*test*.py", "tmp/*", "pytest_cov"] [tool.coverage.report] show_missing = true exclude_lines = [ # Have to re-enable the standard pragma "pragma: no cover", # Don't complain about missing debug-only code: "def __repr__", "if self.debug", # Don't complain if tests don't hit defensive assertion code: "raise AssertionError", "raise NotImplementedError", "if TYPE_CHECKING", 'if __name__ == "__main__"', ] [tool.coverage.html] directory = "tmp/htmlcov" show_contexts = true [tool.mypy] python_version = "3.14" warn_return_any = true warn_unused_configs = true disable_error_code = "annotation-unchecked" mypy_path = "$MYPY_CONFIG_FILE_DIR/tests/django_project" [[tool.mypy.overrides]] module = [ 'django.*', 'pytest.*', 'pydot.*', 'sphinx_gallery.*', 'docutils.*', 'sphinx.*', ] ignore_missing_imports = true [tool.ruff] src = ["statemachine"] line-length = 99 target-version = "py39" # Exclude a variety of commonly ignored directories. exclude = [ ".direnv", ".eggs", ".git", ".mypy_cache", ".nox", ".pants.d", ".ruff_cache", ".tox", ".venv", "__pypackages__", "_build", "buck-out", "build", "dist", "node_modules", "auto_examples", "venv", ] [tool.ruff.lint] # Enable Pyflakes and pycodestyle rules. select = [ "E", # pycodestyle errors "W", # pycodestyle warnings "F", # pyflakes "I", # isort "UP", # pyupgrade "C", # flake8-comprehensions "B", # flake8-bugbear "PT", # flake8-pytest-style ] ignore = [ "UP006", # `use-pep585-annotation` Requires Python3.9+ "UP035", # `use-pep585-annotation` Requires Python3.9+ "UP037", # `remove-quotes-from-type-annotation` Not safe without `from __future__ import annotations` "UP042", # `use-str-enum` Requires Python3.11+ ] # Allow unused variables when underscore-prefixed. dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" [tool.ruff.lint.per-file-ignores] # Ignore `E402` (import violations) in all `__init__.py` files, and in `path/to/file.py`. "__init__.py" = ["E402"] "path/to/file.py" = ["E402"] "tests/examples/**.py" = ["B018"] [tool.ruff.lint.mccabe] max-complexity = 10 [tool.ruff.lint.isort] force-single-line = true [tool.ruff.lint.pydocstyle] # Use Google-style docstrings. convention = "google" [tool.ruff.lint.flake8-pytest-style] fixture-parentheses = true mark-parentheses = true [tool.pyright] pythonVersion = "3.9" typeCheckingMode = "basic" include = ["statemachine"] ================================================ FILE: statemachine/__init__.py ================================================ from .event import Event from .state import HistoryState from .state import HistoryType from .state import State from .statemachine import StateChart from .statemachine import StateMachine from .statemachine import TModel __author__ = """Fernando Macedo""" __email__ = "fgmacedo@gmail.com" __version__ = "3.1.0" __all__ = [ "StateChart", "StateMachine", "State", "HistoryState", "HistoryType", "Event", "TModel", ] ================================================ FILE: statemachine/callbacks.py ================================================ import asyncio from bisect import insort from collections import defaultdict from collections import deque from enum import IntEnum from enum import IntFlag from enum import auto from functools import partial from inspect import isawaitable from typing import TYPE_CHECKING from typing import Callable from typing import Dict from typing import List from .exceptions import AttrNotFound from .i18n import _ from .utils import ensure_iterable if TYPE_CHECKING: from typing import Set def allways_true(*args, **kwargs): return True class CallbackPriority(IntEnum): GENERIC = 0 INLINE = 10 DECORATOR = 20 NAMING = 30 AFTER = 40 class SpecReference(IntFlag): NAME = auto() CALLABLE = auto() PROPERTY = auto() SPECS_ALL = SpecReference.NAME | SpecReference.CALLABLE | SpecReference.PROPERTY SPECS_SAFE = SpecReference.NAME class CallbackGroup(IntEnum): PREPARE = auto() ENTER = auto() EXIT = auto() INVOKE = auto() VALIDATOR = auto() BEFORE = auto() ON = auto() AFTER = auto() COND = auto() def build_key(self, specs: "CallbackSpecList") -> str: return f"{self.name}@{id(specs)}" class CallbackSpec: """Specs about callbacks. At first, `func` can be a name (string), a property or a callable. Names, properties and unbounded callables should be resolved to a callable before any real call is performed. """ names_not_found: "Set[str] | None" = None """List of names that were not found on the model or statemachine""" def __init__( self, func, group: CallbackGroup, is_convention=False, is_event: bool = False, cond=None, priority: CallbackPriority = CallbackPriority.NAMING, expected_value=None, ): self.func = func self.group = group self.is_convention = is_convention self.is_event = is_event self.cond = cond self.expected_value = expected_value self.priority = priority if isinstance(func, property): self.reference = SpecReference.PROPERTY self.attr_name: str = func and func.fget and func.fget.__name__ or "" elif callable(func): self.reference = SpecReference.CALLABLE is_partial = isinstance(func, partial) self.is_bounded = is_partial or hasattr(func, "__self__") name = func.func.__name__ if is_partial else func.__name__ self.attr_name = name if not self.is_event or self.is_bounded else f"_{name}_" if not self.is_bounded: func.attr_name = self.attr_name # type: ignore[union-attr] func.is_event = is_event # type: ignore[union-attr] else: self.reference = SpecReference.NAME self.attr_name = func self.may_contain_boolean_expression = ( not self.is_convention and self.group == CallbackGroup.COND and self.reference == SpecReference.NAME ) def __repr__(self): return f"{type(self).__name__}({self.func!r}, is_convention={self.is_convention!r})" def __str__(self): name = self.attr_name if self.expected_value is False: name = f"!{name}" return name def __eq__(self, other): return self.func == other.func and self.group == other.group def __hash__(self): return id(self) class SpecListGrouper: def __init__(self, list: "CallbackSpecList", group: CallbackGroup) -> None: self.list = list self.group = group self.key = group.build_key(list) def add(self, callbacks, **kwargs): self.list.add(callbacks, group=self.group, **kwargs) return self def __call__(self, callback): return self.list._add_unbounded_callback(callback, group=self.group) def _add_unbounded_callback(self, func, is_event=False, transitions=None, **kwargs): self.list._add_unbounded_callback( func, is_event=is_event, transitions=transitions, group=self.group, **kwargs, ) def __iter__(self): return (item for item in self.list if item.group == self.group) class CallbackSpecList: """List of {ref}`CallbackSpec` instances""" def __init__(self, factory=CallbackSpec): self.items: List[CallbackSpec] = [] self.conventional_specs = set() self._groupers: Dict[CallbackGroup, SpecListGrouper] = {} self.factory = factory def __repr__(self): return f"{type(self).__name__}({self.items!r}, factory={self.factory!r})" def _add_unbounded_callback(self, func, transitions=None, **kwargs): """This list was a target for adding a func using decorator `@[.on|before|after|enter|exit]` syntax. If we assign ``func`` directly as callable on the ``items`` list, this will result in an `unbounded method error`, with `func` expecting a parameter ``self`` not defined. The implemented solution is to resolve the collision giving the func a reference method. To update It's callback when the name is resolved on the :func:`StateMachineMetaclass.add_from_attributes`. If the ``func`` is bounded It will be used directly, if not, it's ref will be replaced by the given attr name and on `statemachine._setup()` the dynamic name will be resolved properly. Args: func (callable): The decorated method to add on the transitions occurs. is_event (bool): If the func is also an event, we'll create a trigger and link the event name to the transitions. transitions (TransitionList): If ``is_event``, the transitions to be attached to the event. """ self._add(func, **kwargs) func._transitions = transitions return func def __iter__(self): return iter(self.items) def clear(self): self.items = [] def grouper(self, group: CallbackGroup) -> SpecListGrouper: if group not in self._groupers: self._groupers[group] = SpecListGrouper(self, group) return self._groupers[group] def _add(self, func, group: CallbackGroup, **kwargs): if isinstance(func, CallbackSpec): spec = func else: spec = self.factory(func, group, **kwargs) if spec in self.items: return self.items.append(spec) if spec.is_convention: self.conventional_specs.add(spec.func) return spec def add(self, callbacks, group: CallbackGroup, **kwargs): if callbacks is None: return self unprepared = ensure_iterable(callbacks) for func in unprepared: self._add(func, group=group, **kwargs) return self class CallbackWrapper: def __init__( self, callback: Callable, condition: Callable, meta: "CallbackSpec", unique_key: str, ) -> None: self._callback = callback self._iscoro = getattr(callback, "is_coroutine", False) self.condition = condition self.meta = meta self.unique_key = unique_key self.expected_value = self.meta.expected_value def __repr__(self): return f"{type(self).__name__}({self.unique_key})" def __str__(self): return str(self.meta) def __lt__(self, other): return self.meta.priority < other.meta.priority async def __call__(self, *args, **kwargs): value = self._callback(*args, **kwargs) if isawaitable(value): value = await value if self.expected_value is not None: return bool(value) == self.expected_value return value def call(self, *args, **kwargs): value = self._callback(*args, **kwargs) if self.expected_value is not None: return bool(value) == self.expected_value return value class CallbacksExecutor: """A list of callbacks that can be executed in order.""" def __init__(self): self.items: "deque[CallbackWrapper]" = deque() self.items_already_seen = set() def __iter__(self): return iter(self.items) def __repr__(self): return f"{type(self).__name__}({self.items!r})" def __str__(self): return ", ".join(str(c) for c in self) def add(self, key: str, spec: CallbackSpec, builder: Callable[[], Callable]): if key in self.items_already_seen: return self.items_already_seen.add(key) condition = spec.cond if spec.cond is not None else allways_true wrapper = CallbackWrapper( callback=builder(), condition=condition, meta=spec, unique_key=key, ) insort(self.items, wrapper) async def async_call( self, *args, on_error: "Callable[[Exception], None] | None" = None, **kwargs ): if on_error is None: return await asyncio.gather( *( callback(*args, **kwargs) for callback in self if callback.condition(*args, **kwargs) ) ) results = [] for callback in self: if callback.condition(*args, **kwargs): # pragma: no branch try: results.append(await callback(*args, **kwargs)) except Exception as e: on_error(e) return results async def async_all( self, *args, on_error: "Callable[[Exception], None] | None" = None, **kwargs ): for callback in self: try: if not await callback(*args, **kwargs): return False except Exception as e: if on_error is not None: on_error(e) return False raise return True def call(self, *args, on_error: "Callable[[Exception], None] | None" = None, **kwargs): if on_error is None: return [ callback.call(*args, **kwargs) for callback in self if callback.condition(*args, **kwargs) ] results = [] for callback in self: if callback.condition(*args, **kwargs): # pragma: no branch try: results.append(callback.call(*args, **kwargs)) except Exception as e: on_error(e) return results def all(self, *args, on_error: "Callable[[Exception], None] | None" = None, **kwargs): for condition in self: try: if not condition.call(*args, **kwargs): return False except Exception as e: if on_error is not None: on_error(e) return False raise return True def visit(self, visitor_fn, *args, **kwargs): """Like call() but delegates execution to visitor_fn for each matching callback.""" for callback in self: if callback.condition(*args, **kwargs): visitor_fn(callback, *args, **kwargs) async def async_visit(self, visitor_fn, *args, **kwargs): """Async variant of visit().""" for callback in self: if callback.condition(*args, **kwargs): result = visitor_fn(callback, *args, **kwargs) if isawaitable(result): await result class CallbacksRegistry: def __init__(self) -> None: self._registry: Dict[str, CallbacksExecutor] = defaultdict(CallbacksExecutor) self.has_async_callbacks: bool = False def __getitem__(self, key: str) -> CallbacksExecutor: return self._registry[key] def __contains__(self, key: str) -> bool: return key in self._registry def check(self, specs: CallbackSpecList): for meta in specs: if meta.is_convention: continue if any( callback for callback in self[meta.group.build_key(specs)] if callback.meta == meta ): continue if meta.names_not_found: raise AttrNotFound( _("Did not found name '{}' from model or statemachine").format( ", ".join(meta.names_not_found) ), ) raise AttrNotFound( _("Did not found name '{}' from model or statemachine").format(meta.func) ) def async_or_sync(self): self.has_async_callbacks = any( callback._iscoro for executor in self._registry.values() for callback in executor ) def call( self, key: str, *args, on_error: "Callable[[Exception], None] | None" = None, **kwargs, ): if key not in self._registry: return [] return self._registry[key].call(*args, on_error=on_error, **kwargs) async def async_call( self, key: str, *args, on_error: "Callable[[Exception], None] | None" = None, **kwargs, ): if key not in self._registry: return [] return await self._registry[key].async_call(*args, on_error=on_error, **kwargs) def all( self, key: str, *args, on_error: "Callable[[Exception], None] | None" = None, **kwargs, ): if key not in self._registry: return True return self._registry[key].all(*args, on_error=on_error, **kwargs) async def async_all( self, key: str, *args, on_error: "Callable[[Exception], None] | None" = None, **kwargs, ): if key not in self._registry: return True return await self._registry[key].async_all(*args, on_error=on_error, **kwargs) def visit(self, key: str, visitor_fn, *args, **kwargs): if key not in self._registry: return self._registry[key].visit(visitor_fn, *args, **kwargs) async def async_visit(self, key: str, visitor_fn, *args, **kwargs): if key not in self._registry: return await self._registry[key].async_visit(visitor_fn, *args, **kwargs) def str(self, key: str) -> str: if key not in self._registry: return "" return str(self._registry[key]) ================================================ FILE: statemachine/configuration.py ================================================ from typing import TYPE_CHECKING from typing import Any from typing import Dict from typing import Mapping from typing import MutableSet from .exceptions import InvalidStateValue from .i18n import _ from .orderedset import OrderedSet _SENTINEL = object() if TYPE_CHECKING: from .state import State class Configuration: """Encapsulates the dual representation of the active state configuration. Internally, ``current_state_value`` is either a scalar (single active state) or an ``OrderedSet`` (parallel regions). This class hides that detail behind a uniform interface for reading, mutating, and caching the resolved ``OrderedSet[State]``. """ __slots__ = ( "_instance_states", "_model", "_state_field", "_states_map", "_cached", "_cached_value", ) def __init__( self, instance_states: "Mapping[str, State]", model: Any, state_field: str, states_map: "Dict[Any, State]", ): self._instance_states = instance_states self._model = model self._state_field = state_field self._states_map = states_map self._cached: "OrderedSet[State] | None" = None self._cached_value: Any = _SENTINEL # -- Raw value (persisted on the model) ------------------------------------ @property def value(self) -> Any: """The raw state value stored on the model (scalar or ``OrderedSet``).""" return getattr(self._model, self._state_field, None) @value.setter def value(self, val: Any): if val is None: self._write_to_model(OrderedSet()) elif isinstance(val, MutableSet): self._write_to_model(OrderedSet(val) if not isinstance(val, OrderedSet) else val) else: self._write_to_model(OrderedSet([val])) @property def values(self) -> OrderedSet[Any]: """The set of raw state values currently active.""" return self._read_from_model() # -- Resolved states ------------------------------------------------------- @property def states(self) -> "OrderedSet[State]": """The set of currently active :class:`State` instances (cached).""" raw = self.value if self._cached is not None and self._cached_value is raw: return self._cached if raw is None: return OrderedSet() # Normalize inline (avoid second getattr via _read_from_model) values = raw if isinstance(raw, MutableSet) else (raw,) result = OrderedSet(self._instance_states[self._states_map[v].id] for v in values) self._cached = result self._cached_value = raw return result @states.setter def states(self, new_configuration: "OrderedSet[State]"): self._write_to_model(OrderedSet(s.value for s in new_configuration)) # -- Incremental mutation (used by the engine) ----------------------------- def add(self, state: "State"): """Add *state* to the configuration.""" values = self._read_from_model() values.add(state.value) self._write_to_model(values) def discard(self, state: "State"): """Remove *state* from the configuration.""" values = self._read_from_model() values.discard(state.value) self._write_to_model(values) # -- Deprecated v2 compat -------------------------------------------------- @property def current_state(self) -> "State | OrderedSet[State]": """Resolve the current state with validation. Unlike ``states`` (which returns an empty set for ``None``), this raises ``InvalidStateValue`` when the value is ``None`` or not found in ``states_map`` — matching the v2 ``current_state`` contract. """ csv = self.value if csv is None: raise InvalidStateValue( csv, _( "There's no current state set. In async code, " "did you activate the initial state? " "(e.g., `await sm.activate_initial_state()`)" ), ) try: config = self.states if len(config) == 1: return next(iter(config)) return config except KeyError as err: raise InvalidStateValue(csv) from err # -- Internal: model boundary ---------------------------------------------- def _read_from_model(self) -> OrderedSet: """Normalize: model value → always ``OrderedSet``.""" raw = self.value if raw is None: return OrderedSet() if isinstance(raw, OrderedSet): return raw if isinstance(raw, MutableSet): return OrderedSet(raw) return OrderedSet([raw]) def _write_to_model(self, values: OrderedSet): """Denormalize: ``OrderedSet`` → ``None | scalar | OrderedSet`` for model.""" self._invalidate() if len(values) == 0: raw = None elif len(values) == 1: raw = next(iter(values)) else: raw = values if raw is not None and not isinstance(raw, MutableSet) and raw not in self._states_map: raise InvalidStateValue(raw) setattr(self._model, self._state_field, raw) def _invalidate(self): self._cached = None self._cached_value = _SENTINEL ================================================ FILE: statemachine/contrib/__init__.py ================================================ ================================================ FILE: statemachine/contrib/diagram/__init__.py ================================================ import importlib from urllib.parse import quote from urllib.request import urlopen from .extract import extract from .formatter import formatter as formatter from .renderers.dot import DotRenderer from .renderers.dot import DotRendererConfig from .renderers.mermaid import MermaidRenderer from .renderers.mermaid import MermaidRendererConfig class DotGraphMachine: """Backwards-compatible facade that uses the extract + render pipeline. Maintains the same public API and class-level customization attributes as the original monolithic DotGraphMachine. """ graph_rankdir = "LR" """ Direction of the graph. Defaults to "LR" (option "TB" for top bottom) http://www.graphviz.org/doc/info/attrs.html#d:rankdir """ font_name = "Helvetica" """Graph font face name""" state_font_size = "10" """State font size""" state_active_penwidth = 2 """Active state external line width""" state_active_fillcolor = "turquoise" transition_font_size = "9" """Transition font size""" def __init__(self, machine): self.machine = machine def _build_config(self) -> DotRendererConfig: return DotRendererConfig( graph_rankdir=self.graph_rankdir, font_name=self.font_name, state_font_size=self.state_font_size, state_active_penwidth=self.state_active_penwidth, state_active_fillcolor=self.state_active_fillcolor, transition_font_size=self.transition_font_size, ) def get_graph(self): ir = extract(self.machine) renderer = DotRenderer(config=self._build_config()) return renderer.render(ir) def __call__(self): return self.get_graph() class MermaidGraphMachine: """Facade for generating Mermaid stateDiagram-v2 source from a state machine.""" direction = "LR" active_fill = "#40E0D0" active_stroke = "#333" def __init__(self, machine): self.machine = machine def _build_config(self) -> MermaidRendererConfig: return MermaidRendererConfig( direction=self.direction, active_fill=self.active_fill, active_stroke=self.active_stroke, ) def get_mermaid(self) -> str: ir = extract(self.machine) renderer = MermaidRenderer(config=self._build_config()) return renderer.render(ir) def __call__(self) -> str: return self.get_mermaid() def quickchart_write_svg(sm, path: str): """ If the default dependency of GraphViz installed locally doesn't work for you. As an option, you can generate the image online from the output of the `dot` language, using one of the many services available. To get the **dot** representation of your state machine is as easy as follows: >>> from tests.examples.order_control_machine import OrderControl >>> sm = OrderControl() >>> print(sm._graph().to_string()) # doctest: +ELLIPSIS digraph OrderControl { ... } To give you an example, we included this method that will serialize the dot, request the graph to https://quickchart.io, and persist the result locally as an ``.svg`` file. .. warning:: Quickchart is an external graph service that supports many formats to generate diagrams. By using this method, you should trust http://quickchart.io. Please read https://quickchart.io/documentation/faq/ for more information. >>> quickchart_write_svg(sm, "docs/images/oc_machine_processing.svg") # doctest: +SKIP """ dot_representation = sm._graph().to_string() url = f"https://quickchart.io/graphviz?graph={quote(dot_representation)}" response = urlopen(url) data = response.read() with open(path, "wb") as f: f.write(data) def _find_sm_class(module): """Find the first StateChart subclass defined in a module.""" import inspect from statemachine.statemachine import StateChart for _name, obj in inspect.getmembers(module, inspect.isclass): if ( issubclass(obj, StateChart) and obj is not StateChart and obj.__module__ == module.__name__ ): return obj return None def import_sm(qualname): from statemachine.statemachine import StateChart module_name, class_name = qualname.rsplit(".", 1) module = importlib.import_module(module_name) smclass = getattr(module, class_name, None) if smclass is not None and isinstance(smclass, type) and issubclass(smclass, StateChart): return smclass # qualname may be a module path without a class name — try importing # the whole path as a module and find the first StateChart subclass. try: module = importlib.import_module(qualname) except ImportError as err: raise ValueError(f"{class_name} is not a subclass of StateMachine") from err smclass = _find_sm_class(module) if smclass is None: raise ValueError(f"No StateMachine subclass found in module {qualname!r}") return smclass def write_image(qualname, out, events=None, fmt=None): """ Given a `qualname`, that is the fully qualified dotted path to a StateMachine classes, imports the class and generates a dot graph using the `pydot` lib. Writes the graph representation to the filename 'out' that will open/create and truncate such file and write on it a representation of the graph defined by the statemachine, in the format specified by the extension contained in the out path (out.ext). If `events` is provided, the machine is instantiated and each event is sent before rendering, so the diagram highlights the current active state. If `fmt` is provided, it overrides the output format (any registered text format such as ``"mermaid"``, ``"dot"``, ``"md"``, ``"rst"``). Use ``out="-"`` to write to stdout. """ import sys smclass = import_sm(qualname) if events: machine = smclass() for event_name in events: machine.send(event_name) else: machine = smclass if fmt is not None: text = formatter.render(machine, fmt) if out == "-": sys.stdout.write(text) else: with open(out, "w") as f: f.write(text) else: graph = DotGraphMachine(machine).get_graph() if out == "-": sys.stdout.buffer.write(graph.create_svg()) # type: ignore[attr-defined] else: out_extension = out.rsplit(".", 1)[1] graph.write(out, format=out_extension) def main(argv=None): import argparse parser = argparse.ArgumentParser( usage="%(prog)s [OPTION] ", description="Generate diagrams for StateMachine classes.", ) parser.add_argument( "class_path", help="A fully-qualified dotted path to the StateMachine class." ) parser.add_argument( "out", help="File to generate the image using extension as the output format.", ) parser.add_argument( "--events", nargs="+", help="Instantiate the machine and send these events before rendering.", ) parser.add_argument( "--format", choices=formatter.supported_formats(), default=None, help="Output as text format instead of Graphviz image.", ) args = parser.parse_args(argv) write_image(qualname=args.class_path, out=args.out, events=args.events, fmt=args.format) ================================================ FILE: statemachine/contrib/diagram/__main__.py ================================================ import sys from . import main if __name__ == "__main__": sys.exit(main()) ================================================ FILE: statemachine/contrib/diagram/extract.py ================================================ from typing import TYPE_CHECKING from typing import List from typing import Set from typing import Union from .model import ActionType from .model import DiagramAction from .model import DiagramGraph from .model import DiagramState from .model import DiagramTransition from .model import StateType if TYPE_CHECKING: from statemachine.state import State from statemachine.statemachine import StateChart from statemachine.transition import Transition # A StateChart class or instance — both expose the same structural metadata. MachineRef = Union["StateChart", "type[StateChart]"] def _determine_state_type(state: "State") -> StateType: from statemachine.state import HistoryState from statemachine.state import HistoryType if isinstance(state, HistoryState): if state.type == HistoryType.DEEP: return StateType.HISTORY_DEEP return StateType.HISTORY_SHALLOW if getattr(state, "parallel", False): return StateType.PARALLEL if state.final: return StateType.FINAL return StateType.REGULAR def _actions_getter(machine: "MachineRef"): from statemachine.statemachine import StateChart if isinstance(machine, StateChart): def getter(grouper): # pyright: ignore[reportRedeclaration] return machine._callbacks.str(grouper.key) else: def getter(grouper): all_names = set(dir(machine)) return ", ".join(str(c) for c in grouper if not c.is_convention or c.func in all_names) return getter def _extract_state_actions(state: "State", getter) -> List[DiagramAction]: actions: List[DiagramAction] = [] entry = str(getter(state.enter)) exit_ = str(getter(state.exit)) if entry: actions.append(DiagramAction(type=ActionType.ENTRY, body=entry)) if exit_: actions.append(DiagramAction(type=ActionType.EXIT, body=exit_)) for transition in state.transitions: if transition.internal: on_text = str(getter(transition.on)) if on_text: actions.append( DiagramAction(type=ActionType.INTERNAL, body=f"{transition.event} / {on_text}") ) return actions def _extract_state( state: "State", machine: "MachineRef", getter, active_values: set, ) -> DiagramState: state_type = _determine_state_type(state) is_active = state.value in active_values is_parallel_area = bool(state.parent and getattr(state.parent, "parallel", False)) children: List[DiagramState] = [] for substate in state.states: children.append(_extract_state(substate, machine, getter, active_values)) for history_state in getattr(state, "history", []): children.append(_extract_state(history_state, machine, getter, active_values)) actions = _extract_state_actions(state, getter) return DiagramState( id=state.id, name=state.name, type=state_type, actions=actions, children=children, is_active=is_active, is_parallel_area=is_parallel_area, is_initial=getattr(state, "initial", False), ) def _format_event_names(transition: "Transition") -> str: """Build a display string for the events that trigger a transition. ``_expand_event_id`` registers both the Python attribute name (``done_invoke_X``) and the SCXML dot form (``done.invoke.X``) under the same transition. For diagram display we only want unique *semantic* events, keeping the Python attribute name when an alias pair exists. """ events = list(transition.events) if not events: return "" all_ids = {str(e) for e in events} seen_ids: Set[str] = set() display: List[str] = [] for event in events: eid = str(event) # Skip dot-form aliases (e.g. "done.invoke.X") when the underscore # form ("done_invoke_X") is also registered on this transition. if "." in eid and eid.replace(".", "_") in all_ids: continue if eid not in seen_ids: # pragma: no branch seen_ids.add(eid) display.append(event.name if event.name else eid) return " ".join(display) def _extract_transitions_from_state(state: "State") -> List[DiagramTransition]: """Extract transitions from a single state (non-recursive).""" result: List[DiagramTransition] = [] for transition in state.transitions: targets = transition.targets if transition.targets else [] target_ids = [t.id for t in targets] cond_strs = [str(c) for c in transition.cond] result.append( DiagramTransition( source=transition.source.id, targets=target_ids, event=_format_event_names(transition), guards=cond_strs, is_internal=transition.internal, ) ) return result def _extract_all_transitions(states) -> List[DiagramTransition]: """Recursively extract transitions from all states.""" result: List[DiagramTransition] = [] for state in states: result.extend(_extract_transitions_from_state(state)) if state.states: result.extend(_extract_all_transitions(state.states)) for history_state in getattr(state, "history", []): result.extend(_extract_transitions_from_state(history_state)) if history_state.states: # pragma: no cover result.extend(_extract_all_transitions(history_state.states)) return result def _collect_compound_ids(states: List[DiagramState]) -> Set[str]: """Collect IDs of states that have children (compound/parallel).""" result: Set[str] = set() for state in states: if state.children: result.add(state.id) result.update(_collect_compound_ids(state.children)) return result def _collect_bidirectional_compound_ids( transitions: List[DiagramTransition], compound_ids: Set[str], ) -> Set[str]: """Find compound states that have both outgoing and incoming explicit edges.""" outgoing: Set[str] = set() incoming: Set[str] = set() for t in transitions: if t.is_internal: continue # Skip implicit initial transitions if t.source in compound_ids and not t.event and t.targets: continue if t.source in compound_ids: outgoing.add(t.source) for target_id in t.targets: if target_id in compound_ids: incoming.add(target_id) return outgoing & incoming def _mark_initial_transitions( transitions: List[DiagramTransition], compound_ids: Set[str], ) -> None: """Mark implicit initial transitions (compound state → child, no event).""" for t in transitions: if t.source in compound_ids and not t.event and t.targets and not t.is_internal: t.is_initial = True def _resolve_initial_states(states: List[DiagramState]) -> None: """Ensure exactly one state per level has is_initial=True. Skips parallel areas and history states. Falls back to document order (first non-history, non-parallel-area state) when no explicit initial exists. Recurses into children. Parallel areas (children of a parallel state) have their is_initial flag cleared: all regions are auto-activated, so no initial arrow is needed. """ # Clear is_initial on parallel areas — all children of a parallel state # are simultaneously active; initial arrows would be misleading. for s in states: if s.is_parallel_area: s.is_initial = False candidates = [ s for s in states if s.type not in (StateType.HISTORY_SHALLOW, StateType.HISTORY_DEEP) and not s.is_parallel_area ] has_explicit_initial = any(s.is_initial for s in candidates) if not has_explicit_initial and candidates: candidates[0].is_initial = True for state in states: if state.children: _resolve_initial_states(state.children) def extract(machine_or_class: "MachineRef") -> DiagramGraph: """Extract a DiagramGraph IR from a state machine instance or class. Accepts either a class or an instance. The class is **never** instantiated — all structural metadata (states, transitions, name) is available on the class itself thanks to the metaclass. Active-state highlighting is only produced when an *instance* is passed. Args: machine_or_class: A StateMachine/StateChart instance or class. Returns: A DiagramGraph representing the machine's structure. """ from statemachine.statemachine import StateChart if isinstance(machine_or_class, StateChart): machine: "MachineRef" = machine_or_class elif isinstance(machine_or_class, type) and issubclass(machine_or_class, StateChart): machine = machine_or_class else: raise TypeError(f"Expected a StateChart instance or class, got {type(machine_or_class)}") getter = _actions_getter(machine) active_values: set = set() if isinstance(machine, StateChart) and hasattr(machine, "configuration_values"): active_values = set(machine.configuration_values) states: List[DiagramState] = [] for state in machine.states: states.append(_extract_state(state, machine, getter, active_values)) transitions = _extract_all_transitions(machine.states) compound_ids = _collect_compound_ids(states) bidir_ids = _collect_bidirectional_compound_ids(transitions, compound_ids) _mark_initial_transitions(transitions, compound_ids) _resolve_initial_states(states) return DiagramGraph( name=machine.name, states=states, transitions=transitions, compound_state_ids=compound_ids, bidirectional_compound_ids=bidir_ids, ) ================================================ FILE: statemachine/contrib/diagram/formatter.py ================================================ """Unified facade for rendering state machines in multiple text formats. The :class:`Formatter` class provides a decorator-based registry where each renderer declares the format names it handles. Adding a new format only requires writing a renderer function and decorating it — no changes to ``__format__``, ``factory.py``, or ``statemachine.py``. A module-level :data:`formatter` instance is the single public entry point:: from statemachine.contrib.diagram import formatter print(formatter.render(sm, "mermaid")) @formatter.register_format("plantuml") def _render_plantuml(machine): ... """ from typing import TYPE_CHECKING from typing import Callable from typing import Dict from typing import List if TYPE_CHECKING: from typing import Union from statemachine.statemachine import StateChart MachineRef = Union["StateChart", "type[StateChart]"] class Formatter: """Unified facade for rendering state machines in multiple text formats.""" def __init__(self) -> None: self._formats: Dict[str, "Callable[[MachineRef], str]"] = {} def register_format( self, *names: str ) -> "Callable[[Callable[[MachineRef], str]], Callable[[MachineRef], str]]": """Decorator factory that registers a renderer under one or more format names. Usage:: @formatter.register_format("md", "markdown") def _render_md(machine_or_class): ... """ def decorator( fn: "Callable[[MachineRef], str]", ) -> "Callable[[MachineRef], str]": for name in names: self._formats[name] = fn return fn return decorator def render(self, machine_or_class: "MachineRef", fmt: str) -> str: """Render a state machine in the given text format. Args: machine_or_class: A ``StateChart`` instance or class. fmt: Format name (e.g., ``"mermaid"``, ``"dot"``, ``"md"``). Empty string falls back to ``repr()``. Raises: ValueError: If ``fmt`` is not registered. """ if fmt == "": return repr(machine_or_class) renderer_fn = self._formats.get(fmt) if renderer_fn is None: primary = sorted({self._primary_name(fn) for fn in set(self._formats.values())}) raise ValueError( f"Unsupported format: {fmt!r}. Use {', '.join(repr(n) for n in primary)}." ) return renderer_fn(machine_or_class) def supported_formats(self) -> List[str]: """Return sorted list of all registered format names (including aliases).""" return sorted(self._formats) def _primary_name(self, fn: "Callable[[MachineRef], str]") -> str: """Return the first registered name for a given renderer function.""" for name, registered_fn in self._formats.items(): if registered_fn is fn: return name return "?" # pragma: no cover formatter = Formatter() """Module-level :class:`Formatter` instance — the single public entry point.""" # --------------------------------------------------------------------------- # Built-in format registrations # --------------------------------------------------------------------------- @formatter.register_format("dot") def _render_dot(machine_or_class: "MachineRef") -> str: from statemachine.contrib.diagram import DotGraphMachine return DotGraphMachine(machine_or_class).get_graph().to_string() # type: ignore[no-any-return] @formatter.register_format("svg") def _render_svg(machine_or_class: "MachineRef") -> str: from statemachine.contrib.diagram import DotGraphMachine svg_bytes: bytes = DotGraphMachine(machine_or_class).get_graph().create_svg() # type: ignore[attr-defined] return svg_bytes.decode("utf-8") @formatter.register_format("mermaid") def _render_mermaid(machine_or_class: "MachineRef") -> str: from statemachine.contrib.diagram import MermaidGraphMachine return MermaidGraphMachine(machine_or_class).get_mermaid() @formatter.register_format("md", "markdown") def _render_md(machine_or_class: "MachineRef") -> str: from statemachine.contrib.diagram.extract import extract from statemachine.contrib.diagram.renderers.table import TransitionTableRenderer return TransitionTableRenderer().render(extract(machine_or_class), fmt="md") @formatter.register_format("rst") def _render_rst(machine_or_class: "MachineRef") -> str: from statemachine.contrib.diagram.extract import extract from statemachine.contrib.diagram.renderers.table import TransitionTableRenderer return TransitionTableRenderer().render(extract(machine_or_class), fmt="rst") ================================================ FILE: statemachine/contrib/diagram/model.py ================================================ from dataclasses import dataclass from dataclasses import field from enum import Enum from typing import List from typing import Set class StateType(Enum): INITIAL = "initial" REGULAR = "regular" FINAL = "final" HISTORY_SHALLOW = "history_shallow" HISTORY_DEEP = "history_deep" CHOICE = "choice" FORK = "fork" JOIN = "join" JUNCTION = "junction" PARALLEL = "parallel" TERMINATE = "terminate" class ActionType(Enum): ENTRY = "entry" EXIT = "exit" INTERNAL = "internal" @dataclass class DiagramAction: type: ActionType body: str @dataclass class DiagramState: id: str name: str type: StateType actions: List[DiagramAction] = field(default_factory=list) children: List["DiagramState"] = field(default_factory=list) is_active: bool = False is_parallel_area: bool = False is_initial: bool = False @dataclass class DiagramTransition: source: str targets: List[str] = field(default_factory=list) event: str = "" guards: List[str] = field(default_factory=list) actions: List[str] = field(default_factory=list) is_internal: bool = False is_initial: bool = False @dataclass class DiagramGraph: name: str states: List[DiagramState] = field(default_factory=list) transitions: List[DiagramTransition] = field(default_factory=list) compound_state_ids: Set[str] = field(default_factory=set) bidirectional_compound_ids: Set[str] = field(default_factory=set) ================================================ FILE: statemachine/contrib/diagram/renderers/__init__.py ================================================ ================================================ FILE: statemachine/contrib/diagram/renderers/dot.py ================================================ from dataclasses import dataclass from dataclasses import field from typing import Dict from typing import List from typing import Optional from typing import Set import pydot from ..model import ActionType from ..model import DiagramAction from ..model import DiagramGraph from ..model import DiagramState from ..model import DiagramTransition from ..model import StateType def _escape_html(text: str) -> str: """Escape text for use inside HTML labels.""" return text.replace("&", "&").replace("<", "<").replace(">", ">") @dataclass class DotRendererConfig: """Configuration for the DOT renderer, matching DotGraphMachine's class attributes.""" graph_rankdir: str = "LR" font_name: str = "Helvetica" state_font_size: str = "12" state_active_penwidth: int = 2 state_active_fillcolor: str = "turquoise" transition_font_size: str = "10" graph_attrs: Dict[str, str] = field(default_factory=dict) node_attrs: Dict[str, str] = field(default_factory=dict) edge_attrs: Dict[str, str] = field(default_factory=dict) class DotRenderer: """Renders a DiagramGraph into a pydot.Dot graph with UML-inspired styling. Uses techniques inspired by state-machine-cat for cleaner visual output: - HTML TABLE labels for states with UML compartments - plaintext nodes with near-transparent fill - Refined graph/node/edge defaults """ def __init__(self, config: Optional[DotRendererConfig] = None): self.config = config or DotRendererConfig() self._compound_ids: Set[str] = set() self._compound_bidir_ids: Set[str] = set() def render(self, graph: DiagramGraph) -> pydot.Dot: """Render a DiagramGraph to a pydot.Dot object.""" self._compound_ids = graph.compound_state_ids self._compound_bidir_ids = graph.bidirectional_compound_ids dot = self._create_graph(graph.name) self._render_states(graph.states, graph.transitions, dot) return dot def _create_graph(self, name: str) -> pydot.Dot: cfg = self.config graph_attrs = { "fontname": cfg.font_name, "fontsize": cfg.state_font_size, "penwidth": "2.0", "splines": "true", "ordering": "out", "compound": "true", "nodesep": "0.3", "ranksep": "0.3", "forcelabels": "true", } graph_attrs.update(cfg.graph_attrs) dot = pydot.Dot( name, graph_type="digraph", label=name, rankdir=cfg.graph_rankdir, **graph_attrs, ) # Set default node attributes node_defaults = { "fontname": cfg.font_name, "fontsize": cfg.state_font_size, "penwidth": "2.0", } node_defaults.update(cfg.node_attrs) dot.set_node_defaults(**node_defaults) # Set default edge attributes edge_defaults = { "fontname": cfg.font_name, "fontsize": cfg.transition_font_size, "labeldistance": "1.5", } edge_defaults.update(cfg.edge_attrs) dot.set_edge_defaults(**edge_defaults) return dot def _state_node_id(self, state_id: str) -> str: """Get the node ID to use for edges. Compound states use an anchor node.""" if state_id in self._compound_ids: return f"{state_id}_anchor" return state_id def _compound_edge_anchor(self, state_id: str, direction: str) -> str: """Return the appropriate anchor node ID for a compound ↔ other edge. Compound states that have both incoming and outgoing explicit transitions get separate ``_anchor_out`` / ``_anchor_in`` nodes so Graphviz can route the two directions through physically distinct points, avoiding overlap. """ if state_id in self._compound_bidir_ids: return f"{state_id}_anchor_{direction}" return f"{state_id}_anchor" def _render_states( self, states: List[DiagramState], transitions: List[DiagramTransition], parent_graph: "pydot.Dot | pydot.Subgraph", extra_nodes: Optional[List[pydot.Node]] = None, ) -> None: """Render states and transitions into the parent graph.""" initial_state = next((s for s in states if s.is_initial), None) # The atomic subgraph groups all non-compound states and the inner # initial dot (when inside a compound cluster) so Graphviz places them # in the same rank region, keeping the initial arrow short. atomic_subgraph = pydot.Subgraph( graph_name=f"cluster___atomic_{id(parent_graph)}", label="", peripheries=0, margin=0, cluster="true", ) has_atomic = False if initial_state: has_atomic = ( self._render_initial_arrow(initial_state, parent_graph, atomic_subgraph) or has_atomic ) for state in states: if state.type in (StateType.HISTORY_SHALLOW, StateType.HISTORY_DEEP): atomic_subgraph.add_node(self._create_history_node(state)) has_atomic = True elif state.children: subgraph = self._create_compound_subgraph(state) anchor_nodes = self._create_compound_anchor_nodes(state) self._render_states( state.children, transitions, subgraph, extra_nodes=anchor_nodes ) parent_graph.add_subgraph(subgraph) # Add transitions originating from this compound state self._add_transitions_for_state(state, transitions, parent_graph) else: atomic_subgraph.add_node(self._create_atomic_node(state)) has_atomic = True has_atomic = self._place_extra_nodes( extra_nodes, atomic_subgraph, parent_graph, has_atomic ) if has_atomic: parent_graph.add_subgraph(atomic_subgraph) # Add transitions for atomic/history states for state in states: if not state.children: self._add_transitions_for_state(state, transitions, parent_graph) @staticmethod def _place_extra_nodes( extra_nodes: Optional[List[pydot.Node]], atomic_subgraph: pydot.Subgraph, parent_graph: "pydot.Dot | pydot.Subgraph", has_atomic: bool, ) -> bool: """Place anchor nodes from the parent compound into the graph. Co-locates them with real states when possible. If there are no atomic states at this level (e.g. a parallel state with only compound children), adds them directly to the parent graph to avoid an empty cluster. Returns the updated ``has_atomic`` flag. """ if not extra_nodes: return has_atomic target = atomic_subgraph if has_atomic else parent_graph for node in extra_nodes: target.add_node(node) return has_atomic or (target is atomic_subgraph) def _render_initial_arrow( self, initial_state: DiagramState, parent_graph: "pydot.Dot | pydot.Subgraph", atomic_subgraph: pydot.Subgraph, ) -> bool: """Render the black-dot initial arrow pointing to ``initial_state``. Returns True if nodes were added to ``atomic_subgraph``. """ initial_node_id = f"__initial_{id(parent_graph)}" initial_node = self._create_initial_node(initial_node_id) added_to_atomic = False extra = {} if initial_state.children: extra["lhead"] = f"cluster_{initial_state.id}" if initial_state.children or isinstance(parent_graph, pydot.Dot): # Compound initial state, or top-level atomic initial state: # keep the dot in a plain wrapper subgraph attached to parent. wrapper = pydot.Subgraph( graph_name=f"{initial_node_id}_sg", label="", peripheries=0, margin=0, ) wrapper.add_node(initial_node) parent_graph.add_subgraph(wrapper) else: # Inner (compound parent) with atomic initial state: add the # dot directly into the atomic cluster so it shares the same # rank region as the target state, avoiding a long arrow caused # by the compound cluster's anchor nodes pushing step1 further. atomic_subgraph.add_node(initial_node) added_to_atomic = True parent_graph.add_edge( pydot.Edge( initial_node_id, self._state_node_id(initial_state.id), label="", minlen=1, weight=100, **extra, ) ) return added_to_atomic def _create_initial_node(self, node_id: str) -> pydot.Node: return pydot.Node( node_id, label="", shape="circle", style="filled", fillcolor="black", color="black", fixedsize="true", width=0.15, height=0.15, penwidth="0", ) def _create_atomic_node(self, state: DiagramState) -> pydot.Node: """Create a node for an atomic state. All states use a native ``shape="rectangle"`` with ``style="rounded, filled"`` so that Graphviz clips edges at the actual rounded border. States with entry/exit actions embed an HTML TABLE (``border="0"``) inside the native shape to render UML-style compartments (name + separator + actions). """ actions = [a for a in state.actions if a.type != ActionType.INTERNAL or a.body] fillcolor = self.config.state_active_fillcolor if state.is_active else "white" penwidth = self.config.state_active_penwidth if state.is_active else 2 if not actions: # Simple state: native rounded rectangle node = pydot.Node( state.id, label=state.name, shape="rectangle", style="rounded, filled", fontname=self.config.font_name, fontsize=self.config.state_font_size, fillcolor=fillcolor, penwidth=penwidth, peripheries=2 if state.type == StateType.FINAL else 1, ) else: # State with actions: native shape + HTML TABLE label (border=0). # The native shape handles edge clipping; the TABLE provides # UML compartment layout with
separator. label = self._build_html_table_label(state, actions) node = pydot.Node( state.id, label=f"<{label}>", shape="rectangle", style="rounded, filled", fontname=self.config.font_name, fontsize=self.config.state_font_size, fillcolor=fillcolor, penwidth=penwidth, margin="0", peripheries=2 if state.type == StateType.FINAL else 1, ) return node def _build_html_table_label( self, state: DiagramState, actions: List[DiagramAction], ) -> str: """Build an HTML TABLE label with UML compartments (name | actions). The TABLE has ``border="0"`` because the visible border is drawn by the native Graphviz shape, ensuring edges are clipped correctly. """ name = _escape_html(state.name) font_size = self.config.state_font_size action_font_size = self.config.transition_font_size action_lines = "
".join( f'{_escape_html(self._format_action(a))}' for a in actions ) return ( f'' f'" f"
" f'" f"
' f'{name}' f"
' f"{action_lines}" f"
" ) @staticmethod def _format_action(action: DiagramAction) -> str: if action.type == ActionType.INTERNAL: return action.body return f"{action.type.value} / {action.body}" def _create_history_node(self, state: DiagramState) -> pydot.Node: label = "H*" if state.type == StateType.HISTORY_DEEP else "H" return pydot.Node( state.id, label=label, shape="circle", style="filled", fillcolor="white", fontname=self.config.font_name, fontsize="8pt", fixedsize="true", width=0.3, height=0.3, ) def _create_compound_anchor_nodes(self, state: DiagramState) -> List[pydot.Node]: """Create invisible anchor nodes for edge routing inside a compound cluster. These nodes are injected into the children's atomic_subgraph so they share the same layout row as the real states, avoiding blank space at the top of the compound cluster. """ # For bidirectional compounds, all edges route through _anchor_in/_anchor_out; # the generic _anchor node is never used and would become an orphan that # Graphviz places arbitrarily, creating blank vertical space in the cluster. if state.id not in self._compound_bidir_ids: nodes = [ pydot.Node( f"{state.id}_anchor", shape="point", style="invis", width=0, height=0, fixedsize="true", ) ] else: nodes = [] for direction in ("in", "out"): nodes.append( pydot.Node( f"{state.id}_anchor_{direction}", shape="point", style="invis", width=0, height=0, fixedsize="true", ) ) return nodes def _create_compound_subgraph(self, state: DiagramState) -> pydot.Subgraph: """Create a cluster subgraph for a compound/parallel state.""" style = "rounded, solid" if state.is_parallel_area: style = "rounded, dashed" label = self._build_compound_label(state) return pydot.Subgraph( graph_name=f"cluster_{state.id}", label=f"<{label}>", style=style, cluster="true", penwidth="2.0", fontname=self.config.font_name, fontsize=self.config.state_font_size, margin="4", ) def _build_compound_label(self, state: DiagramState) -> str: """Build HTML label for a compound/parallel subgraph.""" name = _escape_html(state.name) if state.type == StateType.PARALLEL: return f"{name} ☷" actions = [a for a in state.actions if a.type != ActionType.INTERNAL or a.body] if not actions: return f"{name}" rows = [f"{name}"] for action in actions: action_text = _escape_html(self._format_action(action)) rows.append( f'{action_text}' ) return "
".join(rows) def _add_transitions_for_state( self, state: DiagramState, all_transitions: List[DiagramTransition], graph: "pydot.Dot | pydot.Subgraph", ) -> None: """Add edges for all non-internal transitions originating from this state.""" for transition in all_transitions: if transition.source != state.id or transition.is_internal: continue # Skip implicit initial transitions — represented by the black-dot initial node. if transition.is_initial: continue for edge in self._create_edges(transition): graph.add_edge(edge) def _create_edges(self, transition: DiagramTransition) -> List[pydot.Edge]: """Create pydot.Edge objects for a transition.""" target_ids: List[Optional[str]] = ( list(transition.targets) if transition.targets else [None] ) cond = ", ".join(transition.guards) cond_html = f"
[{_escape_html(cond)}]" if cond else "" return [ self._create_single_edge(transition, target_id, i, cond_html) for i, target_id in enumerate(target_ids) ] def _create_single_edge( self, transition: DiagramTransition, target_id: Optional[str], index: int, cond_html: str, ) -> pydot.Edge: """Create a single pydot.Edge for one target of a transition.""" src, dst, extra = self._resolve_edge_endpoints(transition, target_id) has_substates = bool(extra) html_label = self._build_edge_label(transition.event, cond_html, index) return pydot.Edge( src, dst, label=html_label, minlen=2 if has_substates else 1, **extra, ) def _resolve_edge_endpoints( self, transition: DiagramTransition, target_id: Optional[str], ) -> "tuple[str, str, Dict[str, str]]": """Resolve source/destination node IDs and cluster attributes for an edge.""" extra: Dict[str, str] = {} source_is_compound = transition.source in self._compound_ids target_is_compound = target_id is not None and target_id in self._compound_ids if source_is_compound: extra["ltail"] = f"cluster_{transition.source}" if target_is_compound: extra["lhead"] = f"cluster_{target_id}" dst = ( self._state_node_id(target_id) if target_id is not None else self._state_node_id(transition.source) ) src = self._state_node_id(transition.source) # For compound states in bidirectional pairs, route outgoing edges # through _anchor_out and incoming through _anchor_in so Graphviz # places them at different physical positions inside the cluster. if source_is_compound and transition.source in self._compound_bidir_ids: src = self._compound_edge_anchor(transition.source, "out") extra["ltail"] = f"cluster_{transition.source}" if target_is_compound and target_id in self._compound_bidir_ids: dst = self._compound_edge_anchor(target_id, "in") extra["lhead"] = f"cluster_{target_id}" return src, dst, extra def _build_edge_label(self, event: str, cond_html: str, index: int) -> str: """Build the HTML label for a transition edge.""" event_text = _escape_html(event) if index == 0 else "" if not event_text and not (cond_html and index == 0): return "" label_content = f"{event_text}{cond_html}" if index == 0 else "" font_size = self.config.transition_font_size return ( f'<' f'" f'' f"
' f'{label_content}' f"
>" ) ================================================ FILE: statemachine/contrib/diagram/renderers/mermaid.py ================================================ from dataclasses import dataclass from typing import Dict from typing import List from typing import Optional from typing import Set from ..model import ActionType from ..model import DiagramAction from ..model import DiagramGraph from ..model import DiagramState from ..model import DiagramTransition from ..model import StateType @dataclass class MermaidRendererConfig: """Configuration for the Mermaid renderer.""" direction: str = "LR" active_fill: str = "#40E0D0" active_stroke: str = "#333" class MermaidRenderer: """Renders a DiagramGraph into a Mermaid stateDiagram-v2 source string. Mermaid's stateDiagram-v2 has a rendering bug (`mermaid-js/mermaid#4052 `_) where transitions whose source or target is a compound state (``state X { ... }``) **inside a parallel region** crash with ``Cannot set properties of undefined (setting 'rank')``. To work around this, the renderer rewrites compound-state endpoints that are descendants of a parallel state, redirecting them to the compound's initial child. Compound states outside parallel regions are left unchanged. """ def __init__(self, config: Optional[MermaidRendererConfig] = None): self.config = config or MermaidRendererConfig() self._active_ids: List[str] = [] self._rendered_transitions: Set[tuple] = set() self._compound_ids: Set[str] = set() self._initial_child_map: Dict[str, str] = {} self._parallel_descendant_ids: Set[str] = set() self._all_descendants_map: Dict[str, Set[str]] = {} def render(self, graph: DiagramGraph) -> str: """Render a DiagramGraph to a Mermaid stateDiagram-v2 string.""" self._active_ids = [] self._rendered_transitions = set() self._compound_ids = graph.compound_state_ids self._initial_child_map = self._build_initial_child_map(graph.states) self._parallel_descendant_ids = self._collect_parallel_descendants(graph.states) self._all_descendants_map = self._build_all_descendants_map(graph.states) lines: List[str] = [] lines.append("stateDiagram-v2") lines.append(f" direction {self.config.direction}") top_ids = {s.id for s in graph.states} self._render_states(graph.states, graph.transitions, lines, indent=1) self._render_initial_and_final(graph.states, lines, indent=1) self._render_scope_transitions(graph.transitions, top_ids, lines, indent=1) if self._active_ids: cfg = self.config lines.append("") lines.append(f" classDef active fill:{cfg.active_fill},stroke:{cfg.active_stroke}") for sid in self._active_ids: lines.append(f" {sid}:::active") return "\n".join(lines) + "\n" def _build_initial_child_map(self, states: List[DiagramState]) -> Dict[str, str]: """Build a map from compound state ID to its initial child ID (recursive).""" result: Dict[str, str] = {} for state in states: if state.children: initial = next((c for c in state.children if c.is_initial), None) if initial: result[state.id] = initial.id result.update(self._build_initial_child_map(state.children)) return result @staticmethod def _collect_parallel_descendants( states: List[DiagramState], inside_parallel: bool = False, ) -> Set[str]: """Collect IDs of all states that are descendants of a parallel state.""" result: Set[str] = set() for state in states: if inside_parallel: result.add(state.id) child_inside = inside_parallel or state.type == StateType.PARALLEL result.update( MermaidRenderer._collect_parallel_descendants(state.children, child_inside) ) return result def _build_all_descendants_map(self, states: List[DiagramState]) -> Dict[str, Set[str]]: """Map each compound state ID to the set of all its descendant IDs.""" result: Dict[str, Set[str]] = {} for state in states: if state.children: result[state.id] = self._collect_recursive_descendants(state.children) result.update(self._build_all_descendants_map(state.children)) return result @staticmethod def _collect_recursive_descendants(states: List[DiagramState]) -> Set[str]: """Collect all state IDs in a subtree recursively.""" ids: Set[str] = set() for s in states: ids.add(s.id) ids.update(MermaidRenderer._collect_recursive_descendants(s.children)) return ids def _resolve_endpoint(self, state_id: str) -> str: """Resolve a transition endpoint for Mermaid compatibility. Only redirects compound states that are inside a parallel region — this is where Mermaid's rendering bug (mermaid-js/mermaid#4052) occurs. Compound states outside parallel regions are left unchanged. """ if ( state_id in self._compound_ids and state_id in self._parallel_descendant_ids and state_id in self._initial_child_map ): return self._initial_child_map[state_id] return state_id def _render_states( self, states: List[DiagramState], transitions: List[DiagramTransition], lines: List[str], indent: int, ) -> None: for state in states: if state.type in (StateType.HISTORY_SHALLOW, StateType.HISTORY_DEEP): label = "H*" if state.type == StateType.HISTORY_DEEP else "H" pad = " " * indent lines.append(f'{pad}state "{label}" as {state.id}') continue if state.type == StateType.CHOICE: pad = " " * indent lines.append(f"{pad}state {state.id} <>") continue if state.type == StateType.FORK: pad = " " * indent lines.append(f"{pad}state {state.id} <>") continue if state.type == StateType.JOIN: pad = " " * indent lines.append(f"{pad}state {state.id} <>") continue if state.children: self._render_compound_state(state, transitions, lines, indent) else: self._render_atomic_state(state, lines, indent) def _render_atomic_state( self, state: DiagramState, lines: List[str], indent: int, ) -> None: pad = " " * indent if state.name != state.id: lines.append(f'{pad}state "{state.name}" as {state.id}') actions = [a for a in state.actions if a.type != ActionType.INTERNAL or a.body] if actions: for action in actions: lines.append(f"{pad}{state.id} : {self._format_action(action)}") if state.is_active: self._active_ids.append(state.id) def _render_compound_state( self, state: DiagramState, transitions: List[DiagramTransition], lines: List[str], indent: int, ) -> None: pad = " " * indent if state.type == StateType.PARALLEL: lines.append(f'{pad}state "{state.name}" as {state.id} {{') regions = [c for c in state.children if c.is_parallel_area or c.children] for i, region in enumerate(regions): if i > 0: lines.append(f"{pad} --") self._render_compound_state(region, transitions, lines, indent + 1) lines.append(f"{pad}}}") else: label = state.name if state.name != state.id else "" if label: lines.append(f'{pad}state "{label}" as {state.id} {{') else: lines.append(f"{pad}state {state.id} {{") initial_child = next((c for c in state.children if c.is_initial), None) if initial_child: lines.append(f"{pad} [*] --> {initial_child.id}") self._render_states(state.children, transitions, lines, indent + 1) # Render transitions scoped to this compound child_ids = self._collect_all_descendant_ids(state.children) self._render_scope_transitions(transitions, child_ids, lines, indent + 1) # Final state transitions for child in state.children: if child.type == StateType.FINAL: lines.append(f"{pad} {child.id} --> [*]") lines.append(f"{pad}}}") if state.is_active: self._active_ids.append(state.id) def _collect_all_descendant_ids(self, states: List[DiagramState]) -> Set[str]: """Collect all state IDs in a subtree (direct children only for scope).""" ids: Set[str] = set() for s in states: ids.add(s.id) return ids def _render_scope_transitions( self, transitions: List[DiagramTransition], scope_ids: Set[str], lines: List[str], indent: int, ) -> None: """Render transitions that belong to this scope level. A transition belongs to scope S if all its endpoints are *reachable* from S (either directly in S or descendants of a compound in S) **and** the transition is not fully internal to a single compound in S (those are rendered by the compound's inner scope). This allows cross-boundary transitions (e.g., an outer state targeting a history pseudo-state inside a compound) to be rendered at the correct scope level — Mermaid draws the arrow crossing the compound border. Mermaid crashes when the source or target is a compound state inside a parallel region (mermaid-js/mermaid#4052). For those cases, endpoints are redirected to the compound's initial child via ``_resolve_endpoint``. """ # Build the descendant sets for compounds in this scope compound_descendants: Dict[str, Set[str]] = {} expanded: Set[str] = set(scope_ids) for sid in scope_ids: if sid in self._all_descendants_map: compound_descendants[sid] = self._all_descendants_map[sid] expanded |= self._all_descendants_map[sid] for t in transitions: if t.is_initial or t.is_internal: continue targets = t.targets if t.targets else [t.source] # All endpoints must be reachable from this scope if t.source not in expanded: continue if not all(target in expanded for target in targets): continue # Skip transitions fully internal to a single compound — # those will be rendered by the compound's inner scope. if self._is_fully_internal(t.source, targets, compound_descendants): continue # Resolve endpoints for rendering (redirect compound → initial child) source = self._resolve_endpoint(t.source) resolved_targets = [self._resolve_endpoint(tid) for tid in targets] for target in resolved_targets: key = (source, target, t.event) if key in self._rendered_transitions: continue self._rendered_transitions.add(key) self._render_single_transition(t, source, target, lines, indent) @staticmethod def _is_fully_internal( source: str, targets: List[str], compound_descendants: Dict[str, Set[str]], ) -> bool: """Check if all endpoints belong to the same compound's descendants.""" for descendants in compound_descendants.values(): if source in descendants and all(tgt in descendants for tgt in targets): return True return False def _render_single_transition( self, transition: DiagramTransition, source: str, target: str, lines: List[str], indent: int, ) -> None: pad = " " * indent label_parts: List[str] = [] if transition.event: label_parts.append(transition.event) if transition.guards: label_parts.append(f"[{', '.join(transition.guards)}]") label = " ".join(label_parts) if label: lines.append(f"{pad}{source} --> {target} : {label}") else: lines.append(f"{pad}{source} --> {target}") @staticmethod def _format_action(action: DiagramAction) -> str: if action.type == ActionType.INTERNAL: return action.body return f"{action.type.value} / {action.body}" def _render_initial_and_final( self, states: List[DiagramState], lines: List[str], indent: int, ) -> None: """Render top-level [*] --> initial and final --> [*] arrows.""" pad = " " * indent initial = next((s for s in states if s.is_initial), None) if initial: lines.append(f"{pad}[*] --> {initial.id}") for state in states: if state.type == StateType.FINAL: lines.append(f"{pad}{state.id} --> [*]") ================================================ FILE: statemachine/contrib/diagram/renderers/table.py ================================================ from typing import List from ..model import DiagramGraph from ..model import DiagramState from ..model import DiagramTransition class TransitionTableRenderer: """Renders a DiagramGraph as a transition table in markdown or RST format.""" def render(self, graph: DiagramGraph, fmt: str = "md") -> str: """Render the transition table. Args: graph: The diagram IR to render. fmt: Output format — ``"md"`` for markdown, ``"rst"`` for reStructuredText. Returns: The formatted transition table as a string. """ rows = self._collect_rows(graph.states, graph.transitions) if fmt == "rst": return self._render_rst(rows) return self._render_md(rows) def _collect_rows( self, states: List[DiagramState], transitions: List[DiagramTransition], ) -> "List[tuple[str, str, str, str]]": """Collect (State, Event, Guard, Target) tuples from the IR.""" rows: List[tuple[str, str, str, str]] = [] state_names = self._build_state_name_map(states) for t in transitions: if t.is_initial or t.is_internal: continue source_name = state_names.get(t.source, t.source) guard = ", ".join(t.guards) if t.guards else "" event = t.event or "" if t.targets: for target_id in t.targets: target_name = state_names.get(target_id, target_id) rows.append((source_name, event, guard, target_name)) else: rows.append((source_name, event, guard, source_name)) return rows def _build_state_name_map(self, states: List[DiagramState]) -> dict: """Build a mapping from state ID to display name, recursively.""" result: dict = {} for state in states: result[state.id] = state.name if state.children: result.update(self._build_state_name_map(state.children)) return result def _render_md(self, rows: "List[tuple[str, str, str, str]]") -> str: """Render as a markdown table.""" headers = ("State", "Event", "Guard", "Target") col_widths = [len(h) for h in headers] for row in rows: for i, cell in enumerate(row): col_widths[i] = max(col_widths[i], len(cell)) def _fmt_row(cells: "tuple[str, ...]") -> str: parts = [cell.ljust(col_widths[i]) for i, cell in enumerate(cells)] return "| " + " | ".join(parts) + " |" lines = [_fmt_row(headers)] lines.append("| " + " | ".join("-" * w for w in col_widths) + " |") for row in rows: lines.append(_fmt_row(row)) return "\n".join(lines) + "\n" def _render_rst(self, rows: "List[tuple[str, str, str, str]]") -> str: """Render as an RST grid table.""" headers = ("State", "Event", "Guard", "Target") col_widths = [len(h) for h in headers] for row in rows: for i, cell in enumerate(row): col_widths[i] = max(col_widths[i], len(cell)) def _border(char: str = "-") -> str: return "+" + "+".join(char * (w + 2) for w in col_widths) + "+" def _data_row(cells: "tuple[str, ...]") -> str: parts = [f" {cell.ljust(col_widths[i])} " for i, cell in enumerate(cells)] return "|" + "|".join(parts) + "|" lines = [_border("-")] lines.append(_data_row(headers)) lines.append(_border("=")) for row in rows: lines.append(_data_row(row)) lines.append(_border("-")) return "\n".join(lines) + "\n" ================================================ FILE: statemachine/contrib/diagram/sphinx_ext.py ================================================ """Sphinx extension providing the ``statemachine-diagram`` directive. Usage in MyST Markdown:: ```{statemachine-diagram} mypackage.module.MyMachine :events: start, ship :caption: After shipping ``` The directive imports the state machine class, optionally instantiates it and sends events, then renders an SVG diagram inline in the documentation. """ from __future__ import annotations import hashlib import html as html_mod import os import re from typing import TYPE_CHECKING from typing import Any from typing import ClassVar from docutils import nodes from docutils.parsers.rst import directives from sphinx.util.docutils import SphinxDirective if TYPE_CHECKING: from sphinx.application import Sphinx def _align_spec(argument: str) -> str: return str(directives.choice(argument, ("left", "center", "right"))) def _parse_events(value: str) -> list[str]: """Parse a comma-separated list of event names.""" return [e.strip() for e in value.split(",") if e.strip()] # Match the outer ... element, stripping XML prologue/DOCTYPE. _SVG_TAG_RE = re.compile(r"()", re.DOTALL) # Match fixed width/height attributes (e.g. width="702pt" height="170pt"). _SVG_WIDTH_RE = re.compile(r'\bwidth="([^"]*(?:pt|px))"') _SVG_HEIGHT_RE = re.compile(r'\bheight="([^"]*(?:pt|px))"') class StateMachineDiagram(SphinxDirective): """Render a state machine diagram from an importable class path. Supports the same layout options as the standard ``image`` and ``figure`` directives (``width``, ``height``, ``scale``, ``align``, ``target``, ``class``, ``name``), plus state-machine-specific options (``events``, ``caption``, ``figclass``). """ has_content: ClassVar[bool] = False required_arguments: ClassVar[int] = 1 optional_arguments: ClassVar[int] = 0 option_spec: ClassVar[dict[str, Any]] = { # State-machine options "events": directives.unchanged, "format": directives.unchanged, # Standard image/figure options "caption": directives.unchanged, "alt": directives.unchanged, "width": directives.unchanged, "height": directives.unchanged, "scale": directives.unchanged, "align": _align_spec, "target": directives.unchanged, "class": directives.class_option, "name": directives.unchanged, "figclass": directives.class_option, } def run(self) -> list[nodes.Node]: qualname = self.arguments[0] try: from statemachine.contrib.diagram import formatter from statemachine.contrib.diagram import import_sm sm_class = import_sm(qualname) except (ImportError, ValueError) as exc: return [ self.state_machine.reporter.warning( f"statemachine-diagram: could not import {qualname!r}: {exc}", line=self.lineno, ) ] if "events" in self.options: machine = sm_class() for event_name in _parse_events(self.options["events"]): machine.send(event_name) else: machine = sm_class output_format = self.options.get("format", "").strip().lower() if output_format == "mermaid": return self._run_mermaid(machine, formatter, qualname) try: svg_text = formatter.render(machine, "svg") except Exception as exc: return [ self.state_machine.reporter.warning( f"statemachine-diagram: failed to generate diagram for {qualname!r}: {exc}", line=self.lineno, ) ] svg_tag, intrinsic_width, intrinsic_height = self._prepare_svg(svg_text) svg_styles = self._build_svg_styles(intrinsic_width, intrinsic_height) svg_tag = svg_tag.replace("{svg_tag}' if target: img_html = f'{img_html}' wrapper_classes = self._build_wrapper_classes() class_attr = f' class="{" ".join(wrapper_classes)}"' if "caption" in self.options: caption = html_mod.escape(self.options["caption"]) figclass = self.options.get("figclass", []) if figclass: class_attr = f' class="{" ".join(wrapper_classes + figclass)}"' html = ( f"\n" f" {img_html}\n" f"
{caption}
\n" f"" ) else: html = f"{img_html}" raw_node = nodes.raw("", html, format="html") if "name" in self.options: self.add_name(raw_node) return [raw_node] def _run_mermaid(self, machine: object, formatter: Any, qualname: str) -> list[nodes.Node]: """Render a Mermaid diagram using sphinxcontrib-mermaid's node type.""" try: mermaid_src = formatter.render(machine, "mermaid") except Exception as exc: return [ self.state_machine.reporter.warning( f"statemachine-diagram: failed to generate mermaid for {qualname!r}: {exc}", line=self.lineno, ) ] try: from sphinxcontrib.mermaid import ( # type: ignore[import-untyped] mermaid as MermaidNode, ) except ImportError: # Fallback: emit a raw code block if sphinxcontrib-mermaid is not installed code_node = nodes.literal_block(mermaid_src, mermaid_src) code_node["language"] = "mermaid" return [code_node] node = MermaidNode() node["code"] = mermaid_src node["options"] = {} caption = self.options.get("caption") if caption: figure_node = nodes.figure() figure_node += node figure_node += nodes.caption(caption, caption) if "name" in self.options: self.add_name(figure_node) return [figure_node] if "name" in self.options: self.add_name(node) return [node] def _prepare_svg(self, svg_text: str) -> tuple[str, str, str]: """Extract the ```` element and its intrinsic dimensions.""" match = _SVG_TAG_RE.search(svg_text) svg_tag = match.group(1) if match else svg_text width_match = _SVG_WIDTH_RE.search(svg_tag) height_match = _SVG_HEIGHT_RE.search(svg_tag) intrinsic_width = width_match.group(1) if width_match else "" intrinsic_height = height_match.group(1) if height_match else "" # Remove fixed dimensions — sizing is controlled via inline styles. svg_tag = _SVG_WIDTH_RE.sub("", svg_tag) svg_tag = _SVG_HEIGHT_RE.sub("", svg_tag) return svg_tag, intrinsic_width, intrinsic_height def _build_svg_styles(self, intrinsic_width: str, intrinsic_height: str) -> str: """Build an inline ``style`` attribute for the ```` element.""" parts: list[str] = [] # Width: explicit > scaled intrinsic > intrinsic as max-width. user_width = self.options.get("width", "") scale = self.options.get("scale", "") if user_width: parts.append(f"width: {user_width}") elif scale and intrinsic_width: factor = int(scale.rstrip("%")) / 100 value, unit = _split_length(intrinsic_width) parts.append(f"width: {value * factor:.1f}{unit}") elif intrinsic_width: parts.append(f"max-width: {intrinsic_width}") # Height: explicit > scaled intrinsic > auto. user_height = self.options.get("height", "") if user_height: parts.append(f"height: {user_height}") elif scale and intrinsic_height: factor = int(scale.rstrip("%")) / 100 value, unit = _split_length(intrinsic_height) parts.append(f"height: {value * factor:.1f}{unit}") else: parts.append("height: auto") return f'style="{"; ".join(parts)}"' def _resolve_target(self, svg_text: str) -> str: """Return the href for the wrapper ```` tag, if any. When ``:target:`` is given without a value (or as empty string), the raw SVG is written to ``_images/`` and linked so the user can open the full diagram in a new browser tab for zooming. """ if "target" not in self.options: return "" target = (self.options["target"] or "").strip() if target: return target # Auto-generate a standalone SVG file for zoom. qualname = self.arguments[0] events_key = self.options.get("events", "") identity = f"{qualname}:{events_key}" digest = hashlib.sha1(identity.encode()).hexdigest()[:8] filename = f"statemachine-{digest}.svg" outdir = os.path.join(self.env.app.outdir, "_images") os.makedirs(outdir, exist_ok=True) outpath = os.path.join(outdir, filename) with open(outpath, "w", encoding="utf-8") as f: f.write(svg_text) return f"/_images/{filename}" def _build_wrapper_classes(self) -> list[str]: """Build CSS class list for the outer wrapper element.""" css_classes: list[str] = self.options.get("class", []) align = self.options.get("align", "center") return ["statemachine-diagram", f"align-{align}"] + css_classes def _split_length(value: str) -> tuple[float, str]: """Split a CSS length like ``'702pt'`` into ``(702.0, 'pt')``.""" match = re.match(r"([0-9.]+)(.*)", value) if match: return float(match.group(1)), match.group(2) return 0.0, value def setup(app: "Sphinx") -> dict[str, Any]: app.add_directive("statemachine-diagram", StateMachineDiagram) return {"version": "0.1", "parallel_read_safe": True, "parallel_write_safe": True} ================================================ FILE: statemachine/contrib/timeout.py ================================================ """Timeout helper for state invocations. Provides a ``timeout()`` function that returns an :class:`~statemachine.invoke.IInvoke` handler. When a state is entered, the handler waits for the given duration; if the state is not exited before the timer expires, an event is sent to the machine. Example:: from statemachine.contrib.timeout import timeout class MyMachine(StateChart): waiting = State(initial=True, invoke=timeout(5, on="expired")) timed_out = State(final=True) expired = waiting.to(timed_out) """ from typing import TYPE_CHECKING from typing import Any if TYPE_CHECKING: from statemachine.invoke import InvokeContext class _Timeout: """IInvoke handler that waits for a duration and optionally sends an event.""" def __init__(self, duration: float, on: "str | None" = None): self.duration = duration self.on = on def run(self, ctx: "InvokeContext") -> Any: """Wait for the timeout duration, then optionally send an event. If the owning state is exited before the timer expires (``ctx.cancelled`` is set), the handler returns immediately without sending anything. """ fired = not ctx.cancelled.wait(timeout=self.duration) if not fired: # State was exited before the timeout — nothing to do. return None if self.on is not None: ctx.send(self.on) return None def __repr__(self) -> str: args = f"{self.duration}" if self.on is not None: args += f", on={self.on!r}" return f"timeout({args})" def timeout(duration: float, *, on: "str | None" = None) -> _Timeout: """Create a timeout invoke handler. Args: duration: Time in seconds to wait before firing. on: Event name to send when the timeout expires. If ``None``, the standard ``done.invoke.`` event fires via invoke completion. Returns: An :class:`~statemachine.invoke.IInvoke`-compatible handler. Raises: ValueError: If *duration* is not positive. """ if duration <= 0: raise ValueError(f"timeout duration must be positive, got {duration}") return _Timeout(duration=duration, on=on) ================================================ FILE: statemachine/contrib/weighted.py ================================================ import random from typing import TYPE_CHECKING from typing import Any from typing import Dict from typing import List from typing import Tuple from typing import Union from statemachine.callbacks import CallbackPriority from statemachine.transition_list import TransitionList if TYPE_CHECKING: from statemachine.state import State class _WeightedGroup: """Holds weights and a shared random.Random instance for a group of weighted transitions. When the first transition's cond (index 0) is evaluated, it rolls the dice and caches the selected index. Subsequent conds check against the cache. """ def __init__(self, weights: List[float], seed: "int | float | str | None" = None): self.weights = weights self.rng = random.Random(seed) self._selected: "int | None" = None self._population = list(range(len(weights))) def select(self) -> int: """Roll the dice and cache the selected index.""" self._selected = self.rng.choices(self._population, weights=self.weights, k=1)[0] return self._selected @property def selected(self) -> "int | None": return self._selected def _make_weighted_cond(index: int, group: _WeightedGroup, weight: float, total_weight: float): """Create a weighted condition callable for a specific transition index. Returns a function that, when called, returns True only for the selected weighted transition. Index 0 rolls the dice; other indices check against the cached selection. """ pct = weight / total_weight * 100 def weighted_cond() -> bool: if index == 0: selected = group.select() elif group.selected is None: selected = group.select() else: selected = group.selected return selected == index weighted_cond.__name__ = f"weight={weight} ({pct:.0f}%)" weighted_cond.__qualname__ = f"_weighted_cond_{index}_{id(group)}" return weighted_cond # Type alias for a weighted destination: # (target, weight) or (target, weight, kwargs_dict) _WeightedDest = Union[ Tuple["State", Union[int, float]], Tuple["State", Union[int, float], Dict[str, Any]], ] def to(target: "State", weight: "int | float", **kwargs: Any) -> _WeightedDest: """Build a weighted destination with transition keyword arguments. Syntactic sugar that returns a ``(target, weight, kwargs)`` tuple, allowing transition options (``cond``, ``unless``, ``before``, ``on``, ``after``, …) to be passed as natural keyword arguments instead of a dict. For simple cases without extra options, a plain ``(target, weight)`` tuple works just as well — ``to()`` is only needed when you want to add transition kwargs. Args: target: The destination state. weight: A positive number representing the relative weight. **kwargs: Keyword arguments forwarded to ``source.to(target, **kwargs)``. Returns: A ``(target, weight, kwargs)`` tuple accepted by :func:`weighted_transitions`. Example:: move = weighted_transitions( standing, to(walk, 70), to(run, 30, cond="has_energy", on="start_running"), seed=42, ) """ return (target, weight, kwargs) def _validate_dest(i: int, item: Any) -> "Tuple[State, float, Dict[str, Any]]": """Validate and normalize a single ``(target, weight[, kwargs])`` tuple.""" from statemachine.state import State if not isinstance(item, tuple) or len(item) not in (2, 3): raise TypeError( f"Destination {i} must be a (target_state, weight) or " f"(target_state, weight, kwargs) tuple, got {type(item).__name__}" ) if len(item) == 2: target, weight = item kwargs: Dict[str, Any] = {} else: target, weight, kwargs = item if not isinstance(kwargs, dict): raise TypeError( f"Destination {i}: third element must be a dict of " f"transition kwargs, got {type(kwargs).__name__}" ) if not isinstance(target, State): raise TypeError( f"Destination {i}: first element must be a State, got {type(target).__name__}" ) if not isinstance(weight, (int, float)): raise TypeError( f"Destination {i}: weight must be a positive number, got {type(weight).__name__}" ) if weight <= 0: raise ValueError(f"Destination {i}: weight must be positive, got {weight}") return target, float(weight), kwargs def weighted_transitions( source: "State", *destinations: _WeightedDest, seed: "int | float | str | None" = None, ) -> TransitionList: """Create a :ref:`TransitionList` where transitions are selected probabilistically based on weights. Takes a ``source`` state and one or more ``(target, weight)`` tuples. For simple cases a plain tuple is enough. When you need transition options (``cond``, ``on``, etc.), use the :func:`to` helper to pass them as keyword arguments:: move = weighted_transitions( standing, (walk, 70), # plain tuple to(run, 30, cond="has_energy", on="sprint"), # with kwargs seed=42, ) The returned :ref:`TransitionList` can be assigned to a class attribute just like any other event definition. At runtime, the engine evaluates the weighted conditions and selects exactly one transition per event dispatch according to the weight distribution. Args: source: The source state for all transitions. *destinations: ``(target, weight)`` tuples or :func:`to` calls. seed: Optional seed for the random number generator (for reproducibility). Returns: A :ref:`TransitionList` combining all transitions with weighted conditions. """ from statemachine.state import State if not isinstance(source, State): raise TypeError(f"First argument must be a source State, got {type(source).__name__}") if not destinations: raise ValueError( "weighted_transitions() requires at least one (target, weight) destination" ) validated = [_validate_dest(i, item) for i, item in enumerate(destinations)] weights = [w for _, w, _ in validated] total_weight = sum(weights) group = _WeightedGroup(weights, seed=seed) result = TransitionList() for index, (target, weight, kwargs) in enumerate(validated): trans = source.to(target, **kwargs) cond_fn = _make_weighted_cond(index, group, weight, total_weight) for transition in trans.transitions: transition.cond.add(cond_fn, priority=CallbackPriority.GENERIC, expected_value=True) result.add_transitions(trans) return result ================================================ FILE: statemachine/dispatcher.py ================================================ from dataclasses import dataclass from functools import partial from functools import reduce from operator import attrgetter from typing import TYPE_CHECKING from typing import Any from typing import Callable from typing import Iterable from typing import List from typing import Set from typing import Tuple from .callbacks import SPECS_ALL from .callbacks import SpecReference from .callbacks import allways_true from .event import Event from .exceptions import InvalidDefinition from .i18n import _ from .signature import SignatureAdapter from .spec_parser import custom_and from .spec_parser import operator_mapping from .spec_parser import parse_boolean_expr if TYPE_CHECKING: from .callbacks import CallbackSpec from .callbacks import CallbackSpecList from .callbacks import CallbacksRegistry @dataclass class Listener: """Object reference that provides attributes to be used as callbacks. Args: obj: Any object that will serve as lookup for attributes. skip_attrs: Protected attrs that will be ignored on the search. """ obj: object all_attrs: Set[str] resolver_id: str @classmethod def from_obj(cls, obj, skip_attrs=None) -> "Listener": if isinstance(obj, Listener): return obj else: if skip_attrs is None: skip_attrs = set() all_attrs = set(dir(obj)) - skip_attrs return cls(obj, all_attrs, str(id(obj))) def build_key(self, attr_name) -> str: return f"{attr_name}@{self.resolver_id}" @dataclass class Listeners: """Listeners that provides attributes to be used as callbacks.""" items: Tuple[Listener, ...] all_attrs: Set[str] @classmethod def from_listeners(cls, listeners: Iterable["Listener"]) -> "Listeners": listeners = tuple(listeners) all_attrs = set().union(*(listener.all_attrs for listener in listeners)) return cls(listeners, all_attrs) def resolve( self, specs: "CallbackSpecList", registry: "CallbacksRegistry", allowed_references: SpecReference = SPECS_ALL, ): found_convention_specs = specs.conventional_specs & self.all_attrs for spec in specs: if (spec.reference not in allowed_references) or ( spec.is_convention and spec.func not in found_convention_specs ): continue executor = registry[specs.grouper(spec.group).key] for key, builder in self.build(spec): executor.add(key, spec, builder) def _take_callback(self, name: str, names_not_found_handler: Callable) -> Callable: callbacks: List[Callable] = [] for key, builder in self.search_name(name): callback = builder() callback.unique_key = key # type: ignore[attr-defined] callbacks.append(callback) if len(callbacks) == 0: names_not_found_handler(name) return allways_true elif len(callbacks) == 1: return callbacks[0] else: return reduce(custom_and, callbacks) def build(self, spec: "CallbackSpec"): """ Resolves the `spec` into callables in the `registry`. Args: spec (CallbackSpec): A spec to be resolved. registry (callable): A callable that will be used to store the resolved callables. """ if not spec.may_contain_boolean_expression: yield from self.search(spec) return # Resolves boolean expressions names_not_found: Set[str] = set() take_callback_partial = partial( self._take_callback, names_not_found_handler=names_not_found.add ) try: expression = parse_boolean_expr(spec.func, take_callback_partial, operator_mapping) except SyntaxError as err: raise InvalidDefinition( _("Failed to parse boolean expression '{}'").format(spec.func) ) from err if not expression or names_not_found: spec.names_not_found = names_not_found return yield expression.unique_key, lambda: expression def search(self, spec: "CallbackSpec"): if spec.reference is SpecReference.NAME: yield from self.search_name(spec.attr_name) elif spec.reference is SpecReference.CALLABLE: yield from self._search_callable(spec) elif spec.reference is SpecReference.PROPERTY: yield from self._search_property(spec) else: # never reached here from tests but put an exception for safety. pragma: no cover raise ValueError(f"Invalid reference {spec.reference}") def _search_property(self, spec): # if the attr is a property, we'll try to find the object that has the # property on the configs attr_name = spec.attr_name if attr_name not in self.all_attrs: return for listener in self.items: func = getattr(type(listener.obj), attr_name, None) if func is not None and func is spec.func: yield ( listener.build_key(attr_name), partial(attr_method, attr_name, listener.obj), ) return def _search_callable(self, spec): # if the attr is an unbounded method, we'll try to find the bounded method # on the self if not spec.is_bounded: for listener in self.items: func = getattr(listener.obj, spec.attr_name, None) if func is not None and func.__func__ is spec.func: yield listener.build_key(spec.attr_name), partial(callable_method, func) return yield f"{spec.attr_name}-{id(spec.func)}@None", partial(callable_method, spec.func) def search_name(self, name): for listener in self.items: if name not in listener.all_attrs: continue key = listener.build_key(name) func = getattr(listener.obj, name) if not callable(func): yield key, partial(attr_method, name, listener.obj) continue if isinstance(func, Event): yield key, partial(event_method, func) continue yield key, partial(callable_method, func) def callable_method(a_callable) -> Callable: sig = SignatureAdapter.from_callable(a_callable) sig_bind_expected = sig.bind_expected metadata_to_copy = a_callable.func if isinstance(a_callable, partial) else a_callable if sig.is_coroutine: async def signature_adapter(*args: Any, **kwargs: Any) -> Any: # pyright: ignore[reportRedeclaration] ba = sig_bind_expected(*args, **kwargs) return await a_callable(*ba.args, **ba.kwargs) else: def signature_adapter(*args: Any, **kwargs: Any) -> Any: # type: ignore[misc] ba = sig_bind_expected(*args, **kwargs) return a_callable(*ba.args, **ba.kwargs) signature_adapter.__name__ = metadata_to_copy.__name__ signature_adapter.__doc__ = metadata_to_copy.__doc__ signature_adapter.is_coroutine = sig.is_coroutine # type: ignore[attr-defined] return signature_adapter def attr_method(attribute, obj) -> Callable: getter = attrgetter(attribute) def method(*args, **kwargs): return getter(obj) method.__name__ = attribute return method def event_method(func) -> Callable: def method(*args, **kwargs): kwargs.pop("machine", None) return func(*args, **kwargs) return method def resolver_factory_from_objects(*objects: Tuple[Any, ...]): return Listeners.from_listeners(Listener.from_obj(o) for o in objects) ================================================ FILE: statemachine/engines/__init__.py ================================================ ================================================ FILE: statemachine/engines/async_.py ================================================ import asyncio import contextvars from itertools import chain from time import time from typing import TYPE_CHECKING from typing import Callable from typing import List from ..event_data import EventData from ..event_data import TriggerData from ..exceptions import InvalidDefinition from ..exceptions import TransitionNotAllowed from ..orderedset import OrderedSet from ..state import State from .base import _ERROR_EXECUTION from .base import BaseEngine if TYPE_CHECKING: from ..transition import Transition # ContextVar to distinguish reentrant calls (from within callbacks) from # concurrent external calls. asyncio propagates context to child tasks # (e.g., those created by asyncio.gather in the callback system), so a # ContextVar set in the processing loop is visible in all callbacks. # Independent external coroutines have their own context where this is False. _in_processing_loop: contextvars.ContextVar[bool] = contextvars.ContextVar( "_in_processing_loop", default=False ) class AsyncEngine(BaseEngine): """Async engine with full StateChart support. Mirrors :class:`SyncEngine` algorithm but uses ``async``/``await`` for callback dispatch. All pure-computation helpers are inherited from :class:`BaseEngine`. """ def put(self, trigger_data: TriggerData, internal: bool = False, _delayed: bool = False): """Override to attach an asyncio.Future for external events. Futures are only created when: - The event is external (not internal) - No future is already attached - There is a running asyncio loop - The call is NOT from within the processing loop (reentrant calls from callbacks must not get futures, as that would deadlock) """ if not internal and trigger_data.future is None and not _in_processing_loop.get(): try: loop = asyncio.get_running_loop() trigger_data.future = loop.create_future() except RuntimeError: pass # No running loop — sync caller super().put(trigger_data, internal=internal, _delayed=_delayed) @staticmethod def _resolve_future(future: "asyncio.Future[object] | None", result): """Resolve a future with the given result, if present and not yet done.""" if future is not None and not future.done(): future.set_result(result) @staticmethod def _reject_future(future: "asyncio.Future[object] | None", exc: Exception): """Reject a future with the given exception, if present and not yet done.""" if future is not None and not future.done(): future.set_exception(exc) def _reject_pending_futures(self, exc: Exception): """Reject all unresolved futures in the external queue.""" self.external_queue.reject_futures(exc) # --- Callback dispatch overrides (async versions of BaseEngine methods) --- async def _get_args_kwargs( self, transition: "Transition", trigger_data: TriggerData, target: "State | None" = None ): cache_key = (id(transition), id(trigger_data), id(target)) if cache_key in self._cache: return self._cache[cache_key] event_data = EventData(trigger_data=trigger_data, transition=transition) if target: event_data.state = target event_data.target = target args, kwargs = event_data.args, event_data.extended_kwargs result = await self.sm._callbacks.async_call(self.sm.prepare.key, *args, **kwargs) for new_kwargs in result: kwargs.update(new_kwargs) self._cache[cache_key] = (args, kwargs) return args, kwargs async def _conditions_match(self, transition: "Transition", trigger_data: TriggerData): args, kwargs = await self._get_args_kwargs(transition, trigger_data) on_error = self._on_error_handler() await self.sm._callbacks.async_call( transition.validators.key, *args, on_error=None, **kwargs ) return await self.sm._callbacks.async_all( transition.cond.key, *args, on_error=on_error, **kwargs ) async def _first_transition_that_matches( # type: ignore[override] self, state: State, trigger_data: TriggerData, predicate: Callable, ) -> "Transition | None": for s in chain([state], state.ancestors()): transition: "Transition" for transition in s.transitions: if ( not transition.initial and predicate(transition, trigger_data.event) and await self._conditions_match(transition, trigger_data) ): return transition return None async def _select_transitions( # type: ignore[override] self, trigger_data: TriggerData, predicate: Callable ) -> "OrderedSet[Transition]": enabled_transitions: "OrderedSet[Transition]" = OrderedSet() atomic_states = (state for state in self.sm.configuration if state.is_atomic) for state in atomic_states: transition = await self._first_transition_that_matches(state, trigger_data, predicate) if transition is not None: enabled_transitions.add(transition) return self._filter_conflicting_transitions(enabled_transitions) async def select_eventless_transitions(self, trigger_data: TriggerData): return await self._select_transitions(trigger_data, lambda t, _e: t.is_eventless) async def select_transitions(self, trigger_data: TriggerData) -> "OrderedSet[Transition]": # type: ignore[override] return await self._select_transitions(trigger_data, lambda t, e: t.match(e)) async def _execute_transition_content( self, enabled_transitions: "List[Transition]", trigger_data: TriggerData, get_key: "Callable[[Transition], str]", set_target_as_state: bool = False, **kwargs_extra, ): result = [] for transition in enabled_transitions: target = transition.target if set_target_as_state else None args, kwargs = await self._get_args_kwargs( transition, trigger_data, target=target, ) kwargs.update(kwargs_extra) result += await self.sm._callbacks.async_call(get_key(transition), *args, **kwargs) return result async def _exit_states( # type: ignore[override] self, enabled_transitions: "List[Transition]", trigger_data: TriggerData ) -> "OrderedSet[State]": ordered_states, result = self._prepare_exit_states(enabled_transitions) on_error = self._on_error_handler() for info in ordered_states: # Cancel invocations for this state before executing exit handlers. if info.state is not None: # pragma: no branch self._invoke_manager.cancel_for_state(info.state) args, kwargs = await self._get_args_kwargs(info.transition, trigger_data) if info.state is not None: # pragma: no branch self._debug("%s Exiting state: %s", self._log_id, info.state) await self.sm._callbacks.async_call( info.state.exit.key, *args, on_error=on_error, **kwargs ) self._remove_state_from_configuration(info.state) return result async def _enter_states( # noqa: C901 self, enabled_transitions: "List[Transition]", trigger_data: TriggerData, states_to_exit: "OrderedSet[State]", previous_configuration: "OrderedSet[State]", ): on_error = self._on_error_handler() ordered_states, states_for_default_entry, default_history_content, new_configuration = ( self._prepare_entry_states(enabled_transitions, states_to_exit, previous_configuration) ) # For transition 'on' content, use on_error only for non-error.execution # events. During error.execution processing, errors in transition content # must propagate to microstep() where _send_error_execution's guard # prevents infinite loops (per SCXML spec: errors during error event # processing are ignored). on_error_transition = on_error if ( on_error is not None and trigger_data.event and str(trigger_data.event) == _ERROR_EXECUTION ): on_error_transition = None result = await self._execute_transition_content( enabled_transitions, trigger_data, lambda t: t.on.key, on_error=on_error_transition, previous_configuration=previous_configuration, new_configuration=new_configuration, ) if self.sm.atomic_configuration_update: self.sm.configuration = new_configuration for info in ordered_states: target = info.state transition = info.transition args, kwargs = await self._get_args_kwargs( transition, trigger_data, target=target, ) self._debug("%s Entering state: %s", self._log_id, target) self._add_state_to_configuration(target) on_entry_result = await self.sm._callbacks.async_call( target.enter.key, *args, on_error=on_error, **kwargs ) # Handle default initial states if target.id in {t.state.id for t in states_for_default_entry if t.state}: initial_transitions = [t for t in target.transitions if t.initial] if len(initial_transitions) == 1: result += await self.sm._callbacks.async_call( initial_transitions[0].on.key, *args, **kwargs ) # Handle default history states default_history_transitions = [ i.transition for i in default_history_content.get(target.id, []) ] if default_history_transitions: await self._execute_transition_content( default_history_transitions, trigger_data, lambda t: t.on.key, previous_configuration=previous_configuration, new_configuration=new_configuration, ) # Mark state for invocation if it has invoke callbacks registered if target.invoke.key in self.sm._callbacks: self._invoke_manager.mark_for_invoke(target, trigger_data.kwargs) # Handle final states if target.final: self._handle_final_state(target, on_entry_result) return result async def microstep(self, transitions: "List[Transition]", trigger_data: TriggerData): self._microstep_count += 1 self._debug( "%s macro:%d micro:%d transitions: %s", self._log_id, self._macrostep_count, self._microstep_count, transitions, ) previous_configuration = self.sm.configuration try: result = await self._execute_transition_content( transitions, trigger_data, lambda t: t.before.key ) states_to_exit = await self._exit_states(transitions, trigger_data) result += await self._enter_states( transitions, trigger_data, states_to_exit, previous_configuration ) except InvalidDefinition: self.sm.configuration = previous_configuration raise except Exception as e: self.sm.configuration = previous_configuration self._handle_error(e, trigger_data) return None try: await self._execute_transition_content( transitions, trigger_data, lambda t: t.after.key, set_target_as_state=True, ) except InvalidDefinition: raise except Exception as e: self._handle_error(e, trigger_data) if len(result) == 0: result = None elif len(result) == 1: result = result[0] return result # --- Engine loop --- async def _run_microstep(self, enabled_transitions, trigger_data): # pragma: no cover """Run a microstep for internal/eventless transitions with error handling. Note: microstep() handles its own errors internally, so this try/except is a safety net that is not expected to be reached in normal operation. """ try: await self.microstep(list(enabled_transitions), trigger_data) except InvalidDefinition: raise except Exception as e: self._handle_error(e, trigger_data) async def activate_initial_state(self, **kwargs): """Activate the initial state. In async code, the user must call this method explicitly (or it will be lazily activated on the first event). There's no built-in way to call async code from ``StateMachine.__init__``. Any ``**kwargs`` are forwarded to initial state entry callbacks via dependency injection, just like event kwargs on ``send()``. """ return await self.processing_loop() async def processing_loop( # noqa: C901 self, caller_future: "asyncio.Future[object] | None" = None ): """Process event triggers with the 3-phase macrostep architecture. Phase 1: Eventless transitions + internal queue until quiescence. Phase 2: Remaining internal events (safety net for invoke-generated events). Phase 3: External events. When ``caller_future`` is provided, the caller can ``await`` it to receive its own event's result — even if another coroutine holds the processing lock. """ if not self._processing.acquire(blocking=False): # Another coroutine holds the lock and will process our event. # Await the caller's future so we get our own result back. if caller_future is not None: return await caller_future return None _ctx_token = _in_processing_loop.set(True) self._debug("%s Processing loop started: %s", self._log_id, self.sm.current_state_value) first_result = self._sentinel try: took_events = True while took_events and self.running: self.clear_cache() took_events = False macrostep_done = False # Phase 1: eventless transitions and internal events while not macrostep_done: self._microstep_count = 0 self._debug( "%s Macrostep %d: eventless/internal queue", self._log_id, self._macrostep_count, ) self.clear_cache() internal_event = TriggerData(self.sm, event=None) # null object for eventless enabled_transitions = await self.select_eventless_transitions(internal_event) if not enabled_transitions: if self.internal_queue.is_empty(): macrostep_done = True else: internal_event = self.internal_queue.pop() enabled_transitions = await self.select_transitions(internal_event) if enabled_transitions: self._debug( "%s Enabled transitions: %s", self._log_id, enabled_transitions ) took_events = True await self._run_microstep(enabled_transitions, internal_event) # Spawn invoke handlers for states entered during this macrostep. await self._invoke_manager.spawn_pending_async() self._check_root_final_state() # Phase 2: remaining internal events while not self.internal_queue.is_empty(): # pragma: no cover internal_event = self.internal_queue.pop() enabled_transitions = await self.select_transitions(internal_event) if enabled_transitions: await self._run_microstep(enabled_transitions, internal_event) # Phase 3: external events self._debug("%s Macrostep %d: external queue", self._log_id, self._macrostep_count) while not self.external_queue.is_empty(): self.clear_cache() took_events = True external_event = self.external_queue.pop() current_time = time() if external_event.execution_time > current_time: self.put(external_event, _delayed=True) await asyncio.sleep(self.sm._loop_sleep_in_ms) # Break to Phase 1 so internal events and eventless # transitions can be processed while we wait. break self._macrostep_count += 1 self._microstep_count = 0 self._debug( "%s macrostep %d: event=%s", self._log_id, self._macrostep_count, external_event.event, ) # Handle lazy initial state activation. # Break out of phase 3 so the outer loop restarts from phase 1 # (eventless/internal), ensuring internal events queued during # initial entry are processed before any external events. if external_event.event == "__initial__": transitions = self._initial_transitions(external_event) await self._enter_states( transitions, external_event, OrderedSet(), OrderedSet() ) break # Finalize + autoforward for active invocations self._invoke_manager.handle_external_event(external_event) event_future = external_event.future try: enabled_transitions = await self.select_transitions(external_event) self._debug( "%s Enabled transitions: %s", self._log_id, enabled_transitions ) if enabled_transitions: result = await self.microstep( list(enabled_transitions), external_event ) self._resolve_future(event_future, result) if first_result is self._sentinel: first_result = result else: if not self.sm.allow_event_without_transition: tna = TransitionNotAllowed( external_event.event, self.sm.configuration ) self._reject_future(event_future, tna) self._reject_pending_futures(tna) raise tna # Event allowed but no transition — resolve with None self._resolve_future(event_future, None) except Exception as exc: self._reject_future(event_future, exc) self._reject_pending_futures(exc) self.clear() raise except Exception as exc: if caller_future is not None: # Route the exception to the caller's future if still pending. # If already resolved (caller's own event succeeded before a # later event failed), suppress the exception — the caller will # get their successful result via ``await future`` below, and # the failing event's exception was already routed to *its* # caller's future by ``_reject_future(event_future, ...)``. self._reject_future(caller_future, exc) else: raise finally: _in_processing_loop.reset(_ctx_token) self._processing.release() self._debug("%s Processing loop ended", self._log_id) result = first_result if first_result is not self._sentinel else None # If the caller has a future, await it (already resolved by now). if caller_future is not None: # Resolve the future if it wasn't processed (e.g. machine terminated). self._resolve_future(caller_future, result) return await caller_future return result async def enabled_events(self, *args, **kwargs): sm = self.sm enabled = {} for state in sm.configuration: for transition in state.transitions: for event in transition.events: if event in enabled: continue extended_kwargs = kwargs.copy() extended_kwargs.update( { "machine": sm, "model": sm.model, "event": getattr(sm, event), "source": transition.source, "target": transition.target, "state": state, "transition": transition, } ) try: if await sm._callbacks.async_all( transition.cond.key, *args, **extended_kwargs ): enabled[event] = getattr(sm, event) except Exception: enabled[event] = getattr(sm, event) return list(enabled.values()) ================================================ FILE: statemachine/engines/base.py ================================================ import logging from dataclasses import dataclass from dataclasses import field from itertools import chain from queue import PriorityQueue from queue import Queue from threading import Lock from typing import TYPE_CHECKING from typing import Any from typing import Callable from typing import Dict from typing import List from typing import cast from ..event import BoundEvent from ..event_data import EventData from ..event_data import TriggerData from ..exceptions import InvalidDefinition from ..exceptions import TransitionNotAllowed from ..invoke import InvokeManager from ..orderedset import OrderedSet from ..state import HistoryState from ..state import State from ..transition import Transition if TYPE_CHECKING: from ..statemachine import StateChart logger = logging.getLogger(__name__) @dataclass(frozen=True, unsafe_hash=True, eq=True) class StateTransition: transition: Transition = field(compare=False) state: State class EventQueue: def __init__(self): self.queue: Queue = PriorityQueue() def __repr__(self): return f"EventQueue({self.queue.queue!r}, size={self.queue.qsize()})" def is_empty(self): return self.queue.qsize() == 0 def put(self, trigger_data: TriggerData): """Put the trigger on the queue without blocking the caller.""" self.queue.put(trigger_data) def pop(self): """Pop a trigger from the queue without blocking the caller.""" return self.queue.get(block=False) def clear(self): with self.queue.mutex: self.queue.queue.clear() def reject_futures(self, exc: Exception): """Reject all unresolved futures in the queue. Called when the processing loop exits abnormally so that coroutines awaiting their futures don't hang forever. """ with self.queue.mutex: for trigger_data in self.queue.queue: future = trigger_data.future if future is not None and not future.done(): future.set_exception(exc) def remove(self, send_id: str): # We use the internal `queue` to make thins faster as the mutex # is protecting the block below with self.queue.mutex: self.queue.queue = [ trigger_data for trigger_data in self.queue.queue if trigger_data.send_id != send_id ] _ERROR_EXECUTION = "error.execution" class BaseEngine: def __init__(self, sm: "StateChart"): self.sm: "StateChart" = sm self.external_queue = EventQueue() self.internal_queue = EventQueue() self._sentinel = object() self.running = True self._processing = Lock() self._cache: Dict = {} # Cache for _get_args_kwargs results self._invoke_manager = InvokeManager(self) self._macrostep_count: int = 0 self._microstep_count: int = 0 self._log_id = f"[{type(sm).__name__}]" self._debug = logger.debug if logger.isEnabledFor(logging.DEBUG) else lambda *a, **k: None self._root_parallel_final_pending: "State | None" = None def empty(self): # pragma: no cover return self.external_queue.is_empty() def clear_cache(self): """Clears the cache. Should be called at the start of each processing loop.""" self._cache.clear() def put(self, trigger_data: TriggerData, internal: bool = False, _delayed: bool = False): """Put the trigger on the queue without blocking the caller.""" if not self.running and not self.sm.allow_event_without_transition: raise TransitionNotAllowed(trigger_data.event, self.sm.configuration) if internal: self.internal_queue.put(trigger_data) else: self.external_queue.put(trigger_data) if not _delayed: self._debug( "%s New event '%s' put on the '%s' queue", self._log_id, trigger_data.event, "internal" if internal else "external", ) def pop(self): # pragma: no cover return self.external_queue.pop() def clear(self): self.external_queue.clear() def cancel_event(self, send_id: str): """Cancel the event with the given send_id.""" self.external_queue.remove(send_id) def _on_error_handler(self) -> "Callable[[Exception], None] | None": """Return a per-block error handler, or ``None``. When ``catch_errors_as_events`` is enabled, returns a callable that queues ``error.execution`` on the internal queue. Otherwise returns ``None`` so that exceptions propagate normally. """ if not self.sm.catch_errors_as_events: return None def handler(error: Exception) -> None: if isinstance(error, InvalidDefinition): raise error # Per-block errors always queue error.execution — even when the current # event is itself error.execution. The SCXML spec mandates that the # new error.execution is a separate event that may trigger a different # transition (see W3C test 152). The infinite-loop guard lives at the # *microstep* level (in ``_send_error_execution``), not here. BoundEvent(_ERROR_EXECUTION, internal=True, _sm=self.sm).put(error=error) return handler def _handle_error(self, error: Exception, trigger_data: TriggerData): """Handle an execution error: send ``error.execution`` or re-raise. Centralises the ``if catch_errors_as_events`` check so callers don't need to know about the variation. """ if self.sm.catch_errors_as_events: self._send_error_execution(error, trigger_data) else: raise error def _send_error_execution(self, error: Exception, trigger_data: TriggerData): """Send error.execution to internal queue (SCXML spec). If already processing an error.execution event, ignore to avoid infinite loops. """ self._debug( "%s Error %s captured while executing event=%s", self._log_id, error, trigger_data.event, ) if trigger_data.event and str(trigger_data.event) == _ERROR_EXECUTION: logger.warning("Error while processing error.execution, ignoring: %s", error) return BoundEvent(_ERROR_EXECUTION, internal=True, _sm=self.sm).put(error=error) def start(self, **kwargs): if self.sm.current_state_value is not None: return BoundEvent("__initial__", _sm=self.sm).put(**kwargs) def _initial_transitions(self, trigger_data): empty_state = State() configuration = self.sm._get_initial_configuration() transitions = [ Transition(empty_state, state, event="__initial__") for state in configuration ] for transition in transitions: transition._specs.clear() return transitions def _filter_conflicting_transitions( self, transitions: OrderedSet[Transition] ) -> OrderedSet[Transition]: """ Remove transições conflitantes, priorizando aquelas com estados de origem descendentes ou que aparecem antes na ordem do documento. Args: transitions (OrderedSet[Transition]): Conjunto de transições habilitadas. Returns: OrderedSet[Transition]: Conjunto de transições sem conflitos. """ filtered_transitions = OrderedSet[Transition]() # Ordena as transições na ordem dos estados que as selecionaram for t1 in transitions: t1_preempted = False transitions_to_remove = OrderedSet[Transition]() # Verifica conflitos com as transições já filtradas for t2 in filtered_transitions: # Calcula os conjuntos de saída (exit sets) t1_exit_set = self._compute_exit_set([t1]) t2_exit_set = self._compute_exit_set([t2]) # Verifica interseção dos conjuntos de saída if t1_exit_set & t2_exit_set: # Há interseção if t1.source.is_descendant(t2.source): # t1 é preferido pois é descendente de t2 transitions_to_remove.add(t2) else: # t2 é preferido pois foi selecionado antes na ordem do documento t1_preempted = True break # Se t1 não foi preemptado, adiciona a lista filtrada e remove os conflitantes if not t1_preempted: for t3 in transitions_to_remove: filtered_transitions.discard(t3) filtered_transitions.add(t1) return filtered_transitions def _compute_exit_set(self, transitions: List[Transition]) -> OrderedSet[StateTransition]: """Compute the exit set for a transition.""" states_to_exit = OrderedSet[StateTransition]() for transition in transitions: if not transition.targets: continue domain = self.get_transition_domain(transition) for state in self.sm.configuration: if domain is None or state.is_descendant(domain): info = StateTransition(transition=transition, state=state) states_to_exit.add(info) return states_to_exit def get_transition_domain(self, transition: Transition) -> "State | None": """ Return the compound state such that 1) all states that are exited or entered as a result of taking 'transition' are descendants of it 2) no descendant of it has this property. """ states = self.get_effective_target_states(transition) if not states: return None elif ( transition.internal and transition.source.is_compound and all(state.is_descendant(transition.source) for state in states) ): return transition.source elif ( transition.internal and transition.is_self and transition.target and transition.target.is_atomic ): return transition.source else: return self.find_lcca([transition.source] + list(states)) @staticmethod def find_lcca(states: List[State]) -> "State | None": """ Find the Least Common Compound Ancestor (LCCA) of the given list of states. Args: state_list: A list of states. Returns: The LCCA state, which is a proper ancestor of all states in the list, or None if no such ancestor exists. """ # Get ancestors of the first state in the list, filtering for compound or SCXML elements head, *tail = states ancestors = [anc for anc in head.ancestors() if anc.is_compound] # Find the first ancestor that is also an ancestor of all other states in the list ancestor: State for ancestor in ancestors: if all(state.is_descendant(ancestor) for state in tail): return ancestor return None def get_effective_target_states(self, transition: Transition) -> OrderedSet[State]: targets = OrderedSet[State]() for state in transition.targets: if state.is_history: if state.id in self.sm.history_values: targets.update(self.sm.history_values[state.id]) else: targets.update( state for t in state.transitions for state in self.get_effective_target_states(t) ) else: targets.add(state) return targets def select_eventless_transitions(self, trigger_data: TriggerData): """ Select the eventless transitions that match the trigger data. """ return self._select_transitions(trigger_data, lambda t, _e: t.is_eventless) def select_transitions(self, trigger_data: TriggerData) -> OrderedSet[Transition]: """ Select the transitions that match the trigger data. """ return self._select_transitions(trigger_data, lambda t, e: t.match(e)) def _first_transition_that_matches( self, state: State, trigger_data: TriggerData, predicate: Callable, ) -> "Transition | None": for s in chain([state], state.ancestors()): transition: Transition for transition in s.transitions: if ( not transition.initial and predicate(transition, trigger_data.event) and self._conditions_match(transition, trigger_data) ): return transition return None def _select_transitions( self, trigger_data: TriggerData, predicate: Callable ) -> OrderedSet[Transition]: """Select the transitions that match the trigger data.""" enabled_transitions = OrderedSet[Transition]() # Get atomic states, TODO: sorted by document order atomic_states = (state for state in self.sm.configuration if state.is_atomic) for state in atomic_states: transition = self._first_transition_that_matches(state, trigger_data, predicate) if transition is not None: enabled_transitions.add(transition) return self._filter_conflicting_transitions(enabled_transitions) def microstep(self, transitions: List[Transition], trigger_data: TriggerData): """Process a single set of transitions in a 'lock step'. This includes exiting states, executing transition content, and entering states. """ self._microstep_count += 1 self._debug( "%s macro:%d micro:%d transitions: %s", self._log_id, self._macrostep_count, self._microstep_count, transitions, ) previous_configuration = self.sm.configuration try: result = self._execute_transition_content( transitions, trigger_data, lambda t: t.before.key ) states_to_exit = self._exit_states(transitions, trigger_data) result += self._enter_states( transitions, trigger_data, states_to_exit, previous_configuration ) except InvalidDefinition: self.sm.configuration = previous_configuration raise except Exception as e: self.sm.configuration = previous_configuration self._handle_error(e, trigger_data) return None try: self._execute_transition_content( transitions, trigger_data, lambda t: t.after.key, set_target_as_state=True, ) except InvalidDefinition: raise except Exception as e: self._handle_error(e, trigger_data) if len(result) == 0: result = None elif len(result) == 1: result = result[0] return result def _get_args_kwargs( self, transition: Transition, trigger_data: TriggerData, target: "State | None" = None ): # Generate a unique key for the cache, the cache is invalidated once per loop cache_key = (id(transition), id(trigger_data), id(target)) # Check the cache for existing results if cache_key in self._cache: return self._cache[cache_key] event_data = EventData(trigger_data=trigger_data, transition=transition) if target: event_data.state = target event_data.target = target args, kwargs = event_data.args, event_data.extended_kwargs result = self.sm._callbacks.call(self.sm.prepare.key, *args, **kwargs) for new_kwargs in result: kwargs.update(new_kwargs) # Store the result in the cache self._cache[cache_key] = (args, kwargs) return args, kwargs def _conditions_match(self, transition: Transition, trigger_data: TriggerData): args, kwargs = self._get_args_kwargs(transition, trigger_data) on_error = self._on_error_handler() self.sm._callbacks.call(transition.validators.key, *args, on_error=None, **kwargs) return self.sm._callbacks.all(transition.cond.key, *args, on_error=on_error, **kwargs) def _prepare_exit_states( self, enabled_transitions: List[Transition], ) -> "tuple[list[StateTransition], OrderedSet[State]]": """Compute exit set, sort, and update history. Pure computation, no callbacks.""" states_to_exit = self._compute_exit_set(enabled_transitions) ordered_states = sorted( states_to_exit, key=lambda x: x.state and x.state.document_order or 0, reverse=True ) result = OrderedSet([info.state for info in ordered_states if info.state]) self._debug("%s States to exit: %s", self._log_id, result) # Update history for info in ordered_states: state = info.state for history in state.history: if history.type.is_deep: history_value = [s for s in self.sm.configuration if s.is_descendant(state)] # noqa: E501 else: # shallow history history_value = [s for s in self.sm.configuration if s.parent == state] self._debug( "%s Saving '%s.%s' history state: '%s'", self._log_id, state, history, [s.id for s in history_value], ) self.sm.history_values[history.id] = history_value return ordered_states, result def _remove_state_from_configuration(self, state: State): """Remove a state from the configuration if not using atomic updates.""" if not self.sm.atomic_configuration_update: self.sm._config.discard(state) def _exit_states( self, enabled_transitions: List[Transition], trigger_data: TriggerData ) -> OrderedSet[State]: """Compute and process the states to exit for the given transitions.""" ordered_states, result = self._prepare_exit_states(enabled_transitions) on_error = self._on_error_handler() for info in ordered_states: # Cancel invocations for this state before executing exit handlers. if info.state is not None: # pragma: no branch self._invoke_manager.cancel_for_state(info.state) args, kwargs = self._get_args_kwargs(info.transition, trigger_data) # Execute `onexit` handlers — same per-block error isolation as onentry. if info.state is not None: # pragma: no branch self._debug("%s Exiting state: %s", self._log_id, info.state) self.sm._callbacks.call(info.state.exit.key, *args, on_error=on_error, **kwargs) self._remove_state_from_configuration(info.state) return result def _execute_transition_content( self, enabled_transitions: List[Transition], trigger_data: TriggerData, get_key: Callable[[Transition], str], set_target_as_state: bool = False, **kwargs_extra, ): result = [] for transition in enabled_transitions: target = transition.target if set_target_as_state else None args, kwargs = self._get_args_kwargs( transition, trigger_data, target=target, ) kwargs.update(kwargs_extra) result += self.sm._callbacks.call(get_key(transition), *args, **kwargs) return result def _prepare_entry_states( self, enabled_transitions: List[Transition], states_to_exit: OrderedSet[State], previous_configuration: OrderedSet[State], ) -> "tuple[list[StateTransition], OrderedSet[StateTransition], Dict[str, Any], OrderedSet[State]]": # noqa: E501 """Compute entry set, ordering, and new configuration. Pure computation, no callbacks. Returns: (ordered_states, states_for_default_entry, default_history_content, new_configuration) """ states_to_enter = OrderedSet[StateTransition]() states_for_default_entry = OrderedSet[StateTransition]() default_history_content: Dict[str, Any] = {} self.compute_entry_set( enabled_transitions, states_to_enter, states_for_default_entry, default_history_content ) ordered_states = sorted( states_to_enter, key=lambda x: x.state and x.state.document_order or 0 ) states_targets_to_enter = OrderedSet(info.state for info in ordered_states if info.state) # Build new configuration in a single pass instead of two set operations # (- and |) that each allocate an intermediate OrderedSet. new_configuration = OrderedSet( s for s in previous_configuration if s not in states_to_exit ) new_configuration.update(states_targets_to_enter) self._debug("%s States to enter: %s", self._log_id, states_targets_to_enter) return ordered_states, states_for_default_entry, default_history_content, new_configuration def _add_state_to_configuration(self, target: State): """Add a state to the configuration if not using atomic updates.""" if not self.sm.atomic_configuration_update: self.sm._config.add(target) def stop(self): """Stop this engine externally (e.g. when a parent cancels a child invocation).""" self._debug("%s Stopping engine", self._log_id) self.running = False try: self._invoke_manager.cancel_all() except Exception: # pragma: no cover self._debug("%s Error stopping engine", self._log_id, exc_info=True) def __del__(self): try: self._invoke_manager.cancel_all() except Exception: pass def _handle_final_state(self, target: State, on_entry_result: list): """Handle final state entry: queue done events. No direct callback dispatch.""" self._debug("%s Reached final state: %s", self._log_id, target) if target.parent is None: self._invoke_manager.cancel_all() self.running = False else: parent = target.parent grandparent = parent.parent donedata_args: tuple = () donedata_kwargs: dict = {} for item in on_entry_result: if not item: continue if isinstance(item, dict): donedata_kwargs.update(item) else: donedata_args = (item,) BoundEvent( f"done.state.{parent.id}", _sm=self.sm, internal=True, ).put(*donedata_args, **donedata_kwargs) if grandparent and grandparent.parallel: if all(self.is_in_final_state(child) for child in grandparent.states): BoundEvent(f"done.state.{grandparent.id}", _sm=self.sm, internal=True).put( *donedata_args, **donedata_kwargs ) if grandparent.parent is None: self._root_parallel_final_pending = grandparent def _enter_states( # noqa: C901 self, enabled_transitions: List[Transition], trigger_data: TriggerData, states_to_exit: OrderedSet[State], previous_configuration: OrderedSet[State], ): """Enter the states as determined by the given transitions.""" on_error = self._on_error_handler() ordered_states, states_for_default_entry, default_history_content, new_configuration = ( self._prepare_entry_states(enabled_transitions, states_to_exit, previous_configuration) ) # For transition 'on' content, use on_error only for non-error.execution # events. During error.execution processing, errors in transition content # must propagate to microstep() where _send_error_execution's guard # prevents infinite loops (per SCXML spec: errors during error event # processing are ignored). on_error_transition = on_error if ( on_error is not None and trigger_data.event and str(trigger_data.event) == _ERROR_EXECUTION ): on_error_transition = None result = self._execute_transition_content( enabled_transitions, trigger_data, lambda t: t.on.key, on_error=on_error_transition, previous_configuration=previous_configuration, new_configuration=new_configuration, ) if self.sm.atomic_configuration_update: self.sm.configuration = new_configuration for info in ordered_states: target = info.state transition = info.transition args, kwargs = self._get_args_kwargs( transition, trigger_data, target=target, ) self._debug("%s Entering state: %s", self._log_id, target) self._add_state_to_configuration(target) # Execute `onentry` handlers — each handler is a separate block per # SCXML spec: errors in one block MUST NOT affect other blocks. on_entry_result = self.sm._callbacks.call( target.enter.key, *args, on_error=on_error, **kwargs ) # Handle default initial states if target.id in {t.state.id for t in states_for_default_entry if t.state}: initial_transitions = [t for t in target.transitions if t.initial] if len(initial_transitions) == 1: result += self.sm._callbacks.call( initial_transitions[0].on.key, *args, **kwargs ) # Handle default history states default_history_transitions = [ i.transition for i in default_history_content.get(target.id, []) ] if default_history_transitions: self._execute_transition_content( default_history_transitions, trigger_data, lambda t: t.on.key, previous_configuration=previous_configuration, new_configuration=new_configuration, ) # Mark state for invocation if it has invoke callbacks registered if target.invoke.key in self.sm._callbacks: self._invoke_manager.mark_for_invoke(target, trigger_data.kwargs) # Handle final states if target.final: self._handle_final_state(target, on_entry_result) return result def compute_entry_set( self, transitions, states_to_enter, states_for_default_entry, default_history_content ): """ Compute the set of states to be entered based on the given transitions. Args: transitions: A list of transitions. states_to_enter: A set to store the states that need to be entered. states_for_default_entry: A set to store compound states requiring default entry processing. default_history_content: A dictionary to hold temporary content for history states. """ for transition in transitions: # Process each target state of the transition for target_state in transition.targets: info = StateTransition(transition=transition, state=target_state) self.add_descendant_states_to_enter( info, states_to_enter, states_for_default_entry, default_history_content ) # Determine the ancestor state (transition domain) ancestor = self.get_transition_domain(transition) # Add ancestor states to enter for each effective target state for effective_target in self.get_effective_target_states(transition): info = StateTransition(transition=transition, state=effective_target) self.add_ancestor_states_to_enter( info, ancestor, states_to_enter, states_for_default_entry, default_history_content, ) def add_descendant_states_to_enter( # noqa: C901 self, info: StateTransition, states_to_enter, states_for_default_entry, default_history_content, ): """ Add the given state and its descendants to the entry set. Args: state: The state to add to the entry set. states_to_enter: A set to store the states that need to be entered. states_for_default_entry: A set to track compound states requiring default entry processing. default_history_content: A dictionary to hold temporary content for history states. """ state = info.state if state and state.is_history: # Handle history state state = cast(HistoryState, state) parent_id = state.parent and state.parent.id default_history_content[parent_id] = [info] if state.id in self.sm.history_values: self._debug( "%s History state '%s.%s' %s restoring: '%s'", self._log_id, state.parent, state, state.type.value, [s.id for s in self.sm.history_values[state.id]], ) for history_state in self.sm.history_values[state.id]: info_to_add = StateTransition(transition=info.transition, state=history_state) if state.type.is_deep: states_to_enter.add(info_to_add) else: self.add_descendant_states_to_enter( info_to_add, states_to_enter, states_for_default_entry, default_history_content, ) for history_state in self.sm.history_values[state.id]: info_to_add = StateTransition(transition=info.transition, state=history_state) self.add_ancestor_states_to_enter( info_to_add, state.parent, states_to_enter, states_for_default_entry, default_history_content, ) else: # Handle default history content self._debug( "%s History state '%s.%s' default content: %s", self._log_id, state.parent, state, [t.target.id for t in state.transitions if t.target], ) for transition in state.transitions: target = cast(State, transition.target) info_history = StateTransition(transition=transition, state=target) default_history_content[parent_id].append(info_history) self.add_descendant_states_to_enter( info_history, states_to_enter, states_for_default_entry, default_history_content, ) # noqa: E501 for transition in state.transitions: target = cast(State, transition.target) info_history = StateTransition(transition=transition, state=target) self.add_ancestor_states_to_enter( info_history, state.parent, states_to_enter, states_for_default_entry, default_history_content, ) # noqa: E501 return # Add the state to the entry set if ( self.sm.enable_self_transition_entries or not info.transition.internal or not ( info.transition.is_self or ( info.transition.target and info.transition.target.is_descendant(info.transition.source) ) ) ): states_to_enter.add(info) state = info.state if state.parallel: for child_state in state.states: if not any( # pragma: no branch s.state.is_descendant(child_state) for s in states_to_enter ): info_to_add = StateTransition(transition=info.transition, state=child_state) self.add_descendant_states_to_enter( info_to_add, states_to_enter, states_for_default_entry, default_history_content, ) elif state.is_compound: states_for_default_entry.add(info) transition = next(t for t in state.transitions if t.initial) # Process all targets (supports multi-target initial transitions for parallel regions) for initial_target in transition.targets: info_initial = StateTransition(transition=transition, state=initial_target) self.add_descendant_states_to_enter( info_initial, states_to_enter, states_for_default_entry, default_history_content, ) for initial_target in transition.targets: info_initial = StateTransition(transition=transition, state=initial_target) self.add_ancestor_states_to_enter( info_initial, state, states_to_enter, states_for_default_entry, default_history_content, ) def add_ancestor_states_to_enter( self, info: StateTransition, ancestor, states_to_enter, states_for_default_entry, default_history_content, ): """ Add ancestors of the given state to the entry set. Args: state: The state whose ancestors are to be added. ancestor: The upper bound ancestor (exclusive) to stop at. states_to_enter: A set to store the states that need to be entered. states_for_default_entry: A set to track compound states requiring default entry processing. default_history_content: A dictionary to hold temporary content for history states. """ state = info.state assert state for anc in state.ancestors(parent=ancestor): # Add the ancestor to the entry set info_to_add = StateTransition(transition=info.transition, state=anc) states_to_enter.add(info_to_add) if anc.parallel: # Handle parallel states for child in anc.states: if not any(s.state.is_descendant(child) for s in states_to_enter): info_to_add = StateTransition(transition=info.transition, state=child) self.add_descendant_states_to_enter( info_to_add, states_to_enter, states_for_default_entry, default_history_content, ) def _check_root_final_state(self): """SCXML spec: terminate when the root configuration is final. For top-level parallel states, the machine terminates when all child regions have reached their final states — equivalent to the SCXML algorithm's ``isInFinalState(scxml_element)`` check. Uses a flag set by ``_handle_final_state`` (Information Expert) to avoid re-scanning top-level states on every macrostep. The flag is deferred because ``done.state`` events queued by ``_handle_final_state`` may trigger transitions that exit the parallel, so we verify the parallel is still in the configuration before terminating. """ state = self._root_parallel_final_pending if state is None: return self._root_parallel_final_pending = None # A done.state transition may have exited the parallel; verify it's # still in the configuration before terminating. if state in self.sm.configuration and self.is_in_final_state(state): self._invoke_manager.cancel_all() self.running = False def is_in_final_state(self, state: State) -> bool: if state.is_compound: return any(s.final and s in self.sm.configuration for s in state.states) elif state.parallel: # pragma: no cover — requires nested parallel-in-parallel return all(self.is_in_final_state(s) for s in state.states) else: # pragma: no cover — atomic states are never "in final state" return False ================================================ FILE: statemachine/engines/sync.py ================================================ from time import sleep from time import time from typing import TYPE_CHECKING from statemachine.event import BoundEvent from statemachine.orderedset import OrderedSet from ..event_data import TriggerData from ..exceptions import InvalidDefinition from ..exceptions import TransitionNotAllowed from .base import BaseEngine if TYPE_CHECKING: from ..transition import Transition class SyncEngine(BaseEngine): def _run_microstep(self, enabled_transitions, trigger_data): """Run a microstep for internal/eventless transitions with error handling. Note: microstep() handles its own errors internally, so this try/except is a safety net that is not expected to be reached in normal operation. """ try: self.microstep(list(enabled_transitions), trigger_data) except InvalidDefinition: raise except Exception as e: # pragma: no cover self._handle_error(e, trigger_data) def start(self, **kwargs): if self.sm.current_state_value is not None: return self.activate_initial_state(**kwargs) def activate_initial_state(self, **kwargs): """ Activate the initial state. Called automatically on state machine creation from sync code, but in async code, the user must call this method explicitly. Given how async works on python, there's no built-in way to activate the initial state that may depend on async code from the StateMachine.__init__ method. """ if self.sm.current_state_value is None: trigger_data = BoundEvent("__initial__", _sm=self.sm).build_trigger( machine=self.sm, **kwargs ) transitions = self._initial_transitions(trigger_data) self._processing.acquire(blocking=False) try: self._enter_states(transitions, trigger_data, OrderedSet(), OrderedSet()) finally: self._processing.release() return self.processing_loop() def processing_loop(self, caller_future=None): # noqa: C901 """Process event triggers. The event is put on a queue, and only the first event will have the result collected. .. note:: While processing the queue items, if others events are generated, they will be processed sequentially (and not nested). """ # We make sure that only the first event enters the processing critical section, # next events will only be put on the queue and processed by the same loop. if not self._processing.acquire(blocking=False): return None # We will collect the first result as the processing result to keep backwards compatibility # so we need to use a sentinel object instead of `None` because the first result may # be also `None`, and on this case the `first_result` may be overridden by another result. self._debug("%s Processing loop started: %s", self._log_id, self.sm.current_state_value) first_result = self._sentinel try: took_events = True while took_events and self.running: self.clear_cache() took_events = False # Execute the triggers in the queue in FIFO order until the queue is empty # while self._running and not self.external_queue.is_empty(): macrostep_done = False enabled_transitions: "OrderedSet[Transition] | None" = None # handles eventless transitions and internal events while not macrostep_done: self._microstep_count = 0 self._debug( "%s Macrostep %d: eventless/internal queue", self._log_id, self._macrostep_count, ) self.clear_cache() internal_event = TriggerData( self.sm, event=None ) # this one is a "null object" enabled_transitions = self.select_eventless_transitions(internal_event) if not enabled_transitions: if self.internal_queue.is_empty(): macrostep_done = True else: internal_event = self.internal_queue.pop() enabled_transitions = self.select_transitions(internal_event) if enabled_transitions: self._debug( "%s Enabled transitions: %s", self._log_id, enabled_transitions ) took_events = True self._run_microstep(enabled_transitions, internal_event) # Spawn invoke handlers for states entered during this macrostep. self._invoke_manager.spawn_pending_sync() self._check_root_final_state() # Process remaining internal events before external events. # Note: the macrostep loop above already drains the internal queue, # so this is a safety net per SCXML spec for invoke-generated events. while not self.internal_queue.is_empty(): # pragma: no cover internal_event = self.internal_queue.pop() enabled_transitions = self.select_transitions(internal_event) if enabled_transitions: self._run_microstep(enabled_transitions, internal_event) # Process external events self._debug("%s Macrostep %d: external queue", self._log_id, self._macrostep_count) while not self.external_queue.is_empty(): self.clear_cache() took_events = True external_event = self.external_queue.pop() current_time = time() if external_event.execution_time > current_time: self.put(external_event, _delayed=True) sleep(self.sm._loop_sleep_in_ms) # Break to Phase 1 so internal events and eventless # transitions can be processed while we wait. break self._macrostep_count += 1 self._microstep_count = 0 self._debug( "%s macrostep %d: event=%s", self._log_id, self._macrostep_count, external_event.event, ) # Finalize + autoforward for active invocations self._invoke_manager.handle_external_event(external_event) enabled_transitions = self.select_transitions(external_event) self._debug("%s Enabled transitions: %s", self._log_id, enabled_transitions) if enabled_transitions: try: result = self.microstep(list(enabled_transitions), external_event) if first_result is self._sentinel: first_result = result except Exception: # We clear the queue as we don't have an expected behavior # and cannot keep processing self.clear() raise else: if not self.sm.allow_event_without_transition: raise TransitionNotAllowed(external_event.event, self.sm.configuration) finally: self._processing.release() self._debug("%s Processing loop ended", self._log_id) return first_result if first_result is not self._sentinel else None def enabled_events(self, *args, **kwargs): sm = self.sm enabled = {} for state in sm.configuration: for transition in state.transitions: for event in transition.events: if event in enabled: continue extended_kwargs = kwargs.copy() extended_kwargs.update( { "machine": sm, "model": sm.model, "event": getattr(sm, event), "source": transition.source, "target": transition.target, "state": state, "transition": transition, } ) try: if sm._callbacks.all(transition.cond.key, *args, **extended_kwargs): enabled[event] = getattr(sm, event) except Exception: enabled[event] = getattr(sm, event) return list(enabled.values()) ================================================ FILE: statemachine/event.py ================================================ from typing import TYPE_CHECKING from typing import Any from typing import List from typing import cast from uuid import uuid4 from .callbacks import CallbackGroup from .event_data import TriggerData from .exceptions import InvalidDefinition from .i18n import _ from .transition_mixin import AddCallbacksMixin from .utils import humanize_id if TYPE_CHECKING: from .statemachine import StateChart from .transition import Transition from .transition_list import TransitionList def _expand_event_id(key: str) -> str: """Apply naming conventions for special event prefixes. Converts underscore-based Python attribute names to their dot-separated event equivalents. Returns a space-separated string so ``Events.add()`` registers both forms. """ if key.startswith("done_invoke_"): suffix = key[len("done_invoke_") :] return f"{key} done.invoke.{suffix}" if key.startswith("done_state_"): suffix = key[len("done_state_") :] return f"{key} done.state.{suffix}" if key.startswith("error_"): return f"{key} {key.replace('_', '.')}" return key _event_data_kwargs = { "event_data", "machine", "event", "model", "transition", "state", "source", "target", } class Event(AddCallbacksMixin, str): """An event triggers a signal that something has happened. They are sent to a state machine and allow the state machine to react. An event starts a :ref:`Transition`, which can be thought of as a “cause” that initiates a change in the state of the system. See also :ref:`events`. """ id: str """The event identifier.""" name: str """The event name.""" delay: float = 0 """The delay in milliseconds before the event is triggered. Default is 0.""" internal: bool = False """Indicates if the events should be placed on the internal event queue.""" _sm: "StateChart | None" = None """The state machine instance.""" _transitions: "TransitionList | None" = None _has_real_id = False def __new__( cls, transitions: "str | Transition | TransitionList | None" = None, id: "str | None" = None, name: "str | None" = None, delay: float = 0, internal: bool = False, _sm: "StateChart | None" = None, ): if isinstance(transitions, str): id = transitions transitions = None if id is not None and not isinstance(id, str): raise InvalidDefinition( _( "Event() received a non-string 'id' ({cls_name}). " "To combine multiple transitions under one event, " "use the | operator: t1 | t2." ).format(cls_name=type(id).__name__) ) _has_real_id = id is not None id = str(id) if _has_real_id else f"__event__{uuid4().hex}" instance = super().__new__(cls, id) instance.id = id instance.delay = delay instance.internal = internal if name: instance.name = name elif _has_real_id: instance.name = humanize_id(id) else: instance.name = "" if transitions: instance._transitions = transitions # type: ignore[assignment] instance._has_real_id = _has_real_id instance._sm = _sm return instance def __repr__(self): return ( f"{type(self).__name__}({self.id!r}, delay={self.delay!r}, internal={self.internal!r})" ) def is_same_event(self, *_args, event: "str | None" = None, **_kwargs) -> bool: return self == event def _add_callback(self, callback, grouper: CallbackGroup, is_event=False, **kwargs): if self._transitions is None: raise InvalidDefinition( _("Cannot add callback '{}' to an event with no transitions.").format(callback) ) return self._transitions._add_callback( callback=callback, grouper=grouper, is_event=is_event, **kwargs, ) def __get__(self, instance, owner): """By implementing this method `Event` can be used as a property descriptor When attached to a SM class, if the user tries to get the `Event` instance, we intercept here and return a `BoundEvent` instance, so the user can call it as a method with the correct SM instance. """ if instance is None: return self return BoundEvent(id=self.id, name=self.name, delay=self.delay, _sm=instance) def put(self, *args, send_id: "str | None" = None, **kwargs): # The `__call__` is declared here to help IDEs knowing that an `Event` # can be called as a method. But it is not meant to be called without # an SM instance. Such SM instance is provided by `__get__` method when # used as a property descriptor. assert self._sm is not None trigger_data = self.build_trigger(*args, machine=self._sm, send_id=send_id, **kwargs) self._sm._put_nonblocking(trigger_data, internal=self.internal) return trigger_data def build_trigger(self, *args, machine: "StateChart", send_id: "str | None" = None, **kwargs): if machine is None: raise RuntimeError(_("Event {} cannot be called without a SM instance").format(self)) kwargs = {k: v for k, v in kwargs.items() if k not in _event_data_kwargs} trigger_data = TriggerData( machine=machine, event=self, send_id=send_id, args=args, kwargs=kwargs, ) return trigger_data def __call__(self, *args, **kwargs) -> Any: """Send this event to the current state machine. Triggering an event on a state machine means invoking or sending a signal, initiating the process that may result in executing a transition. """ # The `__call__` is declared here to help IDEs knowing that an `Event` # can be called as a method. But it is not meant to be called without # an SM instance. Such SM instance is provided by `__get__` method when # used as a property descriptor. trigger_data = self.put(*args, **kwargs) return self._sm._processing_loop(trigger_data.future) # type: ignore[union-attr] def split( # type: ignore[override] self, sep: "str | None" = None, maxsplit: int = -1 ) -> List["Event"]: result = super().split(sep, maxsplit) if len(result) == 1: return [self] return [Event(event) for event in result] def match(self, event: str) -> bool: if self == "*": return True # Normalize descriptor by removing trailing '.*' or '.' # to handle cases like 'error', 'error.', 'error.*' descriptor = cast(str, self) if descriptor.endswith(".*"): descriptor = descriptor[:-2] elif descriptor.endswith("."): descriptor = descriptor[:-1] # Check prefix match: # The descriptor must be a prefix of the event. # Split both descriptor and event into tokens descriptor_tokens = descriptor.split(".") if descriptor else [] event_tokens = event.split(".") if event else [] if len(descriptor_tokens) > len(event_tokens): return False for d_token, e_token in zip(descriptor_tokens, event_tokens): # noqa: B905 if d_token != e_token: return False return True class BoundEvent(Event): pass ================================================ FILE: statemachine/event_data.py ================================================ from dataclasses import dataclass from dataclasses import field from time import time from typing import TYPE_CHECKING from typing import Any if TYPE_CHECKING: from .event import Event from .state import State from .statemachine import StateChart from .transition import Transition @dataclass(order=True) class TriggerData: machine: "StateChart" = field(compare=False) event: "Event | None" = field(compare=False) """The Event that was triggered.""" send_id: "str | None" = field(compare=False, default=None) """A string literal to be used as the id of this instance of :ref:`TriggerData`. Allow revoking a delayed :ref:`TriggerData` instance. """ execution_time: float = field(default=0.0) """The time at which the :ref:`Event` should run.""" model: Any = field(init=False, compare=False) """A reference to the underlying model that holds the current :ref:`State`.""" args: tuple = field(default_factory=tuple, compare=False) """All positional arguments provided on the :ref:`Event`.""" kwargs: dict = field(default_factory=dict, compare=False) """All keyword arguments provided on the :ref:`Event`.""" future: Any = field(default=None, compare=False, repr=False, init=False) """An optional :class:`asyncio.Future` for async result routing. When set, the processing loop will resolve this future with the microstep result (or exception), allowing the caller to ``await`` it. """ def __post_init__(self): self.model = self.machine.model delay = self.event.delay if self.event and self.event.delay else 0 self.execution_time = time() + (delay / 1000) @dataclass class EventData: trigger_data: TriggerData """The :ref:`TriggerData` of the :ref:`event`.""" transition: "Transition" """The :ref:`Transition` instance that was activated by the :ref:`Event`.""" state: "State" = field(init=False) """The current :ref:`State` of the :ref:`statemachine`.""" source: "State" = field(init=False) """The :ref:`State` which :ref:`statemachine` was in when the Event started.""" target: "State | None" = field(init=False) """The destination :ref:`State` of the :ref:`transition`, or ``None`` for targetless.""" def __post_init__(self): self.state = self.transition.source self.source = self.transition.source self.target = self.transition.target self.machine = self.trigger_data.machine @property def event(self): return self.trigger_data.event @property def args(self): return self.trigger_data.args @property def extended_kwargs(self): kwargs = self.trigger_data.kwargs.copy() kwargs["event_data"] = self kwargs["machine"] = self.trigger_data.machine kwargs["event"] = self.trigger_data.event kwargs["model"] = self.trigger_data.model kwargs["transition"] = self.transition kwargs["state"] = self.state kwargs["source"] = self.source kwargs["target"] = self.target return kwargs ================================================ FILE: statemachine/events.py ================================================ from .event import Event from .utils import ensure_iterable class Events: """A collection of event names.""" def __init__(self): self._items: list[Event] = [] def __str__(self): sep = " " if len(self._items) > 1 else "" return sep.join(item for item in self._items) def __repr__(self): return f"{self._items!r}" def __iter__(self): return iter(self._items) def add(self, events): if events is None: return self unprepared = ensure_iterable(events) for events in unprepared: for event in events.split(" "): if event in self._items: continue if isinstance(event, Event): self._items.append(event) else: self._items.append(Event(id=event)) return self def match(self, event: "str | None"): if event is None: return self.is_empty return any(e.match(event) for e in self) def _replace(self, old, new): self._items.remove(old) self._items.append(new) @property def is_empty(self): return len(self._items) == 0 ================================================ FILE: statemachine/exceptions.py ================================================ from typing import TYPE_CHECKING from typing import MutableSet from .i18n import _ if TYPE_CHECKING: from .event import Event from .state import State class StateMachineError(Exception): "Base exception for this project, all exceptions that can be raised inherit from this class." class InvalidDefinition(StateMachineError): "The state machine has a definition error" class InvalidStateValue(InvalidDefinition): "The current model state value is not mapped to a state definition." def __init__(self, value, msg=None): self.value = value if msg is None: msg = _("{!r} is not a valid state value.").format(value) super().__init__(msg) class AttrNotFound(InvalidDefinition): "There's no method or property with the given name" class TransitionNotAllowed(StateMachineError): "Raised when there's no transition that can run from the current :ref:`configuration`." def __init__(self, event: "Event | None", configuration: MutableSet["State"]): self.event = event self.configuration = configuration name = ", ".join([s.name for s in configuration]) msg = _("Can't {} when in {}.").format( self.event and self.event.name or "transition", name ) super().__init__(msg) ================================================ FILE: statemachine/factory.py ================================================ import re from typing import Any from typing import Dict from typing import List from typing import Optional from typing import Tuple from . import registry from .callbacks import CallbackGroup from .callbacks import CallbackPriority from .callbacks import CallbackSpecList from .event import Event from .event import _expand_event_id from .exceptions import InvalidDefinition from .graph import disconnected_states from .graph import iterate_states from .graph import iterate_states_and_transitions from .graph import states_without_path_to_final_states from .i18n import _ from .state import State from .states import States from .transition import Transition from .transition_list import TransitionList class StateMachineMetaclass(type): "Metaclass for constructing StateMachine classes" validate_disconnected_states: bool = True """If `True`, the state machine will validate that there are no unreachable states.""" validate_trap_states: bool = True """If ``True``, non-final states without outgoing transitions raise ``InvalidDefinition``.""" validate_final_reachability: bool = True """If ``True`` and final states exist, non-final states without a path to any final state raise ``InvalidDefinition``.""" def __init__( cls, name: str, bases: Tuple[type], attrs: Dict[str, Any], ) -> None: super().__init__(name, bases, attrs) registry.register(cls) cls.name = cls.__name__ cls.id = cls.name.lower() # TODO: Experiment with the IDEA of a root state # cls.root = State(id=cls.id, name=cls.name) cls.states: States = States() cls.states_map: Dict[Any, State] = {} """Map of ``state.value`` to the corresponding :ref:`state`.""" cls._abstract = True cls._events: Dict[Event, None] = {} # used Dict to preserve order and avoid duplicates cls._protected_attrs: set = set() cls._events_to_update: Dict[Event, Optional[Event]] = {} cls._specs = CallbackSpecList() cls.prepare = cls._specs.grouper(CallbackGroup.PREPARE).add( "prepare_event", priority=CallbackPriority.GENERIC, is_convention=True ) cls.add_inherited(bases) cls.add_from_attributes(attrs) cls._collect_class_listeners(attrs, bases) cls._unpack_builders_callbacks() cls._update_event_references() if not cls.states: return cls._initials_by_document_order(list(cls.states), parent=None) initials = [s for s in cls.states if s.initial] parallels = [s.id for s in cls.states if s.parallel] root_only_has_parallels = len(cls.states) == len(parallels) if len(initials) != 1 and not root_only_has_parallels: raise InvalidDefinition( _( "There should be one and only one initial state. " "Your currently have these: {0}" ).format(", ".join(s.id for s in initials)) ) if initials: cls.initial_state = initials[0] else: # pragma: no cover cls.initial_state = None cls.final_states: List[State] = [state for state in cls.states if state.final] cls._check() cls._setup() cls._expand_docstring() _STATECHART_RE = re.compile(r"\{statechart:(\w+)\}") def _expand_docstring(cls) -> None: """Replace ``{statechart:FORMAT}`` placeholders in the class docstring.""" doc = cls.__doc__ if not doc: return from .contrib.diagram.formatter import formatter def _replace(match: "re.Match[str]") -> str: fmt = match.group(1) rendered = formatter.render(cls, fmt) # type: ignore[arg-type] # Respect the indentation of the placeholder line. line_start = doc.rfind("\n", 0, match.start()) if line_start == -1: indent = "" else: indent_match = re.match(r"[ \t]*", doc[line_start + 1 : match.start()]) indent = indent_match.group() if indent_match else "" if indent: lines = rendered.split("\n") rendered = lines[0] + "\n" + "\n".join(indent + line for line in lines[1:]) return rendered cls.__doc__ = cls._STATECHART_RE.sub(_replace, doc) def __format__(cls, fmt: str) -> str: from .contrib.diagram.formatter import formatter return formatter.render(cls, fmt) # type: ignore[arg-type] def _initials_by_document_order( # noqa: C901 cls, states: List[State], parent: "State | None" = None, order: int = 1 ): """Set initial state by document order if no explicit initial state is set""" initials: List[State] = [] for s in states: s.document_order = order order += 1 if s.states: cls._initials_by_document_order(s.states, s, order) if s.initial: initials.append(s) if not initials and states: initial = states[0] initial._initial = True initials.append(initial) if not parent: return # If parent already has a multi-target initial transition (e.g., from SCXML initial # attribute targeting multiple parallel regions), don't create default initial transitions. if any(t for t in parent.transitions if t.initial and len(t.targets) > 1): return for initial in initials: if not any(t for t in parent.transitions if t.initial and t.target == initial): parent.to(initial, initial=True) if not parent.parallel: return for state in states: state._initial = True if not any(t for t in parent.transitions if t.initial and t.target == state): parent.to(state, initial=True) # pragma: no cover def _unpack_builders_callbacks(cls): callbacks = {} for state in iterate_states(cls.states): if state._callbacks: callbacks.update(state._callbacks) del state._callbacks for key, value in callbacks.items(): setattr(cls, key, value) def _check(cls): has_states = bool(cls.states) cls._abstract = not has_states # do not validate the base abstract classes if cls._abstract: # pragma: no cover return cls._check_initial_state() cls._check_final_states() cls._check_disconnected_state() cls._check_trap_states() cls._check_reachable_final_states() def _check_initial_state(cls): initials = [s for s in cls.states if s.initial] if len(initials) != 1: # pragma: no cover raise InvalidDefinition( _( "There should be one and only one initial state. " "You currently have these: {!r}" ).format([s.id for s in initials]) ) # TODO: Check if this is still needed # if not initials[0].transitions.transitions: # raise InvalidDefinition(_("There are no transitions.")) def _check_final_states(cls): final_state_with_invalid_transitions = [ state for state in cls.final_states if state.transitions ] if final_state_with_invalid_transitions: raise InvalidDefinition( _("Cannot declare transitions from final state. Invalid state(s): {}").format( [s.id for s in final_state_with_invalid_transitions] ) ) def _check_trap_states(cls): if not cls.validate_trap_states: return trap_states = [s for s in cls.states if not s.final and not s.transitions] if trap_states: raise InvalidDefinition( _( "All non-final states should have at least one outgoing transition. " "These states have no outgoing transition: {!r}" ).format([s.id for s in trap_states]) ) def _check_reachable_final_states(cls): if not cls.validate_final_reachability: return if not any(s.final for s in cls.states): return # No need to check final reachability disconnected_states = list(states_without_path_to_final_states(cls.states)) if disconnected_states: raise InvalidDefinition( _( "All non-final states should have at least one path to a final state. " "These states have no path to a final state: {!r}" ).format([s.id for s in disconnected_states]) ) def _check_disconnected_state(cls): if not cls.validate_disconnected_states: return assert cls.initial_state states = disconnected_states(cls.initial_state, set(cls.states_map.values())) if states: raise InvalidDefinition( _( "There are unreachable states. " "The statemachine graph should have a single component. " "Disconnected states: {}" ).format([s.id for s in states]) ) def _setup(cls): for visited in iterate_states_and_transitions(cls.states): visited._setup() cls._protected_attrs = { "_abstract", "model", "state_field", "start_value", "initial_state", "final_states", "states", "_events", "states_map", "send", } | {s.id for s in cls.states} def _collect_class_listeners(cls, attrs: Dict[str, Any], bases: Tuple[type]): """Collect class-level listener declarations from attrs and MRO. Listeners declared on parent classes are prepended (MRO order), unless the child sets ``listeners_inherit = False``. """ class_listeners: List[Any] = [] if attrs.get("listeners_inherit", True): for base in reversed(bases): class_listeners.extend(getattr(base, "_class_listeners", [])) for entry in attrs.get("listeners", []): if entry is None or isinstance(entry, (str, int, float, bool)): raise InvalidDefinition( _( "Invalid entry in 'listeners': {!r}. " "Expected a class, callable, or listener instance." ).format(entry) ) class_listeners.append(entry) cls._class_listeners: List[Any] = class_listeners def add_inherited(cls, bases): for base in bases: for state in getattr(base, "states", []): cls.add_state(state.id, state) events = getattr(base, "_events", {}) for event in events: cls.add_event(event=Event(id=event.id, name=event.name)) def add_from_attributes(cls, attrs): # noqa: C901 for key, value in attrs.items(): if isinstance(value, States): cls._add_states_from_dict(value) if isinstance(value, State): cls.add_state(key, value) elif isinstance(value, (Transition, TransitionList)): event_id = _expand_event_id(key) cls.add_event(event=Event(transitions=value, id=event_id)) elif isinstance(value, (Event,)): if value._has_real_id: event_id = value.id else: event_id = _expand_event_id(key) new_event = Event( transitions=value._transitions, id=event_id, name=value.name, ) cls.add_event(event=new_event, old_event=value) # Ensure the event is accessible by the Python attribute name if event_id != key: setattr(cls, key, new_event) elif getattr(value, "attr_name", None): cls._add_unbounded_callback(key, value) def _add_states_from_dict(cls, states): for state_id, state in states.items(): cls.add_state(state_id, state) def _add_unbounded_callback(cls, attr_name, func): # if func is an event, the `attr_name` will be replaced by an event trigger, # so we'll also give the ``func`` a new unique name to be used by the callback # machinery that is stored at ``func.attr_name`` setattr(cls, func.attr_name, func) if func.is_event: cls.add_event(event=Event(func._transitions, id=attr_name)) def add_state(cls, id, state: State): state._set_id(id) cls.states_map[state.value] = state if not state.parent: cls.states.append(state) if not hasattr(cls, id): setattr(cls, id, state) # also register all events associated directly with transitions for event in state.transitions.unique_events: cls.add_event(event) for substate in state.states: cls.add_state(substate.id, substate) def add_event( cls, event: Event, old_event: "Event | None" = None, ): if not event._has_real_id: if event not in cls._events_to_update: cls._events_to_update[event] = None return transitions = event._transitions if transitions is not None: transitions._on_event_defined(event=event, states=list(cls.states)) if event not in cls._events: cls._events[event] = None setattr(cls, event.id, event) if old_event is not None: cls._events_to_update[old_event] = event return cls._events[event] def _update_event_references(cls): for old_event, new_event in cls._events_to_update.items(): for state in cls.states: for transition in state.transitions: if transition._events.match(old_event): if new_event is None: raise InvalidDefinition( _("An event in the '{}' has no id.").format(transition) ) transition.events._replace(old_event, new_event) cls._events_to_update = {} @property def events(self): return list(self._events) ================================================ FILE: statemachine/graph.py ================================================ from collections import deque from typing import TYPE_CHECKING from typing import Iterable from typing import MutableSet if TYPE_CHECKING: from .state import State def visit_connected_states(state: "State"): visit = deque["State"]() already_visited = set() visit.append(state) while visit: state = visit.popleft() if state in already_visited: continue already_visited.add(state) yield state visit.extend(t.target for t in state.transitions if t.target) # Traverse the state hierarchy: entering a compound/parallel state # implicitly enters its initial children (all children for parallel). for child in state.states: if child.initial: visit.append(child) for child in state.history: visit.append(child) # Being in a child state implies being in all ancestor states. if state.parent: visit.append(state.parent) def disconnected_states(starting_state: "State", all_states: MutableSet["State"]): visitable_states = set(visit_connected_states(starting_state)) return all_states - visitable_states def iterate_states_and_transitions(states: Iterable["State"]): for state in states: yield state yield from state.transitions if state.states: yield from iterate_states_and_transitions(state.states) if state.history: yield from iterate_states_and_transitions(state.history) def iterate_states(states: Iterable["State"]): for state in states: yield state if state.states: yield from iterate_states(state.states) if state.history: yield from iterate_states(state.history) def states_without_path_to_final_states(states: Iterable["State"]): return ( state for state in states if not state.final and not any(s.final for s in visit_connected_states(state)) ) ================================================ FILE: statemachine/i18n.py ================================================ import gettext from pathlib import Path script_dir = Path(__file__).resolve().parent locale_dir = script_dir / "locale" def setup_i18n(): translate = gettext.translation("statemachine", locale_dir, fallback=True) gettext.bindtextdomain("statemachine", locale_dir) gettext.textdomain("statemachine") return translate.gettext _ = setup_i18n() ================================================ FILE: statemachine/invoke.py ================================================ """Invoke support for StateCharts. Invoke lets a state spawn external work (API calls, file I/O, child state machines) when entered, and cancel it when exited. Invoke is modelled as a callback group (``CallbackGroup.INVOKE``) so that convention naming (``on_invoke_``), decorators (``@state.invoke``), and inline callables all work out of the box. """ import asyncio import threading import uuid from concurrent.futures import Future from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from dataclasses import field from typing import TYPE_CHECKING from typing import Any from typing import Callable from typing import Dict from typing import List from typing import Tuple from typing import runtime_checkable try: from typing import Protocol except ImportError: # pragma: no cover from typing_extensions import Protocol # type: ignore[assignment] if TYPE_CHECKING: from .callbacks import CallbackWrapper from .engines.base import BaseEngine from .state import State from .statemachine import StateChart @runtime_checkable class IInvoke(Protocol): """Protocol for advanced invoke handlers. Implement ``run(ctx)`` to execute work when a state is entered. Optionally implement ``on_cancel()`` for cleanup when the state is exited. """ def run(self, ctx: "InvokeContext") -> Any: ... # pragma: no branch def _stop_child_machine(child: "StateChart | None") -> None: """Stop a child state machine and cancel all its invocations.""" if child is None: return child._engine.stop() class _InvokeCallableWrapper: """Wraps an IInvoke class/instance or StateChart class for the callback system. The callback resolution system expects plain callables or strings. This wrapper makes IInvoke classes, IInvoke instances, and StateChart classes look like regular callables while preserving the original object for the InvokeManager to detect. When ``_invoke_handler`` is a **class**, ``run()`` instantiates it on each call so that each StateChart instance gets its own handler — avoiding shared mutable state between machines. """ def __init__(self, handler: Any): self._invoke_handler = handler self._is_class = isinstance(handler, type) self._instance: Any = None name = getattr(handler, "__name__", type(handler).__name__) self.__name__ = name self.__qualname__ = getattr(handler, "__qualname__", name) # The callback system inspects __code__ for caching (signature.py) self.__code__ = self.__call__.__code__ def __call__(self, **kwargs): return self._invoke_handler def run(self, ctx: "InvokeContext") -> Any: """Create a fresh instance (if class) and delegate to its ``run()``.""" handler = self._invoke_handler if self._is_class: handler = handler() self._instance = handler return handler.run(ctx) def on_cancel(self): """Delegate to the live instance's ``on_cancel()`` if available.""" if self._instance is not None: target = self._instance elif self._is_class: return # Handler hasn't been instantiated yet — nothing to cancel else: target = self._invoke_handler if hasattr(target, "on_cancel"): target.on_cancel() def normalize_invoke_callbacks(invoke: Any) -> Any: """Wrap IInvoke instances and StateChart classes so the callback system can handle them. Plain callables and strings pass through unchanged. """ if invoke is None: return None from .utils import ensure_iterable items = ensure_iterable(invoke) result = [] for item in items: if _needs_wrapping(item): result.append(_InvokeCallableWrapper(item)) else: result.append(item) return result def _needs_wrapping(item: Any) -> bool: """Check if an item needs wrapping for the callback system.""" if isinstance(item, str): return False if isinstance(item, _InvokeCallableWrapper): return False # IInvoke instance (already instantiated — kept for advanced use / SCXML adapter) if isinstance(item, IInvoke): return True if isinstance(item, type): from .statemachine import StateChart # StateChart subclass → child machine invoker if issubclass(item, StateChart): return True return False @dataclass class InvokeContext: """Context passed to invoke handlers.""" invokeid: str """Unique identifier for this invocation.""" state_id: str """The id of the state that triggered this invocation.""" send: "Callable[..., None]" """``send(event, **data)`` — enqueue an event on the parent machine's external queue.""" machine: "StateChart" """Reference to the parent state machine.""" cancelled: threading.Event = field(default_factory=threading.Event) """Set when the owning state is exited; handlers should check this to stop early.""" kwargs: dict = field(default_factory=dict) """Keyword arguments from the event that triggered the state entry.""" @dataclass class Invocation: """Tracks a single active invocation.""" invokeid: str state_id: str ctx: InvokeContext thread: "threading.Thread | None" = None task: "asyncio.Task[Any] | None" = None terminated: bool = False _handler: Any = None class StateChartInvoker: """Wraps a :class:`StateChart` subclass as an :class:`IInvoke` handler. When ``run(ctx)`` is called, it instantiates and runs the child machine synchronously. The child machine's final result (if any) becomes the return value. """ def __init__(self, child_class: "type[StateChart]"): self._child_class = child_class self._child: "StateChart | None" = None def run(self, _ctx: "InvokeContext") -> Any: self._child = self._child_class() # The child machine starts automatically in its constructor. # If it has final states, it will terminate on its own. return None def on_cancel(self): _stop_child_machine(self._child) self._child = None class InvokeGroup: """Runs multiple callables concurrently and returns their results as a list. All callables are submitted to a :class:`~concurrent.futures.ThreadPoolExecutor`. The handler blocks until every callable completes, then returns a list of results in the same order as the input callables. If the owning state is exited before all callables finish, the remaining futures are cancelled. If any callable raises, the remaining futures are cancelled and the exception propagates (which causes an ``error.execution`` event). """ def __init__(self, callables: "List[Callable[..., Any]]"): self._callables = list(callables) self._futures: "List[Future[Any]]" = [] self._executor: "ThreadPoolExecutor | None" = None def run(self, ctx: "InvokeContext") -> "List[Any]": results: "List[Any]" = [None] * len(self._callables) self._executor = ThreadPoolExecutor(max_workers=len(self._callables)) try: self._futures = [self._executor.submit(fn) for fn in self._callables] for idx, future in enumerate(self._futures): # Poll so we can react to cancellation promptly. while not future.done(): if ctx.cancelled.is_set(): self._cancel_remaining() return [] ctx.cancelled.wait(timeout=0.05) results[idx] = future.result() # re-raises if the callable failed except Exception: self._cancel_remaining() raise finally: # Normal exit: all futures completed, safe to shutdown without waiting. self._executor.shutdown(wait=False) return results def on_cancel(self): # Called from the engine thread — must not block. Cancel pending futures # and signal shutdown; the invoke thread's run() will detect ctx.cancelled # and exit, then _cancel()'s thread.join() waits for the actual cleanup. self._cancel_remaining() if self._executor is not None: self._executor.shutdown(wait=False, cancel_futures=True) def _cancel_remaining(self): for future in self._futures: if not future.done(): future.cancel() def invoke_group(*callables: "Callable[..., Any]") -> InvokeGroup: """Group multiple callables into a single invoke that runs them concurrently. Returns an :class:`InvokeGroup` instance (implements :class:`IInvoke`). When all callables complete, a single ``done.invoke`` event is sent with ``data`` set to a list of results in the same order as the input callables. Example:: loading = State(initial=True, invoke=invoke_group(fetch_users, fetch_config)) def on_enter_ready(self, data=None, **kwargs): users, config = data """ return InvokeGroup(list(callables)) class InvokeManager: """Manages the lifecycle of invoke handlers for a state machine engine. Tracks which states need invocation after entry, spawns handlers (in threads for sync, as tasks for async), and cancels them on exit. """ def __init__(self, engine: "BaseEngine"): self._engine = engine self._active: Dict[str, Invocation] = {} self._pending: "List[Tuple[State, dict]]" = [] @property def _debug(self): return self._engine._debug @property def _log_id(self): return self._engine._log_id @property def sm(self) -> "StateChart": return self._engine.sm # --- Engine hooks --- def mark_for_invoke(self, state: "State", event_kwargs: "dict | None" = None): """Called by ``_enter_states()`` after entering a state with invoke callbacks. Args: state: The state that was entered. event_kwargs: Keyword arguments from the event that triggered the state entry. These are forwarded to invoke handlers via dependency injection (plain callables) and ``InvokeContext.kwargs`` (IInvoke handlers). """ self._pending.append((state, event_kwargs or {})) def cancel_for_state(self, state: "State"): """Called by ``_exit_states()`` before exiting a state.""" self._debug("%s invoke cancel_for_state: %s", self._log_id, state.id) for inv_id, inv in list(self._active.items()): if inv.state_id == state.id and not inv.ctx.cancelled.is_set(): self._cancel(inv_id) self._pending = [(s, kw) for s, kw in self._pending if s.id != state.id] # Don't cleanup here — terminated invocations must stay in _active # so that handle_external_event can still run finalize blocks for # done.invoke events that are already queued. def cancel_all(self): """Cancel all active invocations.""" self._debug("%s invoke cancel_all: %d active", self._log_id, len(self._active)) for inv_id in list(self._active.keys()): self._cancel(inv_id) self._cleanup_terminated() def _cleanup_terminated(self): """Remove invocations whose threads/tasks have actually finished. Only removes invocations that are both terminated AND cancelled. A terminated-but-not-cancelled invocation means the handler's ``run()`` has returned but the owning state is still active — the invocation must stay in ``_active`` so that ``send_to_child()`` can still forward events to it (e.g. ````). """ self._active = { inv_id: inv for inv_id, inv in self._active.items() if not inv.terminated or not inv.ctx.cancelled.is_set() } # --- Sync spawning --- def spawn_pending_sync(self): """Spawn invoke handlers for all states marked for invocation (sync engine).""" # Opportunistically clean up finished invocations before spawning new ones. self._cleanup_terminated() pending = sorted(self._pending, key=lambda p: p[0].document_order) self._pending.clear() for state, event_kwargs in pending: self.sm._callbacks.visit( state.invoke.key, self._spawn_one_sync, state=state, event_kwargs=event_kwargs, ) def _spawn_one_sync(self, callback: "CallbackWrapper", **kwargs): state: "State" = kwargs["state"] event_kwargs: dict = kwargs.get("event_kwargs", {}) # Use meta.func to find the original (unwrapped) handler; the callback # system wraps everything in a signature_adapter closure. handler = self._resolve_handler(callback.meta.func) ctx = self._make_context(state, event_kwargs, handler=handler) invocation = Invocation(invokeid=ctx.invokeid, state_id=state.id, ctx=ctx) invocation._handler = handler self._active[ctx.invokeid] = invocation self._debug("%s invoke spawn sync: %s on state %s", self._log_id, ctx.invokeid, state.id) thread = threading.Thread( target=self._run_sync_handler, args=(callback, handler, ctx, invocation), daemon=True, ) invocation.thread = thread thread.start() def _run_sync_handler( self, callback: "CallbackWrapper", handler: "Any | None", ctx: InvokeContext, invocation: Invocation, ): try: if handler is not None: result = handler.run(ctx) else: result = callback.call(ctx=ctx, machine=ctx.machine, **ctx.kwargs) if not ctx.cancelled.is_set(): self.sm.send( f"done.invoke.{ctx.invokeid}", data=result, ) except Exception as e: if not ctx.cancelled.is_set(): # Intentionally using the external queue (no internal=True): # This handler runs in a background thread, outside the processing # loop. Using the internal queue would either contaminate an # unrelated macrostep in progress, or stall if no macrostep is # active (the internal queue is only drained within a macrostep). # This matches done.invoke, which also uses the external queue. self.sm.send("error.execution", error=e) finally: invocation.terminated = True self._debug( "%s invoke %s: completed (cancelled=%s)", self._log_id, ctx.invokeid, ctx.cancelled.is_set(), ) # --- Async spawning --- async def spawn_pending_async(self): """Spawn invoke handlers for all states marked for invocation (async engine).""" # Opportunistically clean up finished invocations before spawning new ones. self._cleanup_terminated() pending = sorted(self._pending, key=lambda p: p[0].document_order) self._pending.clear() for state, event_kwargs in pending: await self.sm._callbacks.async_visit( state.invoke.key, self._spawn_one_async, state=state, event_kwargs=event_kwargs, ) def _spawn_one_async(self, callback: "CallbackWrapper", **kwargs): state: "State" = kwargs["state"] event_kwargs: dict = kwargs.get("event_kwargs", {}) handler = self._resolve_handler(callback.meta.func) ctx = self._make_context(state, event_kwargs, handler=handler) invocation = Invocation(invokeid=ctx.invokeid, state_id=state.id, ctx=ctx) invocation._handler = handler self._active[ctx.invokeid] = invocation self._debug("%s invoke spawn async: %s on state %s", self._log_id, ctx.invokeid, state.id) loop = asyncio.get_running_loop() task = loop.create_task(self._run_async_handler(callback, handler, ctx, invocation)) invocation.task = task async def _run_async_handler( self, callback: "CallbackWrapper", handler: "Any | None", ctx: InvokeContext, invocation: Invocation, ): try: loop = asyncio.get_running_loop() if handler is not None: # Run handler.run(ctx) in a thread executor so blocking I/O # doesn't freeze the event loop. result = await loop.run_in_executor(None, handler.run, ctx) else: result = await loop.run_in_executor( None, lambda: callback.call(ctx=ctx, machine=ctx.machine, **ctx.kwargs) ) if not ctx.cancelled.is_set(): await self.sm.send( f"done.invoke.{ctx.invokeid}", data=result, ) except asyncio.CancelledError: # Intentionally swallowed: the owning state was exited, so this # invocation was cancelled — there is nothing to propagate. return except Exception as e: if not ctx.cancelled.is_set(): # External queue — see comment in _run_sync_handler. await self.sm.send("error.execution", error=e) finally: invocation.terminated = True self._debug( "%s invoke %s: completed (cancelled=%s)", self._log_id, ctx.invokeid, ctx.cancelled.is_set(), ) # --- Cancel --- def _cancel(self, invokeid: str): invocation = self._active.get(invokeid) if not invocation or invocation.ctx.cancelled.is_set(): return self._debug("%s invoke cancel: %s", self._log_id, invokeid) # 1) Signal cancellation so the handler can check and stop early. invocation.ctx.cancelled.set() # 2) Notify the handler (may stop child SMs, cancel futures, etc.). handler = invocation._handler if handler is not None and hasattr(handler, "on_cancel"): try: handler.on_cancel() except Exception: self._debug("%s Error in on_cancel for %s", self._log_id, invokeid, exc_info=True) # 3) Cancel the async task (raises CancelledError at next await). if invocation.task is not None and not invocation.task.done(): invocation.task.cancel() # 4) Wait for the sync thread to actually finish (skip if we ARE # that thread — e.g. done.invoke processed from within the handler). if ( invocation.thread is not None and invocation.thread is not threading.current_thread() and invocation.thread.is_alive() ): invocation.thread.join(timeout=2.0) def send_to_child(self, invokeid: str, event: str, **data) -> bool: """Send an event to an invoked child session by its invokeid. Returns True if the event was forwarded, False if the invocation was not found or doesn't support event forwarding. """ invocation = self._active.get(invokeid) if invocation is None: return False handler = invocation._handler if handler is not None and hasattr(handler, "on_event"): handler.on_event(event, **data) return True return False # --- Helpers --- def handle_external_event(self, trigger_data) -> None: """Run finalize blocks and autoforward for active invocations. Called by the engine before processing each external event. For each active invocation whose handler has ``on_finalize`` or ``on_event`` (autoforward), delegate accordingly. """ event_name = str(trigger_data.event) if trigger_data.event else None if event_name is None: return # Tag done.invoke events with the invokeid if event_name.startswith("done.invoke."): invokeid = event_name[len("done.invoke.") :] trigger_data.kwargs.setdefault("_invokeid", invokeid) for inv in list(self._active.values()): handler = inv._handler if handler is None: continue # Check if event originates from this invocation is_from_child = trigger_data.kwargs.get( "_invokeid" ) == inv.invokeid or event_name.startswith(f"done.invoke.{inv.invokeid}") # Finalize: run the finalize block if the event came from this invocation. # Note: finalize must run even after the invocation terminates, because # child events may still be queued when the handler thread completes. if is_from_child and hasattr(handler, "on_finalize"): handler.on_finalize(trigger_data) # Autoforward: forward parent events to child (not events from child itself). # Only forward if the invocation is still running. if ( not inv.terminated and not inv.ctx.cancelled.is_set() and not is_from_child and hasattr(handler, "autoforward") and handler.autoforward and hasattr(handler, "on_event") ): self._debug( "%s invoke autoforward: %s -> %s", self._log_id, event_name, inv.invokeid ) handler.on_event(event_name, **trigger_data.kwargs) def _make_context( self, state: "State", event_kwargs: "dict | None" = None, handler: Any = None ) -> InvokeContext: # Use static invoke_id from handler if available (SCXML id= attribute) static_id = getattr(handler, "invoke_id", None) if handler else None invokeid = static_id or f"{state.id}.{uuid.uuid4().hex[:8]}" return InvokeContext( invokeid=invokeid, state_id=state.id, send=self.sm.send, machine=self.sm, kwargs=event_kwargs or {}, ) @staticmethod def _resolve_handler(underlying: Any) -> "Any | None": """Determine the handler type from the resolved callable.""" from .statemachine import StateChart if isinstance(underlying, _InvokeCallableWrapper): inner = underlying._invoke_handler if isinstance(inner, type) and issubclass(inner, StateChart): return StateChartInvoker(inner) # Return the inner handler directly if it's an IInvoke instance # (e.g., SCXMLInvoker) so duck-typed attributes like invoke_id are accessible. # Exclude classes — @runtime_checkable matches classes that define run(). if not isinstance(inner, type) and isinstance(inner, IInvoke): return inner return underlying if isinstance(underlying, IInvoke): return underlying if isinstance(underlying, type) and issubclass(underlying, StateChart): return StateChartInvoker(underlying) return None ================================================ FILE: statemachine/io/__init__.py ================================================ from typing import Any from typing import Dict from typing import List from typing import Mapping from typing import Protocol from typing import Sequence from typing import Tuple from typing import TypedDict from typing import cast from ..factory import StateMachineMetaclass from ..state import HistoryState from ..state import State from ..statemachine import StateChart from ..transition import Transition from ..transition_list import TransitionList class ActionProtocol(Protocol): # pragma: no cover def __call__(self, *args, **kwargs) -> Any: ... class TransitionDict(TypedDict, total=False): target: "str | None" event: "str | None" internal: bool initial: bool validators: bool cond: "str | ActionProtocol | Sequence[str] | Sequence[ActionProtocol]" unless: "str | ActionProtocol | Sequence[str] | Sequence[ActionProtocol]" on: "str | ActionProtocol | Sequence[str] | Sequence[ActionProtocol]" before: "str | ActionProtocol | Sequence[str] | Sequence[ActionProtocol]" after: "str | ActionProtocol | Sequence[str] | Sequence[ActionProtocol]" TransitionsDict = Dict["str | None", List[TransitionDict]] TransitionsList = List[TransitionDict] class BaseStateKwargs(TypedDict, total=False): name: str value: Any initial: bool final: bool parallel: bool enter: "str | ActionProtocol | Sequence[str] | Sequence[ActionProtocol]" exit: "str | ActionProtocol | Sequence[str] | Sequence[ActionProtocol]" donedata: "ActionProtocol | None" class StateKwargs(BaseStateKwargs, total=False): states: List[State] history: List[HistoryState] class HistoryKwargs(TypedDict, total=False): name: str value: Any type: str class HistoryDefinition(HistoryKwargs, total=False): on: TransitionsDict transitions: TransitionsList class StateDefinition(BaseStateKwargs, total=False): states: Dict[str, "StateDefinition"] history: Dict[str, "HistoryDefinition"] on: TransitionsDict transitions: TransitionsList def _parse_history( states: Mapping[str, "HistoryKwargs |HistoryDefinition"], ) -> Tuple[Dict[str, HistoryState], Dict[str, dict]]: states_instances: Dict[str, HistoryState] = {} events_definitions: Dict[str, dict] = {} for state_id, state_definition in states.items(): state_definition = cast(HistoryDefinition, state_definition) transition_defs = state_definition.pop("on", {}) transition_list = state_definition.pop("transitions", []) if transition_list: transition_defs[None] = transition_list if transition_defs: events_definitions[state_id] = transition_defs state_definition = cast(HistoryKwargs, state_definition) states_instances[state_id] = HistoryState(**state_definition) return (states_instances, events_definitions) def _parse_states( states: Mapping[str, "BaseStateKwargs | StateDefinition"], ) -> Tuple[Dict[str, State], Dict[str, dict]]: states_instances: Dict[str, State] = {} events_definitions: Dict[str, dict] = {} for state_id, state_definition in states.items(): # Process nested states. Replaces `states` as a definition by a list of `State` instances. state_definition = cast(StateDefinition, state_definition) # pop the nested states, history and transitions definitions inner_states_defs: Dict[str, StateDefinition] = state_definition.pop("states", {}) inner_history_defs: Dict[str, HistoryDefinition] = state_definition.pop("history", {}) transition_defs = state_definition.pop("on", {}) transition_list = state_definition.pop("transitions", []) if transition_list: transition_defs[None] = transition_list if inner_states_defs: inner_states, inner_events = _parse_states(inner_states_defs) top_level_states = [ state._set_id(state_id) for state_id, state in inner_states.items() if not state.parent ] state_definition["states"] = top_level_states # type: ignore states_instances.update(inner_states) events_definitions.update(inner_events) if inner_history_defs: inner_history, inner_events = _parse_history(inner_history_defs) top_level_history = [ state._set_id(state_id) for state_id, state in inner_history.items() if not state.parent ] state_definition["history"] = top_level_history # type: ignore states_instances.update(inner_history) events_definitions.update(inner_events) if transition_defs: events_definitions[state_id] = transition_defs state_definition = cast(BaseStateKwargs, state_definition) states_instances[state_id] = State(**state_definition) return (states_instances, events_definitions) def create_machine_class_from_definition( name: str, states: Mapping[str, "StateKwargs | StateDefinition"], **definition ) -> "type[StateChart]": # noqa: C901 """Create a StateChart class dynamically from a dictionary definition. Args: name: The class name for the generated state machine. states: A mapping of state IDs to state definitions. Each state definition can include ``initial``, ``final``, ``parallel``, ``name``, ``value``, ``enter``/``exit`` callbacks, ``donedata``, nested ``states``, ``history``, and transitions via ``on`` (event-triggered) or ``transitions`` (eventless). **definition: Additional keyword arguments passed to the metaclass (e.g., ``validate_final_reachability=False``). Returns: A new StateChart subclass configured with the given states and transitions. Example: >>> machine = create_machine_class_from_definition( ... "TrafficLightMachine", ... **{ ... "states": { ... "green": {"initial": True, "on": {"change": [{"target": "yellow"}]}}, ... "yellow": {"on": {"change": [{"target": "red"}]}}, ... "red": {"on": {"change": [{"target": "green"}]}}, ... }, ... } ... ) """ states_instances, events_definitions = _parse_states(states) events: Dict[str, TransitionList] = {} for state_id, state_events in events_definitions.items(): for event_name, transitions_data in state_events.items(): for transition_data in transitions_data: source = states_instances[state_id] target_state_id = transition_data["target"] transition_event_name = transition_data.get("event") if event_name is not None and transition_event_name is not None: transition_event_name = f"{event_name} {transition_event_name}" elif event_name is not None: transition_event_name = event_name transition_kwargs = { "event": transition_event_name, "internal": transition_data.get("internal"), "initial": transition_data.get("initial"), "cond": transition_data.get("cond"), "unless": transition_data.get("unless"), "on": transition_data.get("on"), "before": transition_data.get("before"), "after": transition_data.get("after"), } # Handle multi-target transitions (space-separated target IDs) if target_state_id and isinstance(target_state_id, str) and " " in target_state_id: target_ids = target_state_id.split() targets = [states_instances[tid] for tid in target_ids] t = Transition(source, target=targets, **transition_kwargs) source.transitions.add_transitions(t) transition = TransitionList([t]) else: target = states_instances[target_state_id] if target_state_id else None transition = source.to(target, **transition_kwargs) if event_name in events: events[event_name] |= transition elif event_name is not None: events[event_name] = transition top_level_states = { state_id: state for state_id, state in states_instances.items() if not state.parent } attrs_mapper = {**definition, **top_level_states, **events} return StateMachineMetaclass(name, (StateChart,), attrs_mapper) # type: ignore[return-value] ================================================ FILE: statemachine/io/scxml/__init__.py ================================================ ================================================ FILE: statemachine/io/scxml/actions.py ================================================ import html import logging import re from dataclasses import dataclass from itertools import chain from typing import Any from typing import Callable from uuid import uuid4 from ...event import BoundEvent from ...event import Event from ...event import _event_data_kwargs from ...spec_parser import InState from ...statemachine import StateChart from .parser import Action from .parser import AssignAction from .parser import IfAction from .parser import LogAction from .parser import RaiseAction from .parser import SendAction from .schema import CancelAction from .schema import DataItem from .schema import DataModel from .schema import DoneData from .schema import ExecutableContent from .schema import ForeachAction from .schema import Param from .schema import ScriptAction logger = logging.getLogger(__name__) _debug = logger.debug if logger.isEnabledFor(logging.DEBUG) else lambda *a, **k: None protected_attrs = _event_data_kwargs | {"_sessionid", "_ioprocessors", "_name", "_event"} class ParseTime: pattern = re.compile(r"(\d+)?(\.\d+)?(s|ms)") @classmethod def parse_delay(cls, delay: "str | None", delayexpr: "str | None", **kwargs): if delay: return cls.time_in_ms(delay) elif delayexpr: delay_expr_expanded = cls.replace(delayexpr) return cls.time_in_ms(_eval(delay_expr_expanded, **kwargs)) return 0 @classmethod def replace(cls, expr: str) -> str: def rep(match): return str(cls.time_in_ms(match.group(0))) return cls.pattern.sub(rep, expr) @classmethod def time_in_ms(cls, expr: str) -> float: """ Convert a CSS2 time expression to milliseconds. Args: time (str): A string representing the time, e.g., '1.5s' or '150ms'. Returns: float: The time in milliseconds. Raises: ValueError: If the input is not a valid CSS2 time expression. """ if expr.endswith("ms"): try: return float(expr[:-2]) except ValueError as e: raise ValueError(f"Invalid time value: {expr}") from e elif expr.endswith("s"): try: return float(expr[:-1]) * 1000 except ValueError as e: raise ValueError(f"Invalid time value: {expr}") from e else: try: return float(expr) except ValueError as e: raise ValueError(f"Invalid time unit in: {expr}") from e @dataclass class _Data: kwargs: dict def __getattr__(self, name): return self.kwargs.get(name, None) def get(self, name, default=None): return self.kwargs.get(name, default) class OriginTypeSCXML(str): """The origintype of the :ref:`Event` as specified by the SCXML namespace.""" def __eq__(self, other): return other == "http://www.w3.org/TR/scxml/#SCXMLEventProcessor" or other == "scxml" class EventDataWrapper: origin: str = "" origintype: str = OriginTypeSCXML("scxml") """The origintype of the :ref:`Event` as specified by the SCXML namespace.""" invokeid: str = "" """If this event is generated from an invoked child process, the SCXML Processor MUST set this field to the invoke id of the invocation that triggered the child process. Otherwise it MUST leave it blank. """ def __init__(self, event_data=None, *, trigger_data=None): self.event_data = event_data if trigger_data is not None: self.trigger_data = trigger_data elif event_data is not None: self.trigger_data = event_data.trigger_data else: raise ValueError("Either event_data or trigger_data must be provided") td = self.trigger_data self.sendid = td.send_id self.invokeid = td.kwargs.get("_invokeid", "") if td.event is None or td.event.internal: if "error.execution" == td.event: self.type = "platform" else: self.type = "internal" self.origintype = "" else: self.type = "external" @classmethod def from_trigger_data(cls, trigger_data): """Create an EventDataWrapper directly from a TriggerData (no EventData needed).""" return cls(trigger_data=trigger_data) def __getattr__(self, name): if self.event_data is not None: return getattr(self.event_data, name) raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") def __eq__(self, value): "This makes SCXML test 329 pass. It assumes that the event is the same instance" return isinstance(value, EventDataWrapper) @property def name(self): if self.event_data is not None: return self.event_data.event return str(self.trigger_data.event) if self.trigger_data.event else None @property def data(self): "Property used by the SCXML namespace" td = self.trigger_data if td.kwargs: return _Data(td.kwargs) elif td.args and len(td.args) == 1: return td.args[0] elif td.args: return td.args else: return None def _eval(expr: str, **kwargs) -> Any: if "machine" in kwargs: kwargs.update( **{ k: v for k, v in kwargs["machine"].model.__dict__.items() if k not in protected_attrs } ) kwargs["In"] = InState(kwargs["machine"]) return eval(expr, {}, kwargs) class CallableAction: action: Any def __init__(self): self.__qualname__ = f"{self.__class__.__module__}.{self.__class__.__name__}" def __call__(self, *args, **kwargs): raise NotImplementedError def __str__(self): return f"{self.action}" def __repr__(self): return f"{self.__class__.__name__}({self.action!r})" @property def __name__(self): return str(self) @property def __code__(self): return self.__call__.__code__ class Cond(CallableAction): """Evaluates a condition like a predicate and returns True or False.""" @classmethod def create(cls, cond: "str | None", processor=None): cond = cls._normalize(cond) if cond is None: return None return cls(cond, processor) def __init__(self, cond: str, processor=None): super().__init__() self.action = cond self.processor = processor def __call__(self, *args, **kwargs): result = _eval(self.action, **kwargs) _debug("Cond %s -> %s", self.action, result) return result @staticmethod def _normalize(cond: "str | None") -> "str | None": """ Normalizes a JavaScript-like condition string to be compatible with Python's eval. """ if cond is None: return None # Decode HTML entities, to allow XML syntax like `Var1<Var2` cond = html.unescape(cond) replacements = { "true": "True", "false": "False", "null": "None", "===": "==", "!==": "!=", "&&": "and", "||": "or", } # Use regex to replace each JavaScript-like token with its Python equivalent pattern = re.compile(r"\b(?:true|false|null)\b|===|!==|&&|\|\|") return pattern.sub(lambda match: replacements[match.group(0)], cond) def create_action_callable(action: Action) -> Callable: if isinstance(action, RaiseAction): return create_raise_action_callable(action) elif isinstance(action, AssignAction): return Assign(action) elif isinstance(action, LogAction): return Log(action) elif isinstance(action, IfAction): return create_if_action_callable(action) elif isinstance(action, ForeachAction): return create_foreach_action_callable(action) elif isinstance(action, SendAction): return create_send_action_callable(action) elif isinstance(action, CancelAction): return create_cancel_action_callable(action) elif isinstance(action, ScriptAction): return create_script_action_callable(action) else: raise ValueError(f"Unknown action type: {type(action)}") class Assign(CallableAction): def __init__(self, action: AssignAction): super().__init__() self.action = action def __call__(self, *args, **kwargs): machine: StateChart = kwargs["machine"] if self.action.child_xml is not None: value = self.action.child_xml else: value = _eval(self.action.expr, **kwargs) *path, attr = self.action.location.split(".") obj = machine.model for p in path: obj = getattr(obj, p) if not attr.isidentifier() or not (hasattr(obj, attr) or attr in kwargs): raise ValueError( f" 'location' must be a valid Python attribute name and must be declared, " f"got: {self.action.location}" ) if attr in protected_attrs: raise ValueError( f" 'location' cannot assign to a protected attribute: " f"{self.action.location}" ) setattr(obj, attr, value) _debug("Assign: %s = %r", self.action.location, value) class Log(CallableAction): def __init__(self, action: LogAction): super().__init__() self.action = action def __call__(self, *args, **kwargs): value = _eval(self.action.expr, **kwargs) if self.action.expr else None if self.action.label and self.action.expr is not None: msg = f"{self.action.label}: {value!r}" elif self.action.label: msg = f"{self.action.label}" else: msg = f"{value!r}" print(msg) def create_if_action_callable(action: IfAction) -> Callable: branches = [ ( Cond.create(branch.cond), [create_action_callable(action) for action in branch.actions], ) for branch in action.branches ] def if_action(*args, **kwargs): machine: StateChart = kwargs["machine"] for cond, actions in branches: try: cond_result = not cond or cond(*args, **kwargs) except Exception as e: # SCXML spec: condition error → treat as false, queue error.execution. if machine.catch_errors_as_events: machine.send("error.execution", error=e, internal=True) cond_result = False else: raise if cond_result: for action in actions: action(*args, **kwargs) return if_action.action = action # type: ignore[attr-defined] return if_action def create_foreach_action_callable(action: ForeachAction) -> Callable: child_actions = [create_action_callable(act) for act in action.content.actions] def foreach_action(*args, **kwargs): machine: StateChart = kwargs["machine"] try: # Evaluate the array expression to get the iterable array = _eval(action.array, **kwargs) except Exception as e: raise ValueError(f"Error evaluating 'array' expression: {e}") from e if not action.item.isidentifier(): raise ValueError( f" 'item' must be a valid Python attribute name, got: {action.item}" ) for index, item in enumerate(array): # Assign the item and optionally the index setattr(machine.model, action.item, item) if action.index: setattr(machine.model, action.index, index) # Execute child actions for act in child_actions: act(*args, **kwargs) foreach_action.action = action # type: ignore[attr-defined] return foreach_action def create_raise_action_callable(action: RaiseAction) -> Callable: def raise_action(*args, **kwargs): machine: StateChart = kwargs["machine"] Event(id=action.event, internal=True, _sm=machine).put() raise_action.action = action # type: ignore[attr-defined] return raise_action def _send_to_parent(action: SendAction, **kwargs): """Route a to the parent machine via _invoke_session.""" machine = kwargs["machine"] session = getattr(machine, "_invoke_session", None) if session is None: logger.warning( " ignored: machine %r has no _invoke_session", machine.name, ) return event = action.event or _eval(action.eventexpr, **kwargs) # type: ignore[arg-type] names = [] for name in (action.namelist or "").strip().split(): if not hasattr(machine.model, name): raise NameError(f"Namelist variable '{name}' not found on model") names.append(Param(name=name, expr=name)) params_values = {} for param in chain(names, action.params): if param.expr is None: continue params_values[param.name] = _eval(param.expr, **kwargs) session.send_to_parent(event, **params_values) def _send_to_invoke(action: SendAction, invokeid: str, **kwargs): """Route a to the invoked child session.""" machine: StateChart = kwargs["machine"] event = action.event or _eval(action.eventexpr, **kwargs) # type: ignore[arg-type] names = [] for name in (action.namelist or "").strip().split(): if not hasattr(machine.model, name): raise NameError(f"Namelist variable '{name}' not found on model") names.append(Param(name=name, expr=name)) params_values = {} for param in chain(names, action.params): if param.expr is None: continue params_values[param.name] = _eval(param.expr, **kwargs) if not machine._engine._invoke_manager.send_to_child(invokeid, event, **params_values): # Per SCXML spec: if target is not reachable → error.communication BoundEvent("error.communication", internal=True, _sm=machine).put() def create_send_action_callable(action: SendAction) -> Callable: # noqa: C901 content: Any = () _valid_targets = (None, "#_internal", "internal", "#_parent", "parent") if action.content: try: content = (eval(action.content, {}, {}),) except (NameError, SyntaxError, TypeError): content = (action.content,) def send_action(*args, **kwargs): # noqa: C901 machine: StateChart = kwargs["machine"] event = action.event or _eval(action.eventexpr, **kwargs) # type: ignore[arg-type] target = action.target if action.target else None if action.type and action.type != "http://www.w3.org/TR/scxml/#SCXMLEventProcessor": # Per SCXML spec 6.2.3, unsupported type raises error.execution raise ValueError( f"Unsupported send type: {action.type}. " "Only 'http://www.w3.org/TR/scxml/#SCXMLEventProcessor' is supported" ) if target not in _valid_targets: if target and target.startswith("#_scxml_"): # Valid SCXML session reference but undispatchable → error.communication BoundEvent("error.communication", internal=True, _sm=machine).put() elif target and target.startswith("#_"): # #_ → route to invoked child session _send_to_invoke(action, target[2:], **kwargs) else: # Invalid target expression → error.execution (raised as exception) raise ValueError(f"Invalid target: {target}. Must be one of {_valid_targets}") return # Handle #_parent target — route to parent via _invoke_session if target == "#_parent": _send_to_parent(action, **kwargs) return internal = target in ("#_internal", "internal") send_id = None if action.id: send_id = action.id elif action.idlocation: send_id = uuid4().hex setattr(machine.model, action.idlocation, send_id) delay = ParseTime.parse_delay(action.delay, action.delayexpr, **kwargs) # Per SCXML spec, if namelist evaluation causes an error (e.g., variable not found), # the send MUST NOT be dispatched and error.execution is raised. names = [] for name in (action.namelist or "").strip().split(): if not hasattr(machine.model, name): raise NameError(f"Namelist variable '{name}' not found on model") names.append(Param(name=name, expr=name)) params_values = {} for param in chain(names, action.params): if param.expr is None: continue params_values[param.name] = _eval(param.expr, **kwargs) Event(id=event, delay=delay, internal=internal, _sm=machine).put( *content, send_id=send_id, **params_values, ) send_action.action = action # type: ignore[attr-defined] return send_action def create_cancel_action_callable(action: CancelAction) -> Callable: def cancel_action(*args, **kwargs): machine: StateChart = kwargs["machine"] if action.sendid: send_id = action.sendid elif action.sendidexpr: send_id = _eval(action.sendidexpr, **kwargs) else: raise ValueError("CancelAction must have either 'sendid' or 'sendidexpr'") # Implement cancel logic if necessary # For now, we can just print that the event is canceled machine.cancel_event(send_id) cancel_action.action = action # type: ignore[attr-defined] return cancel_action def create_script_action_callable(action: ScriptAction) -> Callable: def script_action(*args, **kwargs): machine: StateChart = kwargs["machine"] local_vars = { **machine.model.__dict__, } exec(action.content, {}, local_vars) # Assign the resulting variables to the state machine's model for var_name, value in local_vars.items(): setattr(machine.model, var_name, value) script_action.action = action # type: ignore[attr-defined] return script_action def create_invoke_init_callable() -> Callable: """Create a callback that extracts invoke-specific kwargs and stores them on the machine. This is always inserted at position 0 in the initial state's onentry list by the SCXML processor, so that ``_invoke_session`` and ``_invoke_params`` are handled before any other callbacks run — even for SMs without a ````. """ initialized = False def invoke_init(*args, **kwargs): nonlocal initialized if initialized: return initialized = True machine = kwargs.get("machine") if machine is not None: # Use get() not pop(): each callback receives a copy of kwargs # (via EventData.extended_kwargs), so pop would be misleading. machine._invoke_params = kwargs.get("_invoke_params") machine._invoke_session = kwargs.get("_invoke_session") return invoke_init def _create_dataitem_callable(action: DataItem) -> Callable: def data_initializer(**kwargs): machine: StateChart = kwargs["machine"] # Check for invoke param overrides — params from parent override child defaults invoke_params = getattr(machine, "_invoke_params", None) if invoke_params and action.id in invoke_params: setattr(machine.model, action.id, invoke_params[action.id]) return if action.expr: try: value = _eval(action.expr, **kwargs) except Exception: setattr(machine.model, action.id, None) raise elif action.content: try: value = _eval(action.content, **kwargs) except Exception: value = action.content else: value = None setattr(machine.model, action.id, value) return data_initializer def create_datamodel_action_callable(action: DataModel) -> "Callable | None": data_elements = [_create_dataitem_callable(item) for item in action.data] data_elements.extend([create_script_action_callable(script) for script in action.scripts]) if not data_elements: return None initialized = False def datamodel(*args, **kwargs): nonlocal initialized if initialized: return initialized = True for act in data_elements: act(**kwargs) return datamodel class ExecuteBlock(CallableAction): """Parses the children as content XML into a callable.""" def __init__(self, content: ExecutableContent): super().__init__() self.action = content self.action_callables = [create_action_callable(action) for action in content.actions] def __call__(self, *args, **kwargs): for action in self.action_callables: action(*args, **kwargs) class DoneDataCallable(CallableAction): """Evaluates params/content and returns the data for done events.""" def __init__(self, donedata: DoneData): super().__init__() self.action = donedata self.donedata = donedata def __call__(self, *args, **kwargs): if self.donedata.content_expr is not None: return _eval(self.donedata.content_expr, **kwargs) result = {} for param in self.donedata.params: if param.expr is not None: result[param.name] = _eval(param.expr, **kwargs) elif param.location is not None: # pragma: no branch location = param.location.strip() try: result[param.name] = _eval(location, **kwargs) except Exception as e: raise ValueError( f" location '{location}' does not resolve to a valid value" ) from e return result ================================================ FILE: statemachine/io/scxml/invoke.py ================================================ """SCXML-specific invoke handler. Implements the IInvoke protocol by resolving child SCXML content (inline or via src/srcexpr), evaluating params/namelist in the parent context, and managing the child machine lifecycle including ``#_parent`` routing, autoforward, and finalize. """ import asyncio import logging from inspect import isawaitable from pathlib import Path from typing import Any from typing import Callable from ...invoke import IInvoke from ...invoke import InvokeContext from .actions import ExecuteBlock from .actions import _eval from .schema import InvokeDefinition logger = logging.getLogger(__name__) _VALID_INVOKE_TYPES = { None, "scxml", "http://www.w3.org/TR/scxml", "http://www.w3.org/TR/scxml/", "http://www.w3.org/TR/scxml/#SCXMLEventProcessor", } class SCXMLInvoker: """SCXML-specific invoke handler implementing the IInvoke protocol. Resolves the child SCXML from inline content, src file, or srcexpr, evaluates params/namelist, and manages the child machine lifecycle. """ def __init__( self, definition: InvokeDefinition, base_dir: str, register_child: "Callable[[str, str], type]", ): self._definition = definition self._register_child = register_child self._child: Any = None self._base_dir: str = base_dir # Duck-typed attributes for InvokeManager self.invoke_id: "str | None" = definition.id self.idlocation: "str | None" = definition.idlocation self.autoforward: bool = definition.autoforward # Pre-compile finalize block self._finalize_block: "ExecuteBlock | None" = None if definition.finalize and not definition.finalize.is_empty: self._finalize_block = ExecuteBlock(definition.finalize) def run(self, ctx: InvokeContext) -> Any: """Create and run the child state machine.""" machine = ctx.machine # Store invokeid in idlocation if specified if self.idlocation: setattr(machine.model, self.idlocation, ctx.invokeid) # Resolve invoke type invoke_type = self._definition.type if self._definition.typeexpr: invoke_type = _eval(self._definition.typeexpr, machine=machine) if invoke_type not in _VALID_INVOKE_TYPES: raise ValueError( f"Unsupported invoke type: {invoke_type}. Supported types: {_VALID_INVOKE_TYPES}" ) # Resolve child SCXML content scxml_content = self._resolve_content(machine) if scxml_content is None: raise ValueError("No content resolved for ") # Evaluate params and namelist invoke_params = self._evaluate_params(machine) # Parse and create the child machine child_cls = self._create_child_class(scxml_content, ctx.invokeid) # _invoke_session and _invoke_params are passed as kwargs so that the # invoke_init callback (inserted at position 0 in the initial state's onentry # by the processor) can pop them and store them on the machine instance. # # The _ChildRefSetter listener captures ``self._child`` during the first # state entry, before the processing loop blocks. This is necessary # because the child's ``__init__`` may block for an extended time when # there are delayed events, and ``on_event()`` needs access to the child # to forward events from the parent session. session = _InvokeSession(parent=machine, invokeid=ctx.invokeid) ref_setter = _ChildRefSetter(self) self._child = child_cls( _invoke_params=invoke_params, _invoke_session=session, listeners=[ref_setter], ) return None def on_cancel(self): """Cancel the child machine and all its invocations.""" from ...invoke import _stop_child_machine _stop_child_machine(self._child) self._child = None def on_event(self, event_name: str, **data): """Forward an event to the child machine (autoforward).""" if self._child is not None and not self._child.is_terminated: try: self._child.send(event_name, **data) except Exception: logger.debug("Error forwarding event %s to child", event_name, exc_info=True) def on_finalize(self, trigger_data): """Execute the finalize block before the parent processes the event.""" if self._finalize_block is not None: machine = trigger_data.machine kwargs = { "machine": machine, "model": machine.model, } # Inject SCXML context variables from .actions import EventDataWrapper kwargs.update( {k: v for k, v in machine.model.__dict__.items() if not k.startswith("_")} ) # Build EventDataWrapper from trigger_data's kwargs kwargs["_event"] = EventDataWrapper.from_trigger_data(trigger_data) self._finalize_block(**kwargs) def _resolve_content(self, machine) -> "str | None": """Resolve the child SCXML content from content/src/srcexpr.""" defn = self._definition if defn.content: # Content could be an expr to evaluate or inline SCXML if defn.content.lstrip().startswith("<"): return defn.content # It's an expression — evaluate it result = _eval(defn.content, machine=machine) if isinstance(result, str): return result return str(result) if defn.srcexpr: src = _eval(defn.srcexpr, machine=machine) elif defn.src: src = defn.src else: return None # Handle file: URIs and relative paths if src.startswith("file:"): path = Path(src.removeprefix("file:")) else: path = Path(src) # Resolve relative to the base directory of the parent SCXML file if not path.is_absolute(): path = Path(self._base_dir) / path return path.read_text() def _evaluate_params(self, machine) -> dict: """Evaluate params and namelist into a dict of values.""" defn = self._definition result = {} # Evaluate namelist if defn.namelist: for name in defn.namelist.strip().split(): if hasattr(machine.model, name): result[name] = getattr(machine.model, name) # Evaluate param elements for param in defn.params: if param.expr is not None: result[param.name] = _eval(param.expr, machine=machine) elif param.location is not None: result[param.name] = _eval(param.location, machine=machine) return result def _create_child_class(self, scxml_content: str, invokeid: str): """Parse the child SCXML and create a machine class.""" child_name = f"invoke_{invokeid}" return self._register_child(scxml_content, child_name) class _ChildRefSetter: """Listener that captures the child machine reference during initialization. The child's ``__init__`` blocks inside the processing loop (e.g. when there are delayed events). By using this listener, ``SCXMLInvoker._child`` is set during the first state entry — *before* the processing loop starts spinning — so that ``on_event()`` can forward events to the child immediately. """ def __init__(self, invoker: "SCXMLInvoker"): self._invoker = invoker def on_enter_state(self, machine=None, **kwargs): if self._invoker._child is None and machine is not None: self._invoker._child = machine class _InvokeSession: """Holds the reference to the parent machine for ``#_parent`` routing.""" def __init__(self, parent, invokeid: str): self.parent = parent self.invokeid = invokeid def send_to_parent(self, event: str, **data): """Send an event to the parent machine's external queue.""" result = self.parent.send(event, _invokeid=self.invokeid, **data) if isawaitable(result): asyncio.ensure_future(result) # Verify protocol compliance at import time assert isinstance(SCXMLInvoker.__new__(SCXMLInvoker), IInvoke) ================================================ FILE: statemachine/io/scxml/parser.py ================================================ import re import xml.etree.ElementTree as ET from typing import List from typing import Literal from typing import Set from typing import cast from urllib.parse import urlparse from .schema import Action from .schema import AssignAction from .schema import CancelAction from .schema import DataItem from .schema import DataModel from .schema import DoneData from .schema import ExecutableContent from .schema import ForeachAction from .schema import HistoryState from .schema import IfAction from .schema import IfBranch from .schema import InvokeDefinition from .schema import LogAction from .schema import Param from .schema import RaiseAction from .schema import ScriptAction from .schema import SendAction from .schema import State from .schema import StateMachineDefinition from .schema import Transition def strip_namespaces(tree: ET.Element): """Remove all namespaces from tags and attributes in place.""" for el in tree.iter(): if "}" in el.tag: el.tag = el.tag.split("}", 1)[1] attrib = el.attrib for name in list(attrib.keys()): # list() needed: loop mutates attrib if "}" in name: new_name = name.split("}", 1)[1] attrib[new_name] = attrib.pop(name) def _parse_initial(initial_content: "str | None") -> List[str]: if initial_content is None: return [] return initial_content.split() def parse_scxml(scxml_content: str) -> StateMachineDefinition: # noqa: C901 root = ET.fromstring(scxml_content) strip_namespaces(root) scxml = root if root.tag == "scxml" else root.find(".//scxml") if scxml is None: raise ValueError("No scxml element found in document") name = scxml.get("name") initial_states = _parse_initial(scxml.get("initial")) all_initial_states = set(initial_states) definition = StateMachineDefinition(name=name, initial_states=initial_states) # Parse datamodel datamodel = parse_datamodel(scxml) if datamodel: definition.datamodel = datamodel # Parse states for state_elem in scxml: if state_elem.tag == "state": state = parse_state(state_elem, all_initial_states) definition.states[state.id] = state elif state_elem.tag == "final": state = parse_state(state_elem, all_initial_states, is_final=True) definition.states[state.id] = state elif state_elem.tag == "parallel": state = parse_state(state_elem, all_initial_states, is_parallel=True) definition.states[state.id] = state # If no initial state was specified, pick the first state if not all_initial_states and definition.states: first_state = next(iter(definition.states.keys())) definition.initial_states = [first_state] definition.states[first_state].initial = True return definition def _find_own_datamodel_elements(root: ET.Element) -> List[ET.Element]: """Find elements that belong to this SCXML document, not to inline children. Skips any nested inside elements (which contain inline child SCXML documents for ). """ result: List[ET.Element] = [] def _walk(elem: ET.Element): for child in elem: if child.tag == "content": continue # Skip inline SCXML content if child.tag == "datamodel": result.append(child) _walk(child) _walk(root) return result def parse_datamodel(root: ET.Element) -> "DataModel | None": data_model = DataModel() for datamodel_elem in _find_own_datamodel_elements(root): for data_elem in datamodel_elem.findall("data"): content = data_elem.text and re.sub(r"\s+", " ", data_elem.text).strip() or None src = data_elem.attrib.get("src") src_parsed = urlparse(src) if src else None if src_parsed and src_parsed.scheme == "file" and content is None: with open(src_parsed.path) as f: content = f.read() data_model.data.append( DataItem( id=data_elem.attrib["id"], src=src_parsed, expr=data_elem.attrib.get("expr"), content=content, ) ) # Parse
{self._repr_svg_()}
' def _repr_svg_(self): return self._graph().create_svg().decode() # type: ignore[attr-defined] def _graph(self): from .contrib.diagram import DotGraphMachine return DotGraphMachine(self).get_graph() @property def configuration_values(self) -> OrderedSet[Any]: """The state configuration values is the set of currently active states's values (or ids if no custom value is defined).""" return self._config.values @property def configuration(self) -> OrderedSet["State"]: """The set of currently active states.""" return self._config.states @configuration.setter def configuration(self, new_configuration: OrderedSet["State"]): self._config.states = new_configuration @property def current_state_value(self): """Get/Set the current :ref:`state` value. This is a low level API, that can be used to assign any valid state value completely bypassing all the hooks and validations. """ return self._config.value @current_state_value.setter def current_state_value(self, value): self._config.value = value @property def current_state(self) -> "State | MutableSet[State]": """Get/Set the current :ref:`state`. This is a low level API, that can be to assign any valid state completely bypassing all the hooks and validations. """ warnings.warn( """Property `current_state` is deprecated in favor of `configuration`.""", DeprecationWarning, stacklevel=2, ) return self._config.current_state @current_state.setter def current_state(self, value): # pragma: no cover self.current_state_value = value.value @property def events(self) -> "List[Event]": return [getattr(self, event) for event in self.__class__._events] @property def allowed_events(self) -> "List[Event]": """List of the current allowed events.""" return [ getattr(self, event) for state in self.configuration for event in state.transitions.unique_events ] def enabled_events(self, *args, **kwargs) -> Any: """List of the current enabled events, considering guard conditions. An event is **enabled** if at least one of its transitions from the current state has all ``cond``/``unless`` guards satisfied. Args: *args: Positional arguments forwarded to condition callbacks. **kwargs: Keyword arguments forwarded to condition callbacks. Returns: A list of enabled :ref:`Event` instances. """ result = self._engine.enabled_events(*args, **kwargs) if not isawaitable(result): return result return run_async_from_sync(result) def _put_nonblocking(self, trigger_data: TriggerData, internal: bool = False): """Put the trigger on the queue without blocking the caller.""" self._engine.put(trigger_data, internal=internal) def send( self, event: str, *args, delay: float = 0, send_id: "str | None" = None, internal: bool = False, **kwargs, ) -> Any: """Send an :ref:`Event` to the state machine. :param event: The trigger for the state machine, specified as an event id string. :param args: Additional positional arguments to pass to the event. :param delay: A time delay in milliseconds to process the event. Default is 0. :param send_id: An identifier for the event, used with ``cancel_event()`` to cancel delayed events. :param kwargs: Additional keyword arguments to pass to the event. .. seealso:: See: :ref:`triggering events`. """ know_event = getattr(self, event, None) event_name = know_event.name if know_event else event delay = ( delay if delay else know_event and know_event.delay or 0 ) # first the param, then the event, or 0 event_instance = BoundEvent( id=event, name=event_name, delay=delay, internal=internal, _sm=self ) result = event_instance(*args, send_id=send_id, **kwargs) if not isawaitable(result): return result return run_async_from_sync(result) def raise_( self, event: str, *args, delay: float = 0, send_id: "str | None" = None, **kwargs ) -> Any: """Send an :ref:`Event` to the state machine in the internal event queue. Events on the internal queue are processed immediately within the current macrostep, before any pending external events. This is equivalent to calling ``send(..., internal=True)``. .. seealso:: See: :ref:`triggering-events`. """ return self.send(event, *args, delay=delay, send_id=send_id, internal=True, **kwargs) def cancel_event(self, send_id: str): """Cancel all the delayed events with the given ``send_id``.""" self._engine.cancel_event(send_id) @property def is_terminated(self): """Whether the state machine has reached a final state. Returns ``True`` when a top-level final state has been entered and the engine is no longer running. This is the recommended way to check for completion -- it works for flat, compound, and parallel topologies. """ return not self._engine.running class StateMachine(StateChart): allow_event_without_transition: bool = False enable_self_transition_entries: bool = False atomic_configuration_update: bool = True catch_errors_as_events: bool = False ================================================ FILE: statemachine/states.py ================================================ from enum import Enum from typing import Dict # deprecated since 3.9: https://peps.python.org/pep-0585/ from typing import Type from .state import State from .utils import ensure_iterable EnumType = Type[Enum] class States: """ A class representing a collection of :ref:`State` objects. Helps creating :ref:`StateChart`'s :ref:`state` definitions from other sources, like an ``Enum`` class, using :meth:`States.from_enum`. >>> states_def = [('open', {'initial': True}), ('closed', {'final': True})] >>> from statemachine import StateChart >>> class SM(StateChart): ... ... states = States({ ... name: State(**params) for name, params in states_def ... }) ... ... close = states.open.to(states.closed) And states can be used as usual. >>> sm = SM() >>> sm.send("close") >>> sm.closed.is_active True """ def __init__(self, states: "Dict[str, State] | None" = None) -> None: """ Initializes a new instance of the States class. Args: states: A dictionary mapping keys as ``State.id`` and values :ref:`state` instances. Returns: None. """ self._states: Dict[str, State] = states if states is not None else {} def __repr__(self): return f"{list(self)}" def __eq__(self, other): return list(self) == list(other) def __getattr__(self, name: str) -> "State": if name in self._states: return self._states[name] raise AttributeError(f"{name} not found in {self.__class__.__name__}") def __len__(self): return len(self._states) def __getitem__(self, index): return list(self)[index] def __iter__(self): return iter(self._states.values()) def append(self, state): self._states[state.id] = state def items(self): """ Returns a view object of the states, with pairs of ``(State.id, State)``. Args: None. Returns: A view object of the items in the the instance. """ return self._states.items() @classmethod def from_enum(cls, enum_type: EnumType, initial, final=None, use_enum_instance: bool = True): """ Creates a new instance of the ``States`` class from an enumeration. Consider an ``Enum`` type that declares our expected states: >>> class Status(Enum): ... pending = 1 ... completed = 2 A :ref:`StateChart` that uses this enum can be declared as follows: >>> from statemachine import StateChart >>> class ApprovalMachine(StateChart): ... ... _ = States.from_enum(Status, initial=Status.pending, final=Status.completed) ... ... finish = _.pending.to(_.completed) ... ... def on_enter_completed(self): ... print("Completed!") .. tip:: When you assign the result of ``States.from_enum`` to a class-level variable in your :ref:`StateChart`, you're all set. You can use any name for this variable. In this example, we used ``_`` to show that the name doesn't matter. The metaclass will inspect the variable of type :ref:`States (class)` and automatically assign the inner :ref:`State` instances to the state machine. Everything else is similar, the ``Enum`` is only used to declare the :ref:`State` instances. >>> sm = ApprovalMachine() >>> sm.pending.is_active True >>> sm.send("finish") Completed! >>> sm.completed.is_active True >>> sm.current_state_value If you need to use the raw enum value instead of the enum instance, you can set ``use_enum_instance=False``: >>> states = States.from_enum(Status, initial=Status.pending, use_enum_instance=False) >>> states.completed.value 2 .. versionchanged:: 3.0.0 The default changed from ``False`` to ``True``. Args: enum_type: An enumeration containing the states of the machine. initial: The initial state of the machine. final: A set of final states of the machine. use_enum_instance: If ``True``, the value of the state will be the enum item instance, otherwise the enum item value. Defaults to ``True``. Returns: A new instance of the :ref:`States (class)`. """ final_set = set(ensure_iterable(final)) return cls( { e.name: State( value=(e if use_enum_instance else e.value), initial=e is initial, final=e in final_set, ) for e in enum_type } ) ================================================ FILE: statemachine/transition.py ================================================ from copy import deepcopy from typing import TYPE_CHECKING from typing import List from .callbacks import CallbackGroup from .callbacks import CallbackPriority from .callbacks import CallbackSpecList from .events import Events from .exceptions import InvalidDefinition from .i18n import _ if TYPE_CHECKING: from .statemachine import State class Transition: """A transition holds reference to the source and target state. Args: source (State): The origin state of the transition. target: The target state(s) of the transition. Can be a single ``State``, a list of states (for multi-target transitions, e.g. SCXML parallel region entry), or ``None`` (targetless transition). event (Optional[Union[str, List[str]]]): List of designators of events that trigger this transition. Can be either a list of strings, or a space-separated string list of event descriptors. internal (bool): Is the transition internal or external? Internal transitions don't execute the state entry/exit actions. Default ``False``. validators (Optional[Union[str, Callable, List[Callable]]]): The validation callbacks to be invoked before the transition is executed. cond (Optional[Union[str, Callable, List[Callable]]]): The condition callbacks to be invoked before the transition is executed that should evaluate to `True`. unless (Optional[Union[str, Callable, List[Callable]]]): The condition callbacks to be invoked if the `cond` is False before the transition is executed. on (Optional[Union[str, Callable, List[Callable]]]): The callbacks to be invoked when the transition is executed. before (Optional[Union[str, Callable, List[Callable]]]): The callbacks to be invoked before the transition is executed. after (Optional[Union[str, Callable, List[Callable]]]): The callbacks to be invoked after the transition is executed. """ def __init__( self, source: "State", target: "State | List[State] | None" = None, event=None, internal=False, initial=False, validators=None, cond=None, unless=None, on=None, before=None, after=None, ): self.source = source if isinstance(target, list): self._targets: "List[State]" = target elif target is not None: self._targets = [target] else: self._targets = [] self.internal = internal self.initial = initial first_target = self._targets[0] if self._targets else None self.is_self = first_target is source """Is the target state the same as the source state?""" if internal and not ( self.is_self or (first_target and first_target.is_descendant(source)) ): raise InvalidDefinition( _( "Not a valid internal transition from source {source!r}, " "target {target!r} should be self or a descendant." ).format(source=source, target=first_target) ) if initial and any([cond, unless, event]): raise InvalidDefinition("Initial transitions should not have conditions or events.") self._events = Events().add(event) self._specs = CallbackSpecList() self.validators = self._specs.grouper(CallbackGroup.VALIDATOR).add( validators, priority=CallbackPriority.INLINE ) self.before = self._specs.grouper(CallbackGroup.BEFORE).add( before, priority=CallbackPriority.INLINE ) self.on = self._specs.grouper(CallbackGroup.ON).add(on, priority=CallbackPriority.INLINE) self.after = self._specs.grouper(CallbackGroup.AFTER).add( after, priority=CallbackPriority.INLINE ) self.cond = ( self._specs.grouper(CallbackGroup.COND) .add(cond, priority=CallbackPriority.INLINE, expected_value=True) .add(unless, priority=CallbackPriority.INLINE, expected_value=False) ) @property def target(self) -> "State | None": """Primary target state (first target for multi-target transitions).""" return self._targets[0] if self._targets else None @property def targets(self) -> "List[State]": """All target states. For single-target transitions, returns a one-element list.""" return self._targets def __repr__(self): return ( f"{type(self).__name__}({self.source.name!r}, {self.target and self.target.name!r}, " f"event={self._events!r}, internal={self.internal!r}, initial={self.initial!r})" ) def __str__(self): return f"transition {self.event!s} from {self.source!s} to {self.target!s}" def _setup(self): before = self.before.add on = self.on.add after = self.after.add before("before_transition", priority=CallbackPriority.GENERIC, is_convention=True) on("on_transition", priority=CallbackPriority.GENERIC, is_convention=True) for event in self._events: same_event_cond = event.is_same_event before( f"before_{event}", priority=CallbackPriority.NAMING, is_convention=True, cond=same_event_cond, ) on( f"on_{event}", priority=CallbackPriority.NAMING, is_convention=True, cond=same_event_cond, ) after( f"after_{event}", priority=CallbackPriority.NAMING, is_convention=True, cond=same_event_cond, ) after( "after_transition", priority=CallbackPriority.AFTER, is_convention=True, ) def match(self, event: str): return self._events.match(event) @property def event(self): return str(self._events) @property def events(self): return self._events def add_event(self, value): self._events.add(value) def _copy_with_args(self, **kwargs): source = kwargs.pop("source", self.source) target = kwargs.pop("target", list(self._targets) if self._targets else None) event = kwargs.pop("event", self.event) internal = kwargs.pop("internal", self.internal) new_transition = Transition( source=source, target=target, event=event, internal=internal, **kwargs ) for spec in self._specs: new_spec = deepcopy(spec) new_transition._specs.add(new_spec, new_spec.group) return new_transition @property def is_eventless(self): return self._events.is_empty ================================================ FILE: statemachine/transition_list.py ================================================ from typing import TYPE_CHECKING from typing import Iterable from typing import List from .callbacks import CallbackGroup from .transition import Transition from .transition_mixin import AddCallbacksMixin from .utils import ensure_iterable if TYPE_CHECKING: from .events import Event from .state import State class TransitionList(AddCallbacksMixin): """A list-like container of :ref:`transitions` with callback functions.""" def __init__(self, transitions: "Iterable[Transition] | None" = None): """ Args: transitions: An iterable of `Transition` objects. Defaults to `None`. """ self.transitions: List[Transition] = list(transitions) if transitions else [] def __repr__(self): """Return a string representation of the :ref:`TransitionList`.""" return f"{type(self).__name__}({self.transitions!r})" def __or__(self, other: "TransitionList | Iterable"): """Return a new :ref:`TransitionList` that combines the transitions of this :ref:`TransitionList` with another :ref:`TransitionList` or iterable. Args: other: Another :ref:`TransitionList` or iterable of :ref:`Transition` objects. Returns: TransitionList: A new :ref:`TransitionList` object that combines the transitions of this :ref:`TransitionList` with `other`. """ return TransitionList(self.transitions).add_transitions(other) def _on_event_defined(self, event: str, states: List["State"]): self.add_event(event) for transition in self.transitions: transition.source._on_event_defined(event=event, transition=transition, states=states) def add_transitions(self, transition: "Transition | TransitionList | Iterable"): """Adds one or more transitions to the :ref:`TransitionList` instance. Args: transition: A sequence of transitions or a :ref:`TransitionList` instance. Returns: The updated :ref:`TransitionList` instance. """ if isinstance(transition, TransitionList): transition = transition.transitions transitions = ensure_iterable(transition) for transition in transitions: assert isinstance(transition, Transition) # makes mypy happy self.transitions.append(transition) return self def __getitem__(self, index: int) -> "Transition": """Returns the :ref:`transition` at the specified ``index``. Args: index: The index of the transition. Returns: The :ref:`transition` at the specified index. """ return self.transitions[index] def __len__(self): """Returns the number of transitions in the :ref:`TransitionList` instance. Returns: The number of transitions. """ return len(self.transitions) def __iter__(self): return iter(self.transitions) def _add_callback(self, callback, grouper: CallbackGroup, is_event=False, **kwargs): for transition in self.transitions: list_obj = transition._specs.grouper(grouper) list_obj._add_unbounded_callback( callback, is_event=is_event, transitions=self, **kwargs, ) return callback def add_event(self, event: str): """ Adds an event to all transitions in the :ref:`TransitionList` instance. Args: event: The name of the event to be added. """ for transition in self.transitions: transition.add_event(event) @property def unique_events(self) -> List["Event"]: """ Returns a list of unique event names across all transitions in the :ref:`TransitionList` instance. Returns: A list of unique event names. """ tmp_ordered_unique_events_as_keys_on_dict = {} for transition in self.transitions: for event in transition.events: tmp_ordered_unique_events_as_keys_on_dict[event] = True return list(tmp_ordered_unique_events_as_keys_on_dict.keys()) @property def has_eventless_transition(self): return any(transition.is_eventless for transition in self.transitions) ================================================ FILE: statemachine/transition_mixin.py ================================================ from typing import Any from typing import Callable from typing import TypeVar from .callbacks import CallbackGroup from .i18n import _ T = TypeVar("T", bound=Callable) class AddCallbacksMixin: def _add_callback(self, callback: T, grouper: CallbackGroup, is_event=False, **kwargs) -> T: raise NotImplementedError def __call__(self, *args, **kwargs) -> Any: if len(args) == 1 and callable(args[0]) and not kwargs: return self._add_callback(args[0], CallbackGroup.ON, is_event=True) raise TypeError( _("{} only supports the decorator syntax to register callbacks.").format( type(self).__name__ ) ) def before(self, f: Callable): """Adds a ``before`` :ref:`transition actions` callback to every :ref:`transition` in the :ref:`TransitionList` instance. Args: f: The ``before`` :ref:`transition actions` callback function to be added. Returns: The `f` callable. """ return self._add_callback(f, CallbackGroup.BEFORE) def after(self, f: Callable): """Adds a ``after`` :ref:`transition actions` callback to every :ref:`transition` in the :ref:`TransitionList` instance. Args: f: The ``after`` :ref:`transition actions` callback function to be added. Returns: The `f` callable. """ return self._add_callback(f, CallbackGroup.AFTER) def on(self, f: Callable): """Adds a ``on`` :ref:`transition actions` callback to every :ref:`transition` in the :ref:`TransitionList` instance. Args: f: The ``on`` :ref:`transition actions` callback function to be added. Returns: The `f` callable. """ return self._add_callback(f, CallbackGroup.ON) def cond(self, f: Callable): """Adds a ``cond`` :ref:`guards` callback to every :ref:`transition` in the :ref:`TransitionList` instance. Args: f: The ``cond`` :ref:`guards` callback function to be added. Returns: The `f` callable. """ return self._add_callback(f, CallbackGroup.COND, expected_value=True) def unless(self, f: Callable): """Adds a ``unless`` :ref:`guards` callback with expected value ``False`` to every :ref:`transition` in the :ref:`TransitionList` instance. Args: f: The ``unless`` :ref:`guards` callback function to be added. Returns: The `f` callable. """ return self._add_callback(f, CallbackGroup.COND, expected_value=False) def validators(self, f: Callable): """Adds a :ref:`validators` callback to the :ref:`TransitionList` instance. Args: f: The ``validators`` callback function to be added. Returns: The callback function. """ return self._add_callback(f, CallbackGroup.VALIDATOR) ================================================ FILE: statemachine/utils.py ================================================ import asyncio import re import threading from typing import Any _SEPARATOR_RE = re.compile(r"[_.]") _cached_loop = threading.local() """Loop that will be used when the SM is running in a synchronous context. One loop per thread.""" def qualname(cls): """ Returns a fully qualified name of the class, to avoid name collisions. """ return ".".join([cls.__module__, cls.__name__]) def ensure_iterable(obj): """ Returns an iterator if obj is not an instance of string or if it encounters type error, otherwise it returns a list. """ if isinstance(obj, str): return [obj] try: return iter(obj) except TypeError: return [obj] def humanize_id(id: str) -> str: """Convert a machine identifier to a human-readable name. Splits on ``_`` and ``.`` separators and capitalizes the first word. >>> humanize_id("go") 'Go' >>> humanize_id("done_state_parent") 'Done state parent' >>> humanize_id("error.execution") 'Error execution' """ return _SEPARATOR_RE.sub(" ", id).strip().capitalize() def run_async_from_sync(coroutine: "Any") -> "Any": """ Compatibility layer to run an async coroutine from a synchronous context. """ global _cached_loop try: asyncio.get_running_loop() return coroutine except RuntimeError: if not hasattr(_cached_loop, "loop"): _cached_loop.loop = asyncio.new_event_loop() return _cached_loop.loop.run_until_complete(coroutine) ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/conftest.py ================================================ import asyncio import threading import time from datetime import datetime import pytest def pytest_addoption(parser): parser.addoption( "--gen-diagram", action="store_true", default=False, help="Generate a diagram of the SCXML machine", ) @pytest.fixture() def current_time(): return datetime.now() @pytest.fixture() def campaign_machine(): from tests.machines.workflow.campaign_machine import CampaignMachine return CampaignMachine @pytest.fixture() def campaign_machine_with_validator(): from tests.machines.workflow.campaign_machine_with_validator import ( CampaignMachineWithValidator, ) return CampaignMachineWithValidator @pytest.fixture() def campaign_machine_with_final_state(): from tests.machines.workflow.campaign_machine import CampaignMachine return CampaignMachine @pytest.fixture() def campaign_machine_with_values(): from tests.machines.workflow.campaign_machine_with_values import CampaignMachineWithValues return CampaignMachineWithValues @pytest.fixture() def traffic_light_machine(): from tests.examples.traffic_light_machine import TrafficLightMachine return TrafficLightMachine @pytest.fixture() def OrderControl(): from tests.examples.order_control_machine import OrderControl return OrderControl @pytest.fixture() def AllActionsMachine(): from tests.examples.all_actions_machine import AllActionsMachine return AllActionsMachine @pytest.fixture() def classic_traffic_light_machine(engine): from statemachine import State from statemachine import StateChart class TrafficLightMachine(StateChart): green = State(initial=True) yellow = State() red = State() slowdown = green.to(yellow) stop = yellow.to(red) go = red.to(green) def _get_engine(self): return engine(self) return TrafficLightMachine @pytest.fixture() def classic_traffic_light_machine_allow_event(classic_traffic_light_machine): """Already allow_event_without_transition=True (StateChart default).""" return classic_traffic_light_machine @pytest.fixture() def reverse_traffic_light_machine(): from tests.machines.workflow.reverse_traffic_light import ReverseTrafficLightMachine return ReverseTrafficLightMachine @pytest.fixture() def approval_machine(current_time): # noqa: C901 from statemachine import State from statemachine import StateChart class ApprovalMachine(StateChart): "A workflow machine" requested = State(initial=True) accepted = State() rejected = State() completed = State(final=True) validate = requested.to(accepted, cond="is_ok") | requested.to(rejected) @validate def do_validate(self, *args, **kwargs): if self.model.is_ok(): self.model.accepted_at = current_time return self.model else: self.model.rejected_at = current_time return self.model @accepted.to(completed) def complete(self): self.model.completed_at = current_time @requested.to(requested) def update(self, **kwargs): for k, v in kwargs.items(): setattr(self.model, k, v) return self.model @rejected.to(requested) def retry(self): self.model.rejected_at = None return self.model return ApprovalMachine @pytest.fixture(params=["sync", "async"]) def engine(request): from statemachine.engines.async_ import AsyncEngine from statemachine.engines.sync import SyncEngine if request.param == "sync": return SyncEngine else: return AsyncEngine class _AsyncListener: """No-op async listener that triggers AsyncEngine selection.""" async def on_enter_state( self, **kwargs ): ... # No-op: presence of async callback triggers AsyncEngine selection class SMRunner: """Helper for running state machine tests on both sync and async engines. Usage in tests:: async def test_something(self, sm_runner): sm = await sm_runner.start(MyStateChart) await sm_runner.send(sm, "some_event") assert "expected_state" in sm.configuration_values """ def __init__(self, is_async: bool): self.is_async = is_async async def start(self, cls, **kwargs): """Create and activate a state machine instance.""" from inspect import isawaitable if self.is_async: listeners = list(kwargs.pop("listeners", [])) listeners.append(_AsyncListener()) sm = cls(listeners=listeners, **kwargs) result = sm.activate_initial_state() if isawaitable(result): await result else: sm = cls(**kwargs) return sm async def send(self, sm, event, **kwargs): """Send an event to the state machine.""" from inspect import isawaitable result = sm.send(event, **kwargs) if isawaitable(result): return await result return result async def processing_loop(self, sm): """Run the processing loop (for delayed event tests).""" from inspect import isawaitable result = sm._processing_loop() if isawaitable(result): return await result return result async def sleep(self, seconds: float): """Sleep that works for both sync and async engines.""" if self.is_async: await asyncio.sleep(seconds) else: time.sleep(seconds) @pytest.fixture(params=["sync", "async"]) def sm_runner(request): """Fixture that runs tests on both sync and async engines.""" return SMRunner(is_async=request.param == "async") @pytest.fixture(autouse=True) def _check_leaked_threads(): """Detect threads leaked by test cases (e.g. invoke daemon threads). Snapshots active threads before the test, yields, then checks for any new threads still alive after teardown. Leaked threads are joined with a timeout and reported as a test failure. """ before = set(threading.enumerate()) yield new_threads = set(threading.enumerate()) - before if not new_threads: return # Filter out asyncio event loop threads (managed by pytest-asyncio, not by us) # and DummyThreads (created by Python for foreign threads — cannot be joined). new_threads = { t for t in new_threads if not t.name.startswith("asyncio_") and not isinstance(t, threading._DummyThread) } if not new_threads: return # Give ephemeral threads (e.g. executor workers) a chance to finish. for t in new_threads: t.join(timeout=2.0) leaked = [t for t in new_threads if t.is_alive()] if not leaked: return details: list[str] = [] for t in leaked: details.append(f" - {t.name!r} (daemon={t.daemon}, ident={t.ident})") pytest.fail( f"Test leaked {len(leaked)} thread(s) still alive after join:\n" + "\n".join(details), pytrace=False, ) ================================================ FILE: tests/django_project/app.py ================================================ from django.http import HttpResponse from django.urls import re_path def home(request): return HttpResponse("WE LOVE DJANGO") urlpatterns = [ re_path(r"^$", home, name="homepage"), ] ================================================ FILE: tests/django_project/core/__init__,.py ================================================ ================================================ FILE: tests/django_project/core/settings.py ================================================ from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent DEBUG = True ROOT_URLCONF = "app" INSTALLED_APPS = [ "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "workflow", ] DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db.sqlite3", "TEST": { "NAME": "testdb.sqlite3", }, } } WSGI_APPLICATION = "core.wsgi.application" ================================================ FILE: tests/django_project/core/wsgi.py ================================================ """ WSGI config for project project. It exposes the WSGI callable as a module-level variable named ``application``. For more information on this file, see https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ """ import os from django.core.wsgi import get_wsgi_application os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings") application = get_wsgi_application() ================================================ FILE: tests/django_project/manage.py ================================================ #!/usr/bin/env python """Django's command-line utility for administrative tasks.""" import os import sys def main(): """Run administrative tasks.""" os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: raise ImportError( "Couldn't import Django. Are you sure it's installed and " "available on your PYTHONPATH environment variable? Did you " "forget to activate a virtual environment?" ) from exc execute_from_command_line(sys.argv) if __name__ == "__main__": main() ================================================ FILE: tests/django_project/workflow/__init__.py ================================================ ================================================ FILE: tests/django_project/workflow/apps.py ================================================ from django.apps import AppConfig class WorfklowConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "workflow" ================================================ FILE: tests/django_project/workflow/models.py ================================================ from django.contrib.auth import get_user_model from django.db import models from statemachine.mixins import MachineMixin User = get_user_model() class WorkflowSteps(models.TextChoices): DRAFT = "draft" PUBLISHED = "published" class Workflow(models.Model, MachineMixin): state_machine_name = "workflow.statemachines.WorfklowStateMachine" state_machine_attr = "wf" bind_events_as_methods = True state = models.CharField( max_length=30, choices=WorkflowSteps.choices, default=WorkflowSteps.DRAFT ) user = models.ForeignKey(User, on_delete=models.CASCADE, null=True) is_active = models.BooleanField(default=False) ================================================ FILE: tests/django_project/workflow/statemachines.py ================================================ from statemachine.states import States from statemachine import StateChart from .models import WorkflowSteps class WorfklowStateMachine(StateChart): allow_event_without_transition = False _ = States.from_enum(WorkflowSteps, initial=WorkflowSteps.DRAFT, final=WorkflowSteps.PUBLISHED) publish = _.DRAFT.to(_.PUBLISHED, cond="is_active") notify_user = _.DRAFT.to.itself(internal=True, cond="has_user") def has_user(self): return bool(self.model.user) ================================================ FILE: tests/django_project/workflow/tests.py ================================================ import pytest from statemachine.exceptions import TransitionNotAllowed from workflow.models import WorkflowSteps from workflow.statemachines import WorfklowStateMachine pytestmark = [ pytest.mark.django_db, ] @pytest.fixture() def Workflow(): from workflow.models import Workflow return Workflow @pytest.fixture() def User(): from django.contrib.auth import get_user_model return get_user_model() @pytest.fixture() def one(Workflow): return Workflow.objects.create() class TestWorkflow: def test_one(self, one): with pytest.raises(TransitionNotAllowed): one.wf.send("publish") def test_two(self, one): # Managing this instance works if I call it like this instead. # So this test works wf = WorfklowStateMachine(one) with pytest.raises(TransitionNotAllowed): wf.send("publish") def test_async_with_db_operation(self, one, User, Workflow): """Regression test for https://github.com/fgmacedo/python-statemachine/issues/446""" user = User.objects.create_user("user") one.user = user one.save() wf = WorfklowStateMachine(one) wf.send("notify_user") # And clear model cache, casing user to be loaded later on one = Workflow.objects.get(pk=one.pk) wf = WorfklowStateMachine(one) wf.send("notify_user") def test_should_publish(self, one): one.is_active = True one.publish() one.save() assert one.state == "published" assert one.wf.current_state_value == "published" assert one.wf.current_state_value == WorkflowSteps.PUBLISHED ================================================ FILE: tests/examples/README.rst ================================================ Examples -------- Below is a gallery of ``StateMachine`` examples. .. only:: comment sphinx-gallery does not support .md files yet. See https://github.com/sphinx-gallery/sphinx-gallery/issues/710 ================================================ FILE: tests/examples/__init__.py ================================================ ================================================ FILE: tests/examples/ai_shell_machine.py ================================================ """ AI Shell -- coding assistant ============================= A feature-rich coding assistant powered by python-statemachine. A standalone interactive CLI that uses the OpenAI SDK for LLM calls with tool_use. Demonstrates **parallel states**, **compound states**, **HistoryState**, **eventless transitions**, **In() guards**, **done.state**, **error.execution**, **invoke**, and **raise_()** — all working together in a practical application. .. warning:: This example grants an LLM the ability to read files, list directories, and execute shell commands — which can be very useful for exploring a codebase, running tests, or automating tasks. However, the actual behavior depends on the prompts you send and the model you use, and unintended actions (e.g., deleting files or exposing credentials) are possible. **Use at your own risk.** This code is provided for educational and demonstration purposes only. The authors and contributors of python-statemachine accept no liability for any damage or data loss. Consider running it in an isolated environment (e.g., a container or virtual machine) and avoid using elevated privileges. Usage:: # Standalone (installs deps from PyPI) OPENAI_API_KEY=sk-... uv run examples/ai_shell.py # From the repo (uses local statemachine) OPENAI_API_KEY=sk-... uv run --with openai python examples/ai_shell.py # Debug mode — shows engine macro/micro step log on stderr OPENAI_API_KEY=sk-... uv run --with openai python examples/ai_shell.py -v """ # /// script # requires-python = ">=3.9" # dependencies = [ # "openai", # "python-statemachine", # ] # /// import itertools import json import logging import os import random import subprocess import sys import threading from statemachine import HistoryState from statemachine import State from statemachine import StateChart if "-v" in sys.argv or "--verbose" in sys.argv: logging.basicConfig(level=logging.DEBUG, format="%(name)s %(message)s", stream=sys.stderr) # --------------------------------------------------------------------------- # Tool definitions (OpenAI function calling format) # --------------------------------------------------------------------------- TOOLS = [ { "type": "function", "function": { "name": "read_file", "description": ( "Read the contents of a file at the given path. " "Returns the file contents (truncated to 10 000 characters)." ), "parameters": { "type": "object", "properties": { "path": {"type": "string", "description": "Absolute or relative file path."}, }, "required": ["path"], }, }, }, { "type": "function", "function": { "name": "list_files", "description": "List files and directories at the given path.", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "Directory path. Defaults to '.' (current directory).", }, }, }, }, }, { "type": "function", "function": { "name": "run_command", "description": ( "Run a shell command and return its stdout and stderr. " "Commands are executed with a 30-second timeout." ), "parameters": { "type": "object", "properties": { "command": { "type": "string", "description": "The shell command to execute.", }, }, "required": ["command"], }, }, }, ] SYSTEM_PROMPT = ( "You are a helpful coding assistant. You can read files, list directory contents, " "and run shell commands to help the user with their tasks. Be concise and practical. " "You also have tools to introspect the state machine that powers this shell — use them " "when the user asks about the current state, allowed transitions, or other metadata." ) MAX_FILE_CHARS = 10_000 COMMAND_TIMEOUT = 30 MAX_RETRIES = 3 # --------------------------------------------------------------------------- # Spinner animation # --------------------------------------------------------------------------- SPINNER_CHARS = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" SPINNER_MESSAGES = [ "thinking...", "contemplating...", "cooking something up...", "making something special...", "crunching the data...", "pondering...", "culminating...", "brewing ideas...", "connecting the dots...", "almost there...", ] class Spinner: """Animated terminal spinner shown while the LLM is working.""" def __init__(self): self._stop = threading.Event() self._thread: "threading.Thread | None" = None def __enter__(self): self._stop.clear() self._thread = threading.Thread(target=self._run, daemon=True) self._thread.start() return self def __exit__(self, *args): self._stop.set() if self._thread is not None: self._thread.join(timeout=2) def _run(self): messages = SPINNER_MESSAGES[:] random.shuffle(messages) msg_cycle = itertools.cycle(messages) char_cycle = itertools.cycle(SPINNER_CHARS) msg = next(msg_cycle) tick = 0 while not self._stop.wait(timeout=0.08): char = next(char_cycle) if tick > 0 and tick % 30 == 0: msg = next(msg_cycle) line = f" {char} {msg}" print(f"\r{line:<50}", end="", flush=True) tick += 1 print(f"\r{'':50}\r", end="", flush=True) # --------------------------------------------------------------------------- # Tool execution # --------------------------------------------------------------------------- def _tool_read_file(input_data: dict) -> str: path = input_data["path"] try: with open(path) as f: content = f.read(MAX_FILE_CHARS + 1) if len(content) > MAX_FILE_CHARS: content = content[:MAX_FILE_CHARS] + "\n... (truncated)" return content except OSError as e: return f"Error reading file: {e}" def _tool_list_files(input_data: dict) -> str: path = input_data.get("path", ".") try: entries = sorted(os.listdir(path)) return "\n".join(entries) except OSError as e: return f"Error listing directory: {e}" def _tool_run_command(input_data: dict) -> str: command = input_data["command"] try: result = subprocess.run( command, shell=True, capture_output=True, text=True, timeout=COMMAND_TIMEOUT, ) output = "" if result.stdout: output += result.stdout if result.stderr: output += ("" if not output else "\n") + f"stderr: {result.stderr}" if result.returncode != 0: output += f"\n(exit code {result.returncode})" return output or "(no output)" except subprocess.TimeoutExpired: return f"Error: command timed out after {COMMAND_TIMEOUT}s" except OSError as e: return f"Error running command: {e}" TOOL_HANDLERS = { "read_file": _tool_read_file, "list_files": _tool_list_files, "run_command": _tool_run_command, } # --------------------------------------------------------------------------- # State machine introspection tools # --------------------------------------------------------------------------- def _tool_sm_configuration(sm, input_data: dict) -> str: states = sorted(sm.configuration_values) return json.dumps({"active_states": states}) def _tool_sm_enabled_events(sm, input_data: dict) -> str: events = sorted({e.name for e in sm.enabled_events()}) return json.dumps({"enabled_events": events}) def _tool_sm_macrostep_count(sm, input_data: dict) -> str: return json.dumps({"macrostep_count": sm._engine._macrostep_count}) def _tool_sm_states(sm, input_data: dict) -> str: all_states = sorted(sm.states_map.keys()) return json.dumps({"all_states": all_states}) SM_TOOL_HANDLERS = { "sm_configuration": _tool_sm_configuration, "sm_enabled_events": _tool_sm_enabled_events, "sm_macrostep_count": _tool_sm_macrostep_count, "sm_states": _tool_sm_states, } SM_TOOLS = [ { "type": "function", "function": { "name": "sm_configuration", "description": ( "Get the current active states (configuration) of the state machine. " "Returns which states are currently active." ), "parameters": {"type": "object", "properties": {}}, }, }, { "type": "function", "function": { "name": "sm_enabled_events", "description": ( "List events (transitions) that can be triggered from the current " "state machine configuration, considering guard conditions." ), "parameters": {"type": "object", "properties": {}}, }, }, { "type": "function", "function": { "name": "sm_macrostep_count", "description": ( "Get the current macrostep counter of the state machine engine. " "A macrostep is the full processing cycle for one external event." ), "parameters": {"type": "object", "properties": {}}, }, }, { "type": "function", "function": { "name": "sm_states", "description": ( "List all states defined in the state machine, including nested states " "inside compound and parallel states." ), "parameters": {"type": "object", "properties": {}}, }, }, ] def execute_tool(name: str, input_data: dict, sm=None) -> str: sm_handler = SM_TOOL_HANDLERS.get(name) if sm_handler is not None: return sm_handler(sm, input_data) handler = TOOL_HANDLERS.get(name) if handler is None: return f"Unknown tool: {name}" return handler(input_data) # --------------------------------------------------------------------------- # State machine # --------------------------------------------------------------------------- GOODBYE_WORDS = {"bye", "exit", "quit"} class AIShell(StateChart): """An agentic coding assistant as a StateChart. Demonstrates parallel states, compound states, HistoryState, eventless transitions, In() guards, done.state, error.execution, invoke, and raise_() — all in a practical application. States:: session (Parallel) ├── conversation (Compound) │ ├── idle (initial) │ ├── processing (Compound) │ │ ├── thinking (initial, invoke) ← API call + spinner │ │ ├── using_tool (invoke) ← tool execution │ │ ├── done (final) │ │ └── h = HistoryState(deep) ← for error retry │ ├── responding │ ├── recovering ← error.execution handler │ └── conversation_ended (final) └── context_tracker (Compound) ├── fresh (initial) ├── active (≥4 messages) ├── deep (≥20 messages, shows warning) └── tracker_done (final) """ catch_errors_as_events = True # --- Top-level parallel state: two independent regions --- class session(State.Parallel): class conversation(State.Compound): idle = State("Idle", initial=True) class processing(State.Compound): thinking = State("Thinking", initial=True) using_tool = State("Using Tool") done = State("Done", final=True) h = HistoryState(type="deep") # Invoke results route automatically done_invoke_thinking = thinking.to( using_tool, cond="has_tool_calls" ) | thinking.to(done) done_invoke_using_tool = using_tool.to(thinking) responding = State("Responding") recovering = State("Recovering") conversation_ended = State("Ended", final=True) # Named events user_message = idle.to(processing, cond="is_not_goodbye") | idle.to( conversation_ended, cond="is_goodbye" ) done_state_processing = processing.to(responding) error_execution = processing.to(recovering) # Eventless transitions responding.to(idle) recovering.to(processing.h, cond="can_retry") recovering.to(idle, cond="cannot_retry") class context_tracker(State.Compound): fresh = State("Fresh", initial=True) active = State("Active") deep = State("Deep") tracker_done = State(final=True) # Eventless: track conversation depth fresh.to(active, cond="is_active_context") active.to(deep, cond="is_deep_context") # Eventless + In() guard: follow conversation end fresh.to(tracker_done, cond="In('conversation_ended')") active.to(tracker_done, cond="In('conversation_ended')") deep.to(tracker_done, cond="In('conversation_ended')") # --- Initialization --- def __init__(self): from openai import OpenAI # type: ignore[import-not-found] self.client = OpenAI() self.messages: list = [{"role": "system", "content": SYSTEM_PROMPT}] self._last_text: str = "" self._retries: int = 0 self._ready = threading.Event() super().__init__() # --- Guards --- def is_goodbye(self, text="", **kwargs) -> bool: return text.strip().lower() in GOODBYE_WORDS def is_not_goodbye(self, text="", **kwargs) -> bool: return not self.is_goodbye(text=text) def can_retry(self, **kwargs) -> bool: return self._retries < MAX_RETRIES def cannot_retry(self, **kwargs) -> bool: return not self.can_retry() def is_active_context(self, **kwargs) -> bool: return len(self.messages) >= 5 def is_deep_context(self, **kwargs) -> bool: return len(self.messages) >= 20 # --- Callbacks --- def on_user_message(self, text, **kwargs): """Append the user's message to conversation history.""" self.messages.append({"role": "user", "content": text}) def has_tool_calls(self, data=None, **kwargs) -> bool: """Guard: check if the API response contains tool calls.""" return bool(getattr(data, "tool_calls", None)) def on_invoke_thinking(self, **kwargs): """Call the OpenAI API with a spinner animation. Returns the message.""" with Spinner(): response = self.client.chat.completions.create( model="gpt-4o-mini", messages=self.messages, tools=TOOLS + SM_TOOLS, ) message = response.choices[0].message self.messages.append(message) if not message.tool_calls: self._last_text = message.content or "" return message def on_invoke_using_tool(self, data, **kwargs): """Execute tool calls from the API response.""" for call in data.tool_calls: args = json.loads(call.function.arguments) print(f" [tool] {call.function.name}({json.dumps(args)})") result = execute_tool(call.function.name, args, sm=self) self.messages.append( { "role": "tool", "tool_call_id": call.id, "content": result, } ) def on_enter_responding(self, **kwargs): """Print the assistant's text response.""" if self._last_text: print(f"\n{self._last_text}") self._last_text = "" def on_enter_idle(self, **kwargs): """Reset retry counter and signal readiness when returning to idle.""" self._retries = 0 self._ready.set() def on_enter_recovering(self, **kwargs): """Handle API errors with retry logic (via error.execution).""" self._retries += 1 if self._retries < MAX_RETRIES: print(f"\n [error] API call failed, retrying ({self._retries}/{MAX_RETRIES})...") else: print(f"\n [error] API call failed after {MAX_RETRIES} attempts. Giving up.") def on_enter_deep(self, **kwargs): """Warn when conversation context is getting long.""" print(" [context] Conversation is getting long — responses may degrade.") def on_enter_conversation_ended(self, **kwargs): print("\nGoodbye!") # --------------------------------------------------------------------------- # Main loop # --------------------------------------------------------------------------- def _check_openai(): """Return True if the openai package is available.""" try: import openai # noqa: F401 return True except ImportError: return False def main(): if not _check_openai(): print("This example requires the 'openai' package.") print("Install it with: pip install openai") return print("AI Shell") print("A coding assistant powered by python-statemachine + OpenAI.") print("Type 'bye', 'exit', or 'quit' to end. Ctrl+C to interrupt.") if "-v" in sys.argv or "--verbose" in sys.argv: print("Debug mode enabled — engine log is written to stderr.\n") else: print("Tip: run with -v to see engine macro/micro step debug log.\n") try: sm = AIShell() except Exception as e: sys.exit(f"Error initializing: {e}") while not sm.is_terminated: sm._ready.wait() sm._ready.clear() try: text = input("> ") except (EOFError, KeyboardInterrupt): print() break if text.strip(): sm.send("user_message", text=text) if __name__ == "__main__" and "sphinx" not in sys.modules: # pragma: no cover main() ================================================ FILE: tests/examples/air_conditioner_machine.py ================================================ """ Air Conditioner machine ======================= A StateChart that exercises reading from a stream of events. """ import random from statemachine.utils import run_async_from_sync from statemachine import State from statemachine import StateChart def sensor_temperature_reader(seed: int, lower: int = 15, higher: int = 35): "Infinitely generates random temperature readings." random.seed(seed) while True: yield random.randint(lower, higher) class AirConditioner(StateChart): off = State(initial=True) cooling = State() standby = State() sensor_updated = ( off.to(cooling, cond="is_hot") | cooling.to(standby, cond="is_good") | standby.to(cooling, cond="is_hot") | standby.to(off, cond="is_cool") | off.to.itself(internal=True) | cooling.to.itself(internal=True) | standby.to.itself(internal=True) ) async def is_hot(self, temperature: int): return temperature > 25 async def is_good(self, temperature: int): return temperature < 20 async def is_cool(self, temperature: int): return temperature < 18 async def after_transition(self, event: str, source: State, target: State, event_data): print(f"Running {event} from {source!s} to {target!s}: {event_data.trigger_data.kwargs!r}") # %% # Testing async def main(): sensor = sensor_temperature_reader(123456) print("Will create AirConditioner machine") sm = AirConditioner() generator = (("sensor_updated", next(sensor)) for _ in range(20)) for event, temperature in generator: await sm.send(event, temperature=temperature) if __name__ == "__main__": # using `run_async_from_sync` to better integration with an already running loop. # on real life you should use `asyncio.run(main())` run_async_from_sync(main()) ================================================ FILE: tests/examples/all_actions_machine.py ================================================ """ All actions machine =================== A StateChart that exercises all possible :ref:`Actions` and :ref:`Guards`. """ from unittest import mock from statemachine import State from statemachine import StateChart class AllActionsMachine(StateChart): initial = State(initial=True) final = State(final=True) go = initial.to( final, validators=["validation_1", "validation_2"], cond=["condition_1", "condition_2"], unless=["unless_1", "unless_2"], on=["on_inline_1", "on_inline_2"], before=["before_go_inline_1", "before_go_inline_2"], after=["after_go_inline_1", "after_go_inline_2"], ) def __init__(self, *args, **kwargs): self.spy = mock.Mock(side_effect=lambda x: x) super().__init__(*args, **kwargs) # validators and guards def validation_1(self): # this method may raise an exception return self.spy("validation_1") def validation_2(self): # this method may raise an exception return self.spy("validation_2") def condition_1(self): self.spy("condition_1") return True def condition_2(self): self.spy("condition_2") return True def unless_1(self): self.spy("unless_1") return False def unless_2(self): self.spy("unless_2") return False # generics state def on_enter_state(self): return self.spy("on_enter_state") def on_exit_state(self): return self.spy("on_exit_state") # generics transition def before_transition(self): return self.spy("before_transition") def on_transition(self): return self.spy("on_transition") def after_transition(self): return self.spy("after_transition") # before / after specific @go.before def before_go_decor(self): return self.spy("before_go_decor") def before_go_inline_1(self): return self.spy("before_go_inline_1") def before_go_inline_2(self): return self.spy("before_go_inline_2") def before_go(self): return self.spy("before_go") @go.on def go_on_decor(self): return self.spy("go_on_decor") def on_inline_1(self): return self.spy("on_inline_1") def on_inline_2(self): return self.spy("on_inline_2") def on_go(self): return self.spy("on_go") @go.after def after_go_decor(self): return self.spy("after_go_decor") def after_go_inline_1(self): return self.spy("after_go_inline_1") def after_go_inline_2(self): return self.spy("after_go_inline_2") def after_go(self): return self.spy("after_go") # enter / exit specific @initial.enter def enter_initial_decor(self): return self.spy("enter_initial_decor") def on_enter_initial(self): return self.spy("on_enter_initial") @initial.exit def exit_initial_decor(self): return self.spy("exit_initial_decor") def on_exit_initial(self): return self.spy("on_exit_initial") def on_enter_final(self): return self.spy("on_enter_final") def on_exit_final(self): "hopefully this will not be called" return self.spy("on_exit_final") # %% # Testing # ------- machine = AllActionsMachine() spy = machine.spy # %% # Only before/on actions have their result collected. result = machine.go() expected = [ "before_transition", "before_go_inline_1", "before_go_inline_2", "before_go_decor", "before_go", "on_transition", "on_inline_1", "on_inline_2", "go_on_decor", "on_go", ] assert result == expected # %% # Checking the method resolution order assert spy.call_args_list == [ mock.call("on_enter_state"), mock.call("enter_initial_decor"), mock.call("on_enter_initial"), mock.call("validation_1"), mock.call("validation_2"), mock.call("condition_1"), mock.call("condition_2"), mock.call("unless_1"), mock.call("unless_2"), mock.call("before_transition"), mock.call("before_go_inline_1"), mock.call("before_go_inline_2"), mock.call("before_go_decor"), mock.call("before_go"), mock.call("on_exit_state"), mock.call("exit_initial_decor"), mock.call("on_exit_initial"), mock.call("on_transition"), mock.call("on_inline_1"), mock.call("on_inline_2"), mock.call("go_on_decor"), mock.call("on_go"), mock.call("on_enter_state"), mock.call("on_enter_final"), mock.call("after_go_inline_1"), mock.call("after_go_inline_2"), mock.call("after_go_decor"), mock.call("after_go"), mock.call("after_transition"), ] ================================================ FILE: tests/examples/async_guess_the_number_machine.py ================================================ """ Async guess the number machine ============================== An async example of StateChart for the well known game. In order to pay the game, run this script and type a number between 1 and 5. The command line should include an extra param to run the script in interactive mode: On the root folder of the project, run: ``python tests/examples/async_guess_the_number_machine.py -i`` It's worth to mention that the same state machine can be used in syncronous code, as shown in the docstring of the class. You can play on sync contextif you also pass the `-s` flag: ``python tests/examples/async_guess_the_number_machine.py -i -s`` """ import asyncio import random import sys from statemachine import State from statemachine import StateChart class GuessTheNumberMachine(StateChart): """ Guess the number machine. This docstring exercises the SAME `GuessTheNumberMachine`` in synchronous code. >>> random.seed(103) >>> sm = GuessTheNumberMachine(print, seed=103) >>> sm.activate_initial_state() # doctest: +SKIP I'm thinking of a number between 1 and 5. Can you guess what it is? >>> >>> while not sm.is_terminated: # doctest: +SKIP ... sm.send("guess", random.randint(1, 5)) Your guess is 2... Too low. Try again. >>> Your guess is 1... Too low. Try again. >>> Your guess is 5... Too high. Try again. >>> Your guess is 1... Too low. Try again. >>> Your guess is 4... Congratulations, you guessed the number in 5 guesses! """ start = State(initial=True) low = State() high = State() won = State(final=True) lose = State(final=True) guess = ( lose.from_(low, high, cond="max_guesses_reached") | won.from_(low, high, start, cond="guess_is_equal") | low.from_(low, high, start, cond="guess_is_lower") | high.from_(low, high, start, cond="guess_is_higher") ) def __init__(self, writer, max_attempts=5, lower=1, higher=5, seed=42): self.writer = writer self.max_attempts = max_attempts self.lower = lower self.higher = higher self.guesses = 0 # lets play a not so random game, or our tests will be crazy random.seed(seed) self.number = random.randint(self.lower, self.higher) super().__init__() async def max_guesses_reached(self): return self.guesses >= self.max_attempts async def before_guess(self, number): self.guesses += 1 self.writer(f"Your guess is {number}...") async def guess_is_lower(self, number): return number < self.number async def guess_is_higher(self, number): return number > self.number async def guess_is_equal(self, number): return self.number == number async def on_enter_start(self): self.writer( f"I'm thinking of a number between {self.lower} and {self.higher}. " f"Can you guess what it is? >>> " ) async def on_enter_low(self): self.writer("Too low. Try again. >>> ") async def on_enter_high(self): self.writer("Too high. Try again. >>> ") async def on_enter_won(self): self.writer(f"Congratulations, you guessed the number in {self.guesses} guesses!") async def on_enter_lose(self): self.writer(f"Oh, no! You've spent all your {self.guesses} attempts!") # %% # Async stdin/stdout # ------------------ # This function will be used to connect the stdin and stdout to the asyncio event loop. async def connect_stdin_stdout(): loop = asyncio.get_event_loop() reader = asyncio.StreamReader() protocol = asyncio.StreamReaderProtocol(reader) await loop.connect_read_pipe(lambda: protocol, sys.stdin) w_transport, w_protocol = await loop.connect_write_pipe( asyncio.streams.FlowControlMixin, sys.stdout ) writer = asyncio.StreamWriter(w_transport, w_protocol, reader, loop) return reader, writer # %% # Executing the game # ------------------ # # This script only run by passing the `-i` flag, avoiding blocking while running automated tests. # # To play the game, run this script and type a number between 1 and 5. # # Note that when running a SM in async code, the initial state must be activated manually. # This is done by calling ``await sm.activate_initial_state()``. async def main_async(): reader, writer = await connect_stdin_stdout() sm = GuessTheNumberMachine( lambda s: writer.write(b"\n" + s.encode("utf-8")), seed=random.randint(1, 1000) ) await sm.activate_initial_state() while not sm.is_terminated: res = await reader.read(100) if not res: break await sm.send("guess", int(res)) await writer.drain() writer.close() def main_sync(): sm = GuessTheNumberMachine(print, seed=random.randint(1, 1000)) sm.activate_initial_state() while not sm.is_terminated: res = sys.stdin.readline() if not res: break sm.send("guess", int(res)) if __name__ == "__main__" and "-i" in sys.argv: if "-s" in sys.argv: main_sync() else: asyncio.run(main_async()) ================================================ FILE: tests/examples/async_without_loop_machine.py ================================================ """ Async without external loop =========================== Demonstrates that the state machine can have async callbacks even if the calling context is synchronous. """ from statemachine import State from statemachine import StateChart class AsyncStateMachine(StateChart): initial = State("Initial", initial=True) processing = State() final = State("Final", final=True) start = initial.to(processing) finish = processing.to(final) async def on_start(self): return "starting" async def on_finish(self): return "finishing" # %% # Executing # --------- def sync_main(): sm = AsyncStateMachine() result = sm.start() print(f"Start result is {result}") result = sm.send("finish") print(f"Finish result is {result}") print(list(sm.configuration)) assert sm.final in sm.configuration if __name__ == "__main__": sync_main() ================================================ FILE: tests/examples/enum_campaign_machine.py ================================================ """ Enum campaign machine ===================== A :ref:`StateChart` that demonstrates declaring :ref:`States from Enum types` as source for ``States`` definition. """ from enum import Enum from statemachine.states import States from statemachine import StateChart class CampaignStatus(Enum): DRAFT = 1 PRODUCING = 2 CLOSED = 3 class CampaignMachine(StateChart): "A workflow machine" states = States.from_enum( CampaignStatus, initial=CampaignStatus.DRAFT, final=CampaignStatus.CLOSED, ) add_job = states.DRAFT.to(states.DRAFT) | states.PRODUCING.to(states.PRODUCING) produce = states.DRAFT.to(states.PRODUCING) deliver = states.PRODUCING.to(states.CLOSED) # %% # Asserting campaign machine declaration assert CampaignMachine.states.DRAFT.initial assert not CampaignMachine.states.DRAFT.final assert not CampaignMachine.states.PRODUCING.initial assert not CampaignMachine.states.PRODUCING.final assert not CampaignMachine.states.CLOSED.initial assert CampaignMachine.states.CLOSED.final # %% # Testing our campaign machine sm = CampaignMachine() res = sm.send("produce") assert CampaignStatus.DRAFT not in sm.configuration_values assert CampaignStatus.PRODUCING in sm.configuration_values assert CampaignStatus.CLOSED not in sm.configuration_values assert CampaignStatus.PRODUCING in sm.configuration_values ================================================ FILE: tests/examples/guess_the_number_machine.py ================================================ """ Guess the number machine ======================== A StateChart for the well known game. Well leave the machine imagine a number and also play the game. Why not? """ import random from statemachine import State from statemachine import StateChart class GuessTheNumberMachine(StateChart): allow_event_without_transition = False start = State(initial=True) low = State() high = State() won = State(final=True) lose = State(final=True) guess = ( lose.from_(low, high, cond="max_guesses_reached") | won.from_(low, high, start, cond="guess_is_equal") | low.from_(low, high, start, cond="guess_is_lower") | high.from_(low, high, start, cond="guess_is_higher") ) def __init__(self, max_attempts=5, lower=1, higher=5, seed=42): self.max_attempts = max_attempts self.lower = lower self.higher = higher self.guesses = 0 # lets play a not so random game, or our tests will be crazy random.seed(seed) self.number = random.randint(self.lower, self.higher) super().__init__() def max_guesses_reached(self): return self.guesses >= self.max_attempts def before_guess(self, number): self.guesses += 1 print(f"Your guess is {number}...") def guess_is_lower(self, number): return number < self.number def guess_is_higher(self, number): return number > self.number def guess_is_equal(self, number): return self.number == number def on_enter_start(self): print(f"(psss.. don't tell anyone the number is {self.number})") print( f"I'm thinking of a number between {self.lower} and {self.higher}. " f"Can you guess what it is?" ) def on_enter_low(self): print("Too low. Try again.") def on_enter_high(self): print("Too high. Try again.") def on_enter_won(self): print(f"Congratulations, you guessed the number in {self.guesses} guesses!") def on_enter_lose(self): print(f"Oh, no! You've spent all your {self.guesses} attempts!") # %% # Playing # ------- # sm = GuessTheNumberMachine(seed=103) # %% sm.guess(random.randint(1, 5)) # %% sm # %% sm.guess(random.randint(1, 5)) # %% sm.guess(random.randint(1, 5)) sm # %% # %% sm.guess(random.randint(1, 5)) # %% sm.guess(random.randint(1, 5)) # %% sm # %% try: sm.guess(random.randint(1, 5)) except Exception as e: print(e) ================================================ FILE: tests/examples/lor_machine.py ================================================ """ Lord of the Rings Quest - Boolean algebra ========================================= Example that demonstrates the use of Boolean algebra in conditions. """ from statemachine.exceptions import TransitionNotAllowed from statemachine import State from statemachine import StateChart class LordOfTheRingsQuestStateMachine(StateChart): allow_event_without_transition = False # Define the states shire = State("In the Shire", initial=True) bree = State("In Bree") rivendell = State("At Rivendell") moria = State("In Moria") lothlorien = State("In Lothlorien") mordor = State("In Mordor") mount_doom = State("At Mount Doom", final=True) # Define transitions with Boolean conditions start_journey = shire.to(bree, cond="frodo_has_ring and !sauron_alive and frodo_stamina > 90") meet_elves = bree.to(rivendell, cond="gandalf_present and frodo_has_ring") enter_moria = rivendell.to(moria, cond="orc_army_nearby or frodo_has_ring") reach_lothlorien = moria.to(lothlorien, cond="!orc_army_nearby") journey_to_mordor = lothlorien.to(mordor, cond="frodo_has_ring and sam_is_loyal") destroy_ring = mordor.to(mount_doom, cond="frodo_has_ring and frodo_resists_ring") # Conditions (attributes representing the state of conditions) frodo_stamina: int = 100 frodo_has_ring: bool = True sauron_alive: bool = True # Initially, Sauron is alive gandalf_present: bool = False # Gandalf is not present at the start orc_army_nearby: bool = False sam_is_loyal: bool = True frodo_resists_ring: bool = False # Initially, Frodo is not resisting the ring # %% # Playing quest = LordOfTheRingsQuestStateMachine() # Track state changes print(f"Current State: {[s.id for s in quest.configuration]}") # Should start at "shire" # Step 1: Start the journey quest.sauron_alive = False # Assume Sauron is no longer alive try: quest.start_journey() print(f"Current State: {[s.id for s in quest.configuration]}") # Should be "bree" except TransitionNotAllowed: print("Unable to start journey: conditions not met.") # Step 2: Meet the elves in Rivendell quest.gandalf_present = True # Gandalf is now present try: quest.meet_elves() print(f"Current State: {[s.id for s in quest.configuration]}") # Should be "rivendell" except TransitionNotAllowed: print("Unable to meet elves: conditions not met.") # Step 3: Enter Moria quest.orc_army_nearby = True # Orc army is nearby try: quest.enter_moria() print(f"Current State: {[s.id for s in quest.configuration]}") # Should be "moria" except TransitionNotAllowed: print("Unable to enter Moria: conditions not met.") # Step 4: Reach Lothlorien quest.orc_army_nearby = False # Orcs are no longer nearby try: quest.reach_lothlorien() print(f"Current State: {[s.id for s in quest.configuration]}") # Should be "lothlorien" except TransitionNotAllowed: print("Unable to reach Lothlorien: conditions not met.") # Step 5: Journey to Mordor try: quest.journey_to_mordor() print(f"Current State: {[s.id for s in quest.configuration]}") # Should be "mordor" except TransitionNotAllowed: print("Unable to journey to Mordor: conditions not met.") # Step 6: Fight with Smeagol try: quest.destroy_ring() print(f"Current State: {[s.id for s in quest.configuration]}") # Should be "mount_doom" except TransitionNotAllowed: print("Unable to destroy the ring: conditions not met.") # Step 7: Destroy the ring at Mount Doom quest.frodo_resists_ring = True # Frodo is now resisting the ring try: quest.destroy_ring() print(f"Current State: {[s.id for s in quest.configuration]}") # Should be "mount_doom" except TransitionNotAllowed: print("Unable to destroy the ring: conditions not met.") ================================================ FILE: tests/examples/order_control_machine.py ================================================ """ Order control machine --------------------- A StateChart that demonstrates :ref:`Guards` being used to control the state flow. """ from statemachine import State from statemachine import StateChart class OrderControl(StateChart): allow_event_without_transition = False enable_self_transition_entries = False waiting_for_payment = State(initial=True) processing = State() shipping = State() completed = State(final=True) add_to_order = waiting_for_payment.to(waiting_for_payment) receive_payment = waiting_for_payment.to( processing, cond="payments_enough" ) | waiting_for_payment.to(waiting_for_payment, unless="payments_enough") process_order = processing.to(shipping, cond="payment_received") ship_order = shipping.to(completed) def __init__(self): self.order_total = 0 self.payments = [] self.payment_received = False super().__init__() def payments_enough(self, amount): return sum(self.payments) + amount >= self.order_total def before_add_to_order(self, amount): self.order_total += amount return self.order_total def before_receive_payment(self, amount): self.payments.append(amount) return self.payments def after_receive_payment(self): self.payment_received = True def on_enter_waiting_for_payment(self): self.payment_received = False ================================================ FILE: tests/examples/order_control_rich_model_machine.py ================================================ """ Order control machine (rich model) ================================== A StateChart that demonstrates :ref:`Actions` being used on a rich model. """ from statemachine.exceptions import InvalidDefinition from statemachine import State from statemachine import StateChart class Order: def __init__(self): self.order_total = 0 self.payments = [] self.payment_received = False def payments_enough(self, amount): return sum(self.payments) + amount >= self.order_total def before_add_to_order(self, amount): self.order_total += amount return self.order_total def on_receive_payment(self, amount): self.payments.append(amount) return self.payments def after_receive_payment(self): self.payment_received = True def wait_for_payment(self): self.payment_received = False class OrderControl(StateChart): allow_event_without_transition = False enable_self_transition_entries = False waiting_for_payment = State(initial=True, enter="wait_for_payment") processing = State() shipping = State() completed = State(final=True) add_to_order = waiting_for_payment.to(waiting_for_payment) receive_payment = waiting_for_payment.to( processing, cond="payments_enough" ) | waiting_for_payment.to(waiting_for_payment, unless="payments_enough") process_order = processing.to(shipping, cond="payment_received") ship_order = shipping.to(completed) # %% # Testing OrderControl # -------------------- # # Let's first try to create a statemachine instance, using the default dummy model that doesn't # have the needed methods to complete the state machine. Since the required methods will not be # found either in the state machine or in the model, an exception ``AttrNotFound`` will be raised. try: control = OrderControl() except InvalidDefinition as e: assert ( # noqa: PT017 str(e) == ( "Error on Waiting for payment when resolving callbacks: " "Did not found name 'wait_for_payment' from model or statemachine" ) ) # %% # Now initializing with a proper ``order`` instance. order = Order() control = OrderControl(order) # %% # Send events to add to order assert control.send("add_to_order", 3) == 3 assert control.send("add_to_order", 7) == 10 # %% # Receive a payment of $4... control.send("receive_payment", 4) # %% # Since there's still $6 left to fulfill the payment, we cannot process the order. try: control.send("process_order") except StateChart.TransitionNotAllowed as err: print(err) # %% control # %% # Now paying the left amount, we can proceed. control.send("receive_payment", 6) # %% control # %% control.send("process_order") # %% control # %% control.send("ship_order") # %% # Just checking the final expected state order.order_total # %% order.payments # %% control.completed.is_active # %% control # %% assert order.order_total == 10 assert order.payments == [4, 6] assert control.completed.is_active ================================================ FILE: tests/examples/persistent_model_machine.py ================================================ """ Persistent domain model ======================= An example originated from a question: "How to save state to disk?". There are many ways to implement this, but you can get an insight of one possibility. This example implements a custom domain model that persists it's state using a generic strategy that can be extended to any storage format. Original `issue `_. Resource management state machine --------------------------------- Given a simple on/off machine for resource management. """ import tempfile from abc import ABC from abc import abstractmethod from statemachine import State from statemachine import StateChart class ResourceManagement(StateChart): power_off = State(initial=True) power_on = State() turn_on = power_off.to(power_on) shutdown = power_on.to(power_off) # %% # Abstract model with persistency protocol # ---------------------------------------- # # Abstract Base Class for persistent models. # Subclasses should implement concrete strategies for: # # - `_read_state`: Read the state from the concrete persistent layer. # - `_write_state`: Write the state from the concrete persistent layer. class AbstractPersistentModel(ABC): """Abstract Base Class for persistent models. Subclasses should implement concrete strategies for: - `_read_state`: Read the state from the concrete persistent layer. - `_write_state`: Write the state from the concrete persistent layer. """ def __init__(self): self._state = None def __repr__(self): return f"{type(self).__name__}(state={self.state})" @property def state(self): if self._state is None: self._state = self._read_state() return self._state @state.setter def state(self, value): self._state = value self._write_state(value) @abstractmethod def _read_state(self): ... @abstractmethod def _write_state(self, value): ... # %% # FilePersistentModel: Concrete model strategy # -------------------------------------------- # # A concrete implementation of the generic storage protocol above, that reads and writes to a file # in plain text. class FilePersistentModel(AbstractPersistentModel): """A concrete implementation of a storage strategy for a Model that reads and writes to a file. """ def __init__(self, file): super().__init__() self.file = file def _read_state(self): self.file.seek(0) state = self.file.read().strip() return state if state != "" else None def _write_state(self, value): self.file.seek(0) self.file.truncate(0) self.file.write(value or "") # %% # Given a temporary file to store our state. state_file = tempfile.TemporaryFile(mode="r+") # %% # Let's create instances and test the persistence. model = FilePersistentModel(file=state_file) sm = ResourceManagement(model=model) print(f"Initial state: {[s.id for s in sm.configuration]}") sm.send("turn_on") print(f"State after transition: {[s.id for s in sm.configuration]}") # %% # Remove the instances from memory. del sm del model # %% # Restore the previous state from disk. model = FilePersistentModel(file=state_file) sm = ResourceManagement(model=model) print(f"State restored from file system: {[s.id for s in sm.configuration]}") # %% # Closing the file (the temporary file will be removed). state_file.close() ================================================ FILE: tests/examples/recursive_event_machine.py ================================================ """ Looping state machine ===================== This example demonstrates that you can call an event as a side-effect of another event. The event will be put on an internal queue and processed in the same loop after the previous event in the queue is processed. """ from statemachine import State from statemachine import StateChart class MyStateMachine(StateChart): catch_errors_as_events = False startup = State(initial=True) test = State() counter = 0 do_startup = startup.to(test, after="do_test") do_test = test.to.itself(after="do_test") def on_enter_state(self, target, event): self.counter += 1 print(f"{self.counter:>3}: Entering {target} from {event}") if self.counter >= 5: raise StopIteration # %% # Let's create an instance and test the machine. sm = MyStateMachine() try: sm.do_startup() except StopIteration: pass ================================================ FILE: tests/examples/reusing_transitions_machine.py ================================================ """ ------------------- Reusing transitions ------------------- This example helps to turn visual the different compositions of how to declare and bind :ref:`transitions` to :ref:`event`. .. note:: Even sharing the same transition instance, only the transition actions associated with the event will be called. TrafficLightMachine The same transitions are bound to more than one event. TrafficLightIsolatedTransitions We define new transitions, thus, isolating the connection between states. """ from statemachine import State from statemachine import StateChart class TrafficLightMachine(StateChart): "A traffic light machine" green = State(initial=True) yellow = State() red = State() slowdown = green.to(yellow) stop = yellow.to(red) go = red.to(green) cycle = slowdown | stop | go def before_slowdown(self): print("Slowdown") def before_cycle(self, event: str, source: State, target: State, message: str = ""): message = ". " + message if message else "" return f"Running {event} from {source.id} to {target.id}{message}" def on_enter_red(self): print("Don't move.") def on_exit_red(self): print("Go ahead!") # %% # Run a transition sm = TrafficLightMachine() sm.send("cycle") # %% class TrafficLightIsolatedTransitions(StateChart): "A traffic light machine" green = State(initial=True) yellow = State() red = State() slowdown = green.to(yellow) stop = yellow.to(red) go = red.to(green) cycle = green.to(yellow) | yellow.to(red) | red.to(green) def before_slowdown(self): print("Slowdown") def before_cycle(self, event: str, source: State, target: State, message: str = ""): message = ". " + message if message else "" return f"Running {event} from {source.id} to {target.id}{message}" def on_enter_red(self): print("Don't move.") def on_exit_red(self): print("Go ahead!") # %% # Run a transition sm2 = TrafficLightIsolatedTransitions() sm2.send("cycle") ================================================ FILE: tests/examples/sqlite_persistent_model_machine.py ================================================ """ SQLite-backed approval workflow ================================ Real-world state machines often need to survive process restarts. This example shows how to **persist a StateChart configuration to a relational database**, using the same property getter/setter pattern that ORMs like Django and SQLAlchemy use under the hood. We build a **document approval workflow** where each document must pass both a legal and a technical review (parallel tracks) before it can be approved. If **any** reviewer rejects, the document is rejected immediately — the entire parallel state is exited at once. The example also compares two configuration update strategies controlled by :attr:`~statemachine.statemachine.StateChart.atomic_configuration_update`: - **Incremental** (``False``, ``StateChart`` default, SCXML-spec compliant): the configuration is updated state-by-state as the engine enters and exits states during a microstep. - **Atomic** (``True``, ``StateMachine`` default): the full configuration is computed first and written in a single operation — fewer database writes per transition. """ import sqlite3 from statemachine.orderedset import OrderedSet from statemachine import State from statemachine import StateChart # %% # Database abstraction # -------------------- # # ``WorkflowDB`` manages two tables: # # - **documents** — each row is a domain entity with ``id``, ``title``, # ``author``, and a ``state`` column that holds the serialized state chart # configuration. # - **state_history** — an append-only log of every state mutation, useful for # auditing, debugging, or building a timeline view. # # The state is serialized as a comma-separated string. ``NULL`` means # "no state yet" (the state chart will enter its initial state on creation). class WorkflowDB: """SQLite persistence layer for document workflows.""" def __init__(self): self.conn = sqlite3.connect(":memory:") self.conn.execute( "CREATE TABLE documents (" " id INTEGER PRIMARY KEY AUTOINCREMENT," " title TEXT NOT NULL," " author TEXT NOT NULL," " state TEXT" ")" ) self.conn.execute( "CREATE TABLE state_history (" " id INTEGER PRIMARY KEY AUTOINCREMENT," " document_id INTEGER NOT NULL REFERENCES documents(id)," " old_state TEXT," " new_state TEXT" ")" ) self.conn.commit() def insert_document(self, title, author): """Insert a new document and return its id.""" cur = self.conn.execute( "INSERT INTO documents (title, author) VALUES (?, ?)", (title, author) ) self.conn.commit() return cur.lastrowid def find_document(self, doc_id): """Return ``(title, author)`` for the given document.""" return self.conn.execute( "SELECT title, author FROM documents WHERE id = ?", (doc_id,) ).fetchone() def get_state(self, doc_id): """Read state from the DB and deserialize.""" raw = self.conn.execute("SELECT state FROM documents WHERE id = ?", (doc_id,)).fetchone()[ 0 ] if raw is None: return None parts = raw.split(",") return parts[0] if len(parts) == 1 else OrderedSet(parts) def set_state(self, doc_id, value): """Serialize state, persist it, and record the mutation in history.""" old_raw = self.conn.execute( "SELECT state FROM documents WHERE id = ?", (doc_id,) ).fetchone()[0] if value is None: new_raw = None elif isinstance(value, OrderedSet): new_raw = ",".join(str(v) for v in value) else: new_raw = str(value) self.conn.execute("UPDATE documents SET state = ? WHERE id = ?", (new_raw, doc_id)) self.conn.execute( "INSERT INTO state_history (document_id, old_state, new_state) VALUES (?, ?, ?)", (doc_id, old_raw, new_raw), ) self.conn.commit() def all_documents(self): """Return all rows from the documents table.""" return self.conn.execute( "SELECT id, title, author, state FROM documents ORDER BY id" ).fetchall() def history_for(self, doc_id): """Return the state mutation history for a specific document.""" return self.conn.execute( "SELECT id, old_state, new_state FROM state_history WHERE document_id = ? ORDER BY id", (doc_id,), ).fetchall() def mutation_count(self): """Return the total number of state mutations recorded.""" return self.conn.execute("SELECT COUNT(*) FROM state_history").fetchone()[0] def close(self): self.conn.close() # %% # Domain model # ------------ # # ``Document`` is a domain entity. Its ``state`` property reads from and writes # to the database **on every access** — each getter call returns a **freshly # deserialized** object. This is exactly how Django model fields and # SQLAlchemy column properties work: the ORM never hands you the same Python # object twice. # # Each ``Document`` owns a workflow instance, following the same pattern as # :class:`~statemachine.mixins.MachineMixin`: the model holds a reference to # its state machine. The workflow class is injected at creation time, keeping # the model decoupled from any specific chart definition. class Document: """A document that needs approval.""" def __init__(self, store, doc_id, title, author): self.store = store self.id = doc_id self.title = title self.author = author self.workflow: "ApprovalWorkflow | None" = None @classmethod def create(cls, store, workflow_cls, title, author): """Insert a new document into the DB and start its workflow.""" doc_id = store.insert_document(title, author) doc = cls(store, doc_id, title, author) doc.workflow = workflow_cls(model=doc) return doc @classmethod def load(cls, store, workflow_cls, doc_id): """Restore a document and its workflow from the DB.""" title, author = store.find_document(doc_id) doc = cls(store, doc_id, title, author) doc.workflow = workflow_cls(model=doc) return doc @property def state(self): return self.store.get_state(self.id) @state.setter def state(self, value): self.store.set_state(self.id, value) def __repr__(self): config = list(self.workflow.configuration_values) if self.workflow else "?" return f"Document(#{self.id} {self.title!r} by {self.author}, state={config})" # %% # Approval workflow # ----------------- # # A document starts as a **draft**. When submitted, it enters a **parallel** # review state: legal and technical tracks run independently. # # - **Both approve** → ``done.state.review`` fires → **approved** # - **Any reviewer rejects** → exits the entire parallel state → **rejected** class ApprovalWorkflow(StateChart): """Document approval with parallel legal and technical review tracks.""" draft = State("Draft", initial=True) class review(State.Parallel): class legal_track(State.Compound): legal_pending = State("Legal Pending", initial=True) legal_approved = State("Legal Approved", final=True) approve_legal = legal_pending.to(legal_approved) class tech_track(State.Compound): tech_pending = State("Tech Pending", initial=True) tech_approved = State("Tech Approved", final=True) approve_tech = tech_pending.to(tech_approved) submit = draft.to(review) approved = State("Approved", final=True) rejected = State("Rejected", final=True) done_state_review = review.to(approved) reject_legal = review.to(rejected) reject_tech = review.to(rejected) # %% # Here is the workflow diagram — note the two parallel regions inside # ``review`` and the ``reject_legal`` / ``reject_tech`` transitions that exit # the entire parallel state at once. sm = ApprovalWorkflow() # %% sm # %% # Display helper # ~~~~~~~~~~~~~~ def print_table(headers, rows): """Print a simple formatted table.""" widths = [len(h) for h in headers] for row in rows: for i, val in enumerate(row): widths[i] = max(widths[i], len(str(val) if val is not None else "NULL")) fmt = " ".join(f"{{:<{w}}}" for w in widths) print(fmt.format(*headers)) print(" ".join("-" * w for w in widths)) for row in rows: print(fmt.format(*(str(v) if v is not None else "NULL" for v in row))) # %% # Incremental configuration updates # ---------------------------------- # # ``StateChart`` defaults to ``atomic_configuration_update=False``, following # the SCXML specification: the configuration is modified state-by-state as the # engine enters and exits states during each microstep (``configuration.add()`` # and ``configuration.discard()`` in the W3C algorithm). # # Each ``add()`` or ``discard()`` call triggers the model's ``state`` property # setter, which writes to the database. This means you'll see **one DB write # per state** entered or exited — fine for correctness, but chatty for # persistence layers. db_inc = WorkflowDB() alice = Document.create(db_inc, ApprovalWorkflow, "RFC-001: API Redesign", "Alice") bob = Document.create(db_inc, ApprovalWorkflow, "RFC-002: DB Migration", "Bob") print(f"Created: {alice}") print(f"Created: {bob}") assert alice.state == "draft" assert bob.state == "draft" # %% # Alice's document goes through full approval. alice.workflow.send("submit") print(f"After submit: {alice}") alice.workflow.send("approve_legal") print(f"Legal approved: {alice}") assert "legal_approved" in alice.workflow.configuration_values assert "tech_pending" in alice.workflow.configuration_values alice.workflow.send("approve_tech") print(f"Fully approved: {alice}") # %% # When both tracks reach their final state, ``done.state.review`` fires # automatically and the workflow transitions to **approved**. assert alice.workflow.approved.is_active assert alice.state == "approved" # %% # Bob's document is **rejected** by the legal team. The ``reject_legal`` # event transitions out of the ``review`` parallel state, exiting all child # states at once — even though technical review hasn't started yet. bob.workflow.send("submit") bob.workflow.send("reject_legal") print(f"Rejected: {bob}") assert bob.workflow.rejected.is_active assert bob.state == "rejected" # %% # Documents table (incremental mode) # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ print() print_table(["id", "title", "author", "state"], db_inc.all_documents()) # %% # State mutation history — Alice's document # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # # Every ``add()`` / ``discard()`` call during state entry or exit is a # separate DB write. The history reveals the step-by-step construction and # teardown of the parallel configuration: # # ``draft`` → ``NULL`` → ``review`` → add ``legal_track`` → add # ``legal_pending`` → add ``tech_track`` → add ``tech_pending`` → ... print() print_table(["#", "old_state", "new_state"], db_inc.history_for(alice.id)) inc_mutations = db_inc.mutation_count() print(f"\nTotal mutations (incremental, 2 docs): {inc_mutations}") # %% # Atomic configuration updates # ----------------------------- # # Setting ``atomic_configuration_update=True`` changes the strategy: the # engine computes the full new configuration first, then writes it in a # **single** ``setattr`` call. This means one DB write per microstep instead # of one per state — a significant reduction for parallel charts. # # We can enable this with a one-line subclass: class ApprovalWorkflowAtomic(ApprovalWorkflow): """Same workflow with atomic configuration updates.""" atomic_configuration_update = True # %% # Run the same scenario with atomic updates. db_atom = WorkflowDB() alice2 = Document.create(db_atom, ApprovalWorkflowAtomic, "RFC-001: API Redesign", "Alice") bob2 = Document.create(db_atom, ApprovalWorkflowAtomic, "RFC-002: DB Migration", "Bob") alice2.workflow.send("submit") alice2.workflow.send("approve_legal") alice2.workflow.send("approve_tech") assert alice2.state == "approved" bob2.workflow.send("submit") bob2.workflow.send("reject_legal") assert bob2.state == "rejected" print(f"Alice: {alice2}") print(f"Bob: {bob2}") # %% # State mutation history — Alice's document (atomic mode) # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ # # Each microstep now produces **one** DB write with the full configuration. # No intermediate states are visible. print() print_table(["#", "old_state", "new_state"], db_atom.history_for(alice2.id)) atom_mutations = db_atom.mutation_count() print(f"\nTotal mutations (atomic, 2 docs): {atom_mutations}") # %% # Comparison # ~~~~~~~~~~ # # Both modes produce identical final states, but atomic mode generates # significantly fewer database writes — especially with parallel states where # many children are entered and exited simultaneously. print(f"\nIncremental: {inc_mutations} mutations") print(f"Atomic: {atom_mutations} mutations") assert atom_mutations < inc_mutations # %% # State restoration from the database # ------------------------------------ # # The real test of persistence: delete the Python objects and recreate them # from the database. The state chart should resume exactly where it left off, # preserving even parallel configurations. alice_id = alice.id alice_config = list(alice.workflow.configuration_values) del alice alice_restored = Document.load(db_inc, ApprovalWorkflow, alice_id) print(f"Restored: {alice_restored}") assert list(alice_restored.workflow.configuration_values) == alice_config # %% # Bob's rejection is also preserved — no state is lost. bob_id = bob.id del bob bob_restored = Document.load(db_inc, ApprovalWorkflow, bob_id) print(f"Restored: {bob_restored}") assert bob_restored.state == "rejected" # %% # Final documents table # ~~~~~~~~~~~~~~~~~~~~~~ print() print_table(["id", "title", "author", "state"], db_inc.all_documents()) # %% # Cleanup. db_inc.close() db_atom.close() ================================================ FILE: tests/examples/statechart_cleanup_machine.py ================================================ """ Cleanup / finalize pattern =========================== This example demonstrates how to guarantee cleanup code runs after a transition **regardless of success or failure** — similar to a ``try/finally`` block. With ``StateChart`` (where ``catch_errors_as_events=True`` by default), errors in callbacks are caught at the **block level** — meaning the microstep continues and ``after_()`` callbacks still run. This makes ``after_()`` a natural **finalize** hook. For error-specific handling (logging, recovery), define an ``error.execution`` transition and use :func:`raise_() ` to auto-recover within the same macrostep. """ from statemachine import Event from statemachine import State from statemachine import StateChart class ResourceManager(StateChart): """A machine that acquires a resource, processes, and always releases it. ``after_start`` acts as the **finalize** callback — it runs after the ``start`` transition regardless of whether the job succeeds or fails. On failure, ``error.execution`` additionally transitions to ``recovering`` for error-specific handling before auto-recovering back to ``idle``. """ idle = State("Idle", initial=True) working = State("Working") recovering = State("Recovering") start = idle.to(working) done = working.to(idle) recover = recovering.to(idle) error_execution = Event( working.to(recovering), id="error.execution", ) def __init__(self, job=None): self.job = job or (lambda: None) super().__init__() def on_enter_working(self): print(" [working] resource acquired") self.job() self.raise_("done") # --- Finalize (runs on both success and failure) --- def after_start(self): print(" [after_start] resource released") # --- Error-specific handling --- def on_enter_recovering(self, error=None, **kwargs): print(f" [recovering] error caught: {error}") self.raise_("recover") def on_enter_idle(self): print(" [idle] ready") # %% # Success path # ------------- # # When the job completes without errors, the machine transitions # ``idle → working → idle``. The ``after_start`` callback releases the resource. def good_job(): print(" [working] processing... done!") sm = ResourceManager(job=good_job) print(f"State: {sorted(sm.configuration_values)}") sm.send("start") print(f"State: {sorted(sm.configuration_values)}") assert "idle" in sm.configuration_values # %% # Failure path # ------------- # # When the job raises, the error is caught at the block level and # ``after_start`` **still runs** — releasing the resource. Then # ``error.execution`` fires, transitioning to ``recovering`` for # error-specific handling before auto-recovering to ``idle``. def bad_job(): print(" [working] processing... oops!") raise RuntimeError("something went wrong") sm2 = ResourceManager(job=bad_job) sm2.send("start") print(f"State: {sorted(sm2.configuration_values)}") assert "idle" in sm2.configuration_values ================================================ FILE: tests/examples/statechart_compound_machine.py ================================================ """ Compound states -- Quest through Middle-earth ============================================== This example demonstrates compound (hierarchical) states using ``StateChart``. A compound state contains inner child states, allowing you to model nested behavior. When a compound state is entered, both the parent and its initial child become active. Transitions within a compound change the active child while the parent stays active. Exiting a compound removes all descendants. """ from statemachine import State from statemachine import StateChart class QuestMachine(StateChart): """A quest through Middle-earth with compound states. The journey has two compound regions: the ``shire`` (with locations to visit) and ``rivendell`` (with council activities). A ``wilderness`` state connects them. """ class shire(State.Compound): bag_end = State("Bag End", initial=True) green_dragon = State("The Green Dragon") visit_pub = bag_end.to(green_dragon) class rivendell(State.Compound): council = State("Council of Elrond", initial=True) forging = State("Reforging Narsil", final=True) begin_forging = council.to(forging) wilderness = State("Wilderness") destination = State("Quest continues", final=True) depart_shire = shire.to(wilderness) arrive_rivendell = wilderness.to(rivendell) done_state_rivendell = rivendell.to(destination) # %% # Starting the quest # ------------------ # # When the machine starts, the ``shire`` compound and its initial child ``bag_end`` # are both active. sm = QuestMachine() print(f"Active states: {sorted(sm.configuration_values)}") assert {"shire", "bag_end"} == set(sm.configuration_values) # %% # Transitioning within a compound # -------------------------------- # # Moving within a compound changes the active child. The parent stays active. sm.send("visit_pub") print(f"After visiting pub: {sorted(sm.configuration_values)}") assert "shire" in sm.configuration_values assert "green_dragon" in sm.configuration_values assert "bag_end" not in sm.configuration_values # %% # Exiting a compound # ------------------- # # Leaving a compound removes the parent and all children. sm.send("depart_shire") print(f"In the wilderness: {sorted(sm.configuration_values)}") assert {"wilderness"} == set(sm.configuration_values) # %% # Entering another compound # -------------------------- # # Entering ``rivendell`` activates its initial child ``council``. sm.send("arrive_rivendell") print(f"At Rivendell: {sorted(sm.configuration_values)}") assert {"rivendell", "council"} == set(sm.configuration_values) # %% # done.state event # ------------------ # # When the final child of a compound is reached, a ``done.state.{parent}`` event # fires automatically, triggering the transition to ``destination``. sm.send("begin_forging") print(f"Quest continues: {sorted(sm.configuration_values)}") assert {"destination"} == set(sm.configuration_values) ================================================ FILE: tests/examples/statechart_delayed_machine.py ================================================ """ Supervised task -- Beacons of Gondor ===================================== This example demonstrates a **self-driven** ``StateChart`` combining **compound states**, **parallel states**, **internal events**, **delayed events**, **eventless transitions**, and **event cancellation**. - **Compound states** model the beacon chain: each beacon is a sub-state of a compound, and the ``light_next`` event advances through them. - **Parallel states** run the beacon lighting and the siege clock concurrently inside a single ``StateChart``. - ``raise_("event")`` queues an event on the **internal** queue, processed immediately within the current macrostep. - ``send("event", delay=N)`` schedules a delayed event on the **external** queue, processed only after ``N`` milliseconds. - **Eventless transitions** fire automatically when their ``In()`` guard becomes true, without requiring an explicit event. - ``cancel_event(send_id)`` removes a pending event before it fires. The scenario: Minas Tirith is besieged and the Beacons of Gondor must be lit to summon Rohan's aid. Two things happen in parallel: 1. **Beacons** -- Each beacon's ``on_enter`` lights the next via ``raise_()``, chaining through all seven relay points in a single macrostep (microseconds in wall-clock time). 2. **Siege** -- A delayed ``fall`` event ticks down. If the beacons aren't all lit before the timer expires, the city is overrun. When the last beacon fires and the signal reaches Rohan, an eventless transition detects ``In('rohan_reached')`` and transitions the whole parallel state to the happy ending -- cancelling the siege timer. If the siege timer fires first, ``In('fallen')`` triggers the sad ending instead. .. tip:: Run with ``-v`` to see the engine's macro/micro step debug log:: uv run python tests/examples/statechart_delayed_machine.py -v """ import logging import sys from statemachine import State from statemachine import StateChart if "-v" in sys.argv or "--verbose" in sys.argv: logging.basicConfig(level=logging.DEBUG, format="%(name)s %(message)s", stream=sys.stdout) class BeaconsMachine(StateChart): """Light the Beacons of Gondor before the siege overwhelms Minas Tirith. A parallel state runs two concurrent regions: * **beacons** -- a compound state whose sub-states are the seven beacon relay points from Minas Tirith to Rohan. Each beacon's entry action fires ``raise_("light_next")`` to chain to the next one. * **siege** -- a compound state with a delayed ``fall`` event that represents the city being overrun. Two eventless transitions on the parallel state detect the outcome: * ``In('rohan_reached')`` -- all beacons lit, Rohan is summoned. * ``In('fallen')`` -- siege timer expired, the city falls. """ idle = State("Idle", initial=True) class quest(State.Parallel): class beacons(State.Compound): minas_tirith = State("Minas Tirith", initial=True) amon_din = State("Amon Din") eilenach = State("Eilenach") nardol = State("Nardol") erelas = State("Erelas") min_rimmon = State("Min-Rimmon") calenhad = State("Calenhad") rohan_reached = State("Signal reaches Rohan", final=True) light_next = ( minas_tirith.to(amon_din) | amon_din.to(eilenach) | eilenach.to(nardol) | nardol.to(erelas) | erelas.to(min_rimmon) | min_rimmon.to(calenhad) | calenhad.to(rohan_reached) ) class siege(State.Compound): holding = State("The city holds", initial=True) fallen = State("City overrun", final=True) fall = holding.to(fallen) rohan_rides = State("Rohan rides to aid!", final=True) city_falls = State("Minas Tirith has fallen!", final=True) # External event to kick off the quest start = idle.to(quest) # Eventless transitions -- checked automatically each macrostep quest.to(rohan_rides, cond="In('rohan_reached')") quest.to(city_falls, cond="In('fallen')") siege_timeout_ms: int = 5000 def on_enter_minas_tirith(self): """Gandalf lights the first beacon. The chain begins.""" print(" Minas Tirith -- The beacon is lit!") self.raise_("light_next") def after_light_next(self, target): """Each beacon keeper spots the fire and lights their own.""" if target.final: print(f" {target.name}!") else: print(f" {target.name} -- The beacon is lit!") self.raise_("light_next") def on_enter_holding(self): """The siege clock starts ticking.""" self.send("fall", delay=self.siege_timeout_ms, send_id="siege_timer") def on_enter_rohan_rides(self): self.cancel_event("siege_timer") print(" The beacons are answered! Rohan rides to aid!") def on_enter_city_falls(self): print(" The beacons were never lit. Minas Tirith has fallen.") # %% # Scenario 1: All beacons lit before the siege # ----------------------------------------------- # # A single ``send("start")`` triggers the entire workflow: # # 1. Entering the ``quest`` parallel state activates both regions. # 2. In the **beacons** region, ``on_enter_minas_tirith`` fires # ``raise_("light_next")``, and ``after_light_next`` chains through # all seven beacons via internal events -- completing in microseconds. # 3. In the **siege** region, ``on_enter_holding`` schedules a delayed # ``fall`` event (5 seconds). # 4. The eventless guard ``In('rohan_reached')`` becomes true and the # machine exits the parallel state into ``rohan_rides``. # 5. ``on_enter_rohan_rides`` cancels the pending siege timer. print("=== Scenario 1: Beacons lit in time ===") sm = BeaconsMachine() sm.send("start") print(f" Result: {sorted(sm.configuration_values)}") assert "rohan_rides" in sm.configuration_values # %% # Scenario 2: The beacons are never lit # ---------------------------------------- # # Denethor, in his despair, refuses to light the beacon. The chain never # starts. Because the beacon region stays stuck at ``minas_tirith``, the # processing loop has nothing to do except busy-wait (sleeping 1 ms per # cycle) for the delayed ``fall`` event. # # The siege timeout is set to just 10 ms for this demonstration -- any # value > 0 would work since the machine is completely idle while waiting. # When the delayed ``fall`` event fires, ``holding`` transitions to # ``fallen``, and the eventless guard ``In('fallen')`` routes the machine # to ``city_falls``. class FailedBeaconsMachine(BeaconsMachine): """Denethor refuses to light the beacons. The city is lost.""" siege_timeout_ms: int = 10 def on_enter_minas_tirith(self): print(" Denethor: 'Why do the fools fly? Better to die sooner than late.'") print() print("=== Scenario 2: The beacons are never lit ===") sm2 = FailedBeaconsMachine() sm2.send("start") print(f" Result: {sorted(sm2.configuration_values)}") assert "city_falls" in sm2.configuration_values ================================================ FILE: tests/examples/statechart_error_handling_machine.py ================================================ """ Error handling -- Quest Recovery ================================= This example demonstrates **error.execution** handling using ``StateChart``. When ``catch_errors_as_events=True`` (the ``StateChart`` default), runtime errors in callbacks are caught and dispatched as ``error.execution`` events instead of propagating as exceptions. This lets you define error-recovery transitions. - The ``error_`` naming convention auto-registers both ``error_X`` and ``error.X`` event names. - Alternatively, use ``Event(transitions, id="error.execution")`` for explicit registration. - Error data (the original exception, event, etc.) is available in handler kwargs. """ from statemachine import Event from statemachine import State from statemachine import StateChart class QuestRecoveryMachine(StateChart): """A quest where actions can fail and the error handler routes to recovery. When ``on_enter_danger_zone`` raises, the ``error.execution`` event fires and transitions to the ``recovering`` state instead of crashing. """ safe = State("Safe", initial=True) danger_zone = State("Danger Zone") recovering = State("Recovering") completed = State("Quest Complete", final=True) venture = safe.to(danger_zone) survive = danger_zone.to(completed) recover = recovering.to(safe) # Register error.execution handler using Event with explicit id error_execution = Event( safe.to(recovering) | danger_zone.to(recovering), id="error.execution", ) def on_enter_danger_zone(self): # This simulates an unexpected error during a quest action raise RuntimeError("Ambush! Orcs attack!") def on_enter_recovering(self, error=None, **kwargs): if error: print(f"Error caught: {error}") print("Retreating to recover...") # %% # Error triggers recovery instead of crashing # ---------------------------------------------- # # When entering ``danger_zone`` raises a ``RuntimeError``, the error is caught # and dispatched as ``error.execution``. The machine transitions to ``recovering``. sm = QuestRecoveryMachine() print(f"Start: {sorted(sm.configuration_values)}") assert "safe" in sm.configuration_values sm.send("venture") print(f"After venture: {sorted(sm.configuration_values)}") assert "recovering" in sm.configuration_values # %% # Recover and try again # ----------------------- sm.send("recover") print(f"After recovery: {sorted(sm.configuration_values)}") assert "safe" in sm.configuration_values # %% # Comparison with catch_errors_as_events=False (error propagation) # -------------------------------------------------------------- # # With ``catch_errors_as_events=False``, the same error # would propagate as an exception instead of being caught. class QuestNoCatch(StateChart): catch_errors_as_events = False safe = State("Safe", initial=True) danger_zone = State("Danger Zone", final=True) venture = safe.to(danger_zone) def on_enter_danger_zone(self): raise RuntimeError("Ambush! Orcs attack!") sm2 = QuestNoCatch() try: sm2.send("venture") except RuntimeError as e: print(f"Exception propagated: {e}") ================================================ FILE: tests/examples/statechart_eventless_machine.py ================================================ """ Eventless (automatic) transitions -- The One Ring's Corruption ============================================================== This example demonstrates **eventless transitions** using ``StateChart``. An eventless transition has no triggering event -- it fires automatically when its guard condition becomes true during the macrostep processing loop. Eventless transitions are evaluated after every macrostep. If the condition is met, the transition fires without any explicit event. Multiple eventless transitions can cascade in a single macrostep. """ from statemachine import State from statemachine import StateChart class RingCorruptionMachine(StateChart): """The One Ring gradually corrupts its bearer. As ``ring_power`` increases, automatic transitions fire when thresholds are crossed. No explicit events drive the state changes -- only the guard conditions. A ``tick`` internal self-transition is used to re-trigger the processing loop after changing ``ring_power`` from the outside. """ # States represent corruption stages resisting = State("Resisting", initial=True) tempted = State("Tempted") corrupted = State("Corrupted") lost = State("Lost to the Ring", final=True) # Eventless transitions: fire automatically when conditions are met resisting.to(tempted, cond="is_tempted") tempted.to(corrupted, cond="is_corrupted") corrupted.to(lost, cond="is_lost") # A no-op event to re-trigger the processing loop tick = ( resisting.to.itself(internal=True) | tempted.to.itself(internal=True) | corrupted.to.itself(internal=True) ) ring_power: int = 0 def is_tempted(self): return self.ring_power >= 3 def is_corrupted(self): return self.ring_power >= 6 def is_lost(self): return self.ring_power >= 9 # %% # The bearer starts by resisting # ------------------------------- sm = RingCorruptionMachine() print(f"Stage: {sorted(sm.configuration_values)}") assert "resisting" in sm.configuration_values # %% # Increase ring power below threshold -- nothing changes # ------------------------------------------------------- # # Setting ``ring_power`` alone doesn't trigger processing. We send a ``tick`` # event to re-enter the processing loop where eventless transitions are checked. sm.ring_power = 2 sm.send("tick") print(f"Power 2 -> Stage: {sorted(sm.configuration_values)}") assert "resisting" in sm.configuration_values # %% # Cross the first threshold -- automatic transition to "tempted" # --------------------------------------------------------------- sm.ring_power = 4 sm.send("tick") print(f"Power 4 -> Stage: {sorted(sm.configuration_values)}") assert "tempted" in sm.configuration_values # %% # Cross multiple thresholds at once -- cascade in one macrostep # -------------------------------------------------------------- # # When ``ring_power`` jumps past several thresholds, all matching eventless # transitions fire in sequence within a single macrostep. sm.ring_power = 10 sm.send("tick") print(f"Power 10 -> Stage: {sorted(sm.configuration_values)}") assert "lost" in sm.configuration_values ================================================ FILE: tests/examples/statechart_history_machine.py ================================================ """ History states -- Gollum's dual personality ============================================ This example demonstrates history pseudo-states using ``StateChart``. A history state records the active child of a compound state when it is exited. Re-entering via the history state restores the previously active child instead of starting from the initial child. Both shallow history (``HistoryState()``) and deep history (``HistoryState(type="deep")``) are shown. """ from statemachine import HistoryState from statemachine import State from statemachine import StateChart class PersonalityMachine(StateChart): """Gollum's dual personality with shallow history. The ``personality`` compound has two children: ``smeagol`` and ``gollum``. When Gollum leaves the ``personality`` state and returns via the history pseudo-state, the previously active personality is restored. """ class personality(State.Compound): smeagol = State("Smeagol", initial=True) gollum = State("Gollum") h = HistoryState() dark_side = smeagol.to(gollum) light_side = gollum.to(smeagol) outside = State("Outside") leave = personality.to(outside) return_via_history = outside.to(personality.h) # %% # Shallow history remembers the last child # ------------------------------------------ sm = PersonalityMachine() print(f"Initial: {sorted(sm.configuration_values)}") assert "smeagol" in sm.configuration_values # Switch to Gollum, then leave sm.send("dark_side") print(f"Gollum active: {sorted(sm.configuration_values)}") assert "gollum" in sm.configuration_values sm.send("leave") print(f"Left: {sorted(sm.configuration_values)}") assert {"outside"} == set(sm.configuration_values) # Return via history -> Gollum is restored sm.send("return_via_history") print(f"History restored: {sorted(sm.configuration_values)}") assert "gollum" in sm.configuration_values assert "personality" in sm.configuration_values # %% # Multiple exit/reentry cycles # ------------------------------ # # History updates each time the compound is exited. sm.send("light_side") print(f"Switched to Smeagol: {sorted(sm.configuration_values)}") assert "smeagol" in sm.configuration_values sm.send("leave") sm.send("return_via_history") print(f"Smeagol restored: {sorted(sm.configuration_values)}") assert "smeagol" in sm.configuration_values # %% # Deep history with nested compounds # ------------------------------------ # # Deep history remembers the exact leaf state in nested compounds. class DeepPersonalityMachine(StateChart): """A machine with nested compounds and deep history.""" class realm(State.Compound): class inner(State.Compound): entrance = State("Entrance", initial=True) chamber = State("Chamber") explore = entrance.to(chamber) assert isinstance(inner, State) h = HistoryState(type="deep") # type: ignore[has-type] bridge = State("Bridge", final=True) flee = inner.to(bridge) outside = State("Outside") escape = realm.to(outside) return_deep = outside.to(realm.h) # type: ignore[has-type] sm2 = DeepPersonalityMachine() print(f"\nDeep history initial: {sorted(sm2.configuration_values)}") assert "entrance" in sm2.configuration_values # Move to the inner leaf state sm2.send("explore") print(f"Explored chamber: {sorted(sm2.configuration_values)}") assert "chamber" in sm2.configuration_values # Exit and return via deep history sm2.send("escape") print(f"Escaped: {sorted(sm2.configuration_values)}") assert {"outside"} == set(sm2.configuration_values) sm2.send("return_deep") print(f"Deep history restored: {sorted(sm2.configuration_values)}") assert "chamber" in sm2.configuration_values assert "inner" in sm2.configuration_values assert "realm" in sm2.configuration_values ================================================ FILE: tests/examples/statechart_in_condition_machine.py ================================================ """ In() guard condition -- Fellowship Coordination ================================================= This example demonstrates the **In()** guard condition using ``StateChart`` with parallel states. ``In('state_id')`` checks whether a given state is currently active. This is especially useful in parallel regions where one region's transitions depend on the state of another region. """ from statemachine import State from statemachine import StateChart class FellowshipMachine(StateChart): """Fellowship coordination with parallel regions. Two parallel regions track Frodo and Sam independently. The key transition -- ``sam_to_mordor`` -- uses ``In('mordor_f')`` to ensure Sam only follows Frodo to Mordor after Frodo has already arrived there. """ class quest(State.Parallel): class frodo_path(State.Compound): shire_f = State("Frodo in Shire", initial=True) rivendell_f = State("Frodo at Rivendell") mordor_f = State("Frodo in Mordor", final=True) frodo_to_rivendell = shire_f.to(rivendell_f) frodo_to_mordor = rivendell_f.to(mordor_f) class sam_path(State.Compound): shire_s = State("Sam in Shire", initial=True) rivendell_s = State("Sam at Rivendell") mordor_s = State("Sam in Mordor") mount_doom_s = State("Sam at Mount Doom", final=True) sam_to_rivendell = shire_s.to(rivendell_s) # Sam can only go to Mordor when Frodo is already there sam_to_mordor = rivendell_s.to(mordor_s, cond="In('mordor_f')") sam_to_mount_doom = mordor_s.to(mount_doom_s) victory = State("Victory", final=True) done_state_quest = quest.to(victory) # %% # Initial state -- both in the Shire # ------------------------------------ sm = FellowshipMachine() vals = set(sm.configuration_values) print(f"Start: {sorted(vals)}") assert "shire_f" in vals assert "shire_s" in vals # %% # Move both to Rivendell independently # --------------------------------------- sm.send("frodo_to_rivendell") sm.send("sam_to_rivendell") vals = set(sm.configuration_values) print(f"Both at Rivendell: {sorted(vals)}") assert "rivendell_f" in vals assert "rivendell_s" in vals # %% # Sam can't go to Mordor yet -- In('mordor_f') is false # ------------------------------------------------------- # # Frodo hasn't reached Mordor, so ``In('mordor_f')`` evaluates to false # and Sam's transition is blocked. sm.send("sam_to_mordor") vals = set(sm.configuration_values) print(f"Sam blocked: {sorted(vals)}") assert "rivendell_s" in vals # Sam still at Rivendell # %% # Frodo reaches Mordor -- now Sam can follow # --------------------------------------------- # # After Frodo transitions to ``mordor_f``, the ``In('mordor_f')`` condition # becomes true. Now sending ``sam_to_mordor`` will succeed. sm.send("frodo_to_mordor") vals = set(sm.configuration_values) print(f"Frodo in Mordor: {sorted(vals)}") assert "mordor_f" in vals assert "rivendell_s" in vals # Sam still waiting # %% # Sam follows Frodo -- In() guard passes # ---------------------------------------- sm.send("sam_to_mordor") vals = set(sm.configuration_values) print(f"Sam follows: {sorted(vals)}") assert "mordor_s" in vals # %% # Both regions complete -- done.state fires # ------------------------------------------- # # When both parallel regions reach their final states, ``done.state.quest`` # fires automatically and transitions to ``victory``. sm.send("sam_to_mount_doom") print(f"Victory: {sorted(sm.configuration_values)}") assert "victory" in sm.configuration_values ================================================ FILE: tests/examples/statechart_parallel_machine.py ================================================ """ Parallel states -- War of the Ring =================================== This example demonstrates parallel states using ``StateChart``. A parallel state activates all child regions simultaneously. Each region operates independently -- events in one region don't affect others. The ``done.state`` event fires only when **all** regions reach a final state. """ from statemachine import State from statemachine import StateChart class WarMachine(StateChart): """The War of the Ring with parallel fronts. Three independent fronts run simultaneously inside the ``war`` parallel state: Frodo's quest to destroy the Ring, Aragorn's path to kingship, and Gandalf's defense of the realms. """ class war(State.Parallel): class frodos_quest(State.Compound): shire = State("The Shire", initial=True) mordor = State("Mordor") mount_doom = State("Mount Doom", final=True) journey = shire.to(mordor) destroy_ring = mordor.to(mount_doom) class aragorns_path(State.Compound): ranger = State("Ranger", initial=True) king = State("King of Gondor", final=True) coronation = ranger.to(king) class gandalfs_defense(State.Compound): rohan = State("Rohan", initial=True) gondor = State("Gondor", final=True) ride_to_gondor = rohan.to(gondor) peace = State("Peace in Middle-earth", final=True) done_state_war = war.to(peace) # %% # All regions activate at once # ----------------------------- # # Entering the ``war`` parallel state activates the initial child of every region. sm = WarMachine() config = set(sm.configuration_values) print(f"Active states: {sorted(config)}") expected = {"war", "frodos_quest", "shire", "aragorns_path", "ranger", "gandalfs_defense", "rohan"} assert expected.issubset(config) # %% # Independent transitions # ------------------------ # # An event in one region does not affect others. sm.send("journey") print(f"Frodo journeys: {sorted(sm.configuration_values)}") assert "mordor" in sm.configuration_values assert "ranger" in sm.configuration_values # Aragorn unchanged assert "rohan" in sm.configuration_values # Gandalf unchanged # %% # Partial completion # ------------------- # # One region reaching final doesn't end the parallel state. sm.send("coronation") print(f"Aragorn crowned: {sorted(sm.configuration_values)}") assert "king" in sm.configuration_values assert "war" in sm.configuration_values # parallel still active # %% # All regions reach final # ------------------------ # # When all regions reach final, ``done.state.war`` fires and transitions to ``peace``. sm.send("ride_to_gondor") print(f"Gandalf in Gondor: {sorted(sm.configuration_values)}") assert "war" in sm.configuration_values # Frodo not done yet sm.send("destroy_ring") print(f"Peace: {sorted(sm.configuration_values)}") assert {"peace"} == set(sm.configuration_values) ================================================ FILE: tests/examples/traffic_light_machine.py ================================================ """ --------------------- Traffic light machine --------------------- This example demonstrates how to create a traffic light machine using the ``StateChart`` class. The state machine will run in a dedicated thread and will cycle through the states. """ import time from threading import Event as ThreadingEvent from threading import Thread from statemachine import State from statemachine import StateChart class TrafficLightMachine(StateChart): "A traffic light machine" green = State(initial=True) yellow = State() red = State() cycle = green.to(yellow) | yellow.to(red) | red.to(green) def before_cycle(self, event: str, source: State, target: State): print(f"Running {event} from {source.id} to {target.id}") # %% # Run in a dedicated thread class Supervisor: def __init__(self, sm: StateChart, sm_event: str): self.sm = sm self.sm_event = sm_event self.stop_event = ThreadingEvent() def run(self): while not self.stop_event.is_set(): self.sm.send(self.sm_event) self.stop_event.wait(0.1) def stop(self): self.stop_event.set() def main(): supervisor = Supervisor(TrafficLightMachine(), "cycle") t = Thread(target=supervisor.run) t.start() time.sleep(0.5) supervisor.stop() t.join() if __name__ == "__main__": main() ================================================ FILE: tests/examples/user_machine.py ================================================ """ User workflow machine ===================== This machine binds the events to the User model, the StateChart is wrapped internally in the `User` class. Demonstrates that multiple state machines can be used in the same model. And that logic can be reused with listeners. """ from dataclasses import dataclass from enum import Enum from statemachine.states import States from statemachine import State from statemachine import StateChart class UserStatus(str, Enum): signup_incomplete = "SIGNUP_INCOMPLETE" signup_complete = "SIGNUP_COMPLETE" signup_rejected = "SIGNUP_REJECTED" operational_enabled = "OPERATIONAL_ENABLED" operational_disabled = "OPERATIONAL_DISABLED" operational_rescinded = "OPERATIONAL_RESCINDED" class UserExperience(str, Enum): basic = "BASIC" premium = "PREMIUM" @dataclass class User: name: str email: str status: UserStatus = UserStatus.signup_incomplete experience: UserExperience = UserExperience.basic verified: bool = False def __post_init__(self): self._status_sm = UserStatusMachine( self, state_field="status", listeners=[MachineChangeListenter()] ) self._status_sm.bind_events_to(self) self._experience_sm = UserExperienceMachine( self, state_field="experience", listeners=[MachineChangeListenter()] ) self._experience_sm.bind_events_to(self) class MachineChangeListenter: def before_transition(self, event: str, state: State): print(f"Before {event} in {state}") def on_enter_state(self, state: State, event: str): print(f"Entering {state} from {event}") class UserStatusMachine(StateChart): catch_errors_as_events = False _states = States.from_enum( UserStatus, initial=UserStatus.signup_incomplete, final=[ UserStatus.operational_rescinded, UserStatus.signup_rejected, ], ) signup = _states.signup_incomplete.to(_states.signup_complete) reject = _states.signup_rejected.from_( _states.signup_incomplete, _states.signup_complete, ) enable = _states.signup_complete.to(_states.operational_enabled) disable = _states.operational_enabled.to(_states.operational_disabled) rescind = _states.operational_rescinded.from_( _states.operational_enabled, _states.operational_disabled, ) def on_signup(self, token: str): if token == "": raise ValueError("Token is required") self.model.verified = True # type: ignore[union-attr] class UserExperienceMachine(StateChart): _states = States.from_enum( UserExperience, initial=UserExperience.basic, ) upgrade = _states.basic.to(_states.premium) downgrade = _states.premium.to(_states.basic) # %% # Executing def main(): # type: ignore[attr-defined] # By binding the events to the User model, the events can be fired directly from the model user = User(name="Frodo", email="frodo@lor.com") try: # Trying to signup with an empty token should raise an exception user.signup("") except Exception as e: print(e) assert user.verified is False user.signup("1234") assert user.status == UserStatus.signup_complete assert user.verified is True print(user.experience) user.upgrade() print(user.experience) if __name__ == "__main__": main() ================================================ FILE: tests/examples/weighted_idle_machine.py ================================================ """ ------------------------------ Weighted idle animation machine ------------------------------ This example demonstrates how to use ``weighted_transitions`` to create probabilistic idle animations for a game character. Each time the ``idle`` event fires, the character randomly picks an animation based on relative weights. """ from statemachine.contrib.weighted import weighted_transitions from statemachine import State from statemachine import StateChart class WeightedIdleMachine(StateChart): """A game character with weighted idle animations. When idle, the character randomly picks an animation based on weights: - 70% chance: shift weight from foot to foot - 20% chance: adjust hair - 10% chance: bang shield """ standing = State(initial=True) shift_weight = State() adjust_hair = State() bang_shield = State() idle = weighted_transitions( standing, (shift_weight, 70), (adjust_hair, 20), (bang_shield, 10), seed=42, ) finish = shift_weight.to(standing) | adjust_hair.to(standing) | bang_shield.to(standing) ================================================ FILE: tests/helpers.py ================================================ import importlib from pathlib import Path def import_module_by_path(src_file: Path): module_name = str(src_file).replace("/", ".") try: return importlib.import_module(module_name) except ModuleNotFoundError: return ================================================ FILE: tests/machines/__init__.py ================================================ ================================================ FILE: tests/machines/compound/__init__.py ================================================ ================================================ FILE: tests/machines/compound/middle_earth_journey.py ================================================ from statemachine import State from statemachine import StateChart class MiddleEarthJourney(StateChart): class rivendell(State.Compound): council = State(initial=True) preparing = State() get_ready = council.to(preparing) class moria(State.Compound): gates = State(initial=True) bridge = State(final=True) cross = gates.to(bridge) class lothlorien(State.Compound): mirror = State(initial=True) departure = State(final=True) leave = mirror.to(departure) march_to_moria = rivendell.to(moria) march_to_lorien = moria.to(lothlorien) ================================================ FILE: tests/machines/compound/middle_earth_journey_two_compounds.py ================================================ from statemachine import State from statemachine import StateChart class MiddleEarthJourneyTwoCompounds(StateChart): class rivendell(State.Compound): council = State(initial=True) preparing = State() get_ready = council.to(preparing) class moria(State.Compound): gates = State(initial=True) bridge = State(final=True) cross = gates.to(bridge) march_to_moria = rivendell.to(moria) ================================================ FILE: tests/machines/compound/middle_earth_journey_with_finals.py ================================================ from statemachine import State from statemachine import StateChart class MiddleEarthJourneyWithFinals(StateChart): class rivendell(State.Compound): council = State(initial=True) preparing = State(final=True) get_ready = council.to(preparing) class moria(State.Compound): gates = State(initial=True) bridge = State(final=True) cross = gates.to(bridge) class lothlorien(State.Compound): mirror = State(initial=True) departure = State(final=True) leave = mirror.to(departure) march_to_moria = rivendell.to(moria) march_to_lorien = moria.to(lothlorien) ================================================ FILE: tests/machines/compound/moria_expedition.py ================================================ from statemachine import State from statemachine import StateChart class MoriaExpedition(StateChart): class moria(State.Compound): class upper_halls(State.Compound): entrance = State(initial=True) bridge = State(final=True) cross = entrance.to(bridge) assert isinstance(upper_halls, State) depths = State(final=True) descend = upper_halls.to(depths) ================================================ FILE: tests/machines/compound/moria_expedition_with_escape.py ================================================ from statemachine import State from statemachine import StateChart class MoriaExpeditionWithEscape(StateChart): class moria(State.Compound): class upper_halls(State.Compound): entrance = State(initial=True) bridge = State() cross = entrance.to(bridge) assert isinstance(upper_halls, State) depths = State(final=True) descend = upper_halls.to(depths) daylight = State(final=True) escape = moria.to(daylight) ================================================ FILE: tests/machines/compound/quest_for_erebor.py ================================================ from statemachine import State from statemachine import StateChart class QuestForErebor(StateChart): class lonely_mountain(State.Compound): approach = State(initial=True) inside = State(final=True) enter_mountain = approach.to(inside) victory = State(final=True) done_state_lonely_mountain = lonely_mountain.to(victory) ================================================ FILE: tests/machines/compound/shire_to_rivendell.py ================================================ from statemachine import State from statemachine import StateChart class ShireToRivendell(StateChart): class shire(State.Compound): bag_end = State(initial=True) green_dragon = State() visit_pub = bag_end.to(green_dragon) road = State(final=True) depart = shire.to(road) ================================================ FILE: tests/machines/donedata/__init__.py ================================================ ================================================ FILE: tests/machines/donedata/destroy_the_ring.py ================================================ from statemachine import Event from statemachine import State from statemachine import StateChart class DestroyTheRing(StateChart): class quest(State.Compound): traveling = State(initial=True) completed = State(final=True, donedata="get_quest_result") finish = traveling.to(completed) def get_quest_result(self): return {"ring_destroyed": True, "hero": "frodo"} epilogue = State(final=True) done_state_quest = Event(quest.to(epilogue, on="capture_result")) # type: ignore[arg-type] def capture_result(self, ring_destroyed=None, hero=None, **kwargs): self.received = {"ring_destroyed": ring_destroyed, "hero": hero} ================================================ FILE: tests/machines/donedata/destroy_the_ring_simple.py ================================================ from statemachine import Event from statemachine import State from statemachine import StateChart class DestroyTheRingSimple(StateChart): class quest(State.Compound): traveling = State(initial=True) completed = State(final=True, donedata="get_result") finish = traveling.to(completed) def get_result(self): return {"outcome": "victory"} celebration = State(final=True) done_state_quest = Event(quest.to(celebration)) # type: ignore[arg-type] ================================================ FILE: tests/machines/donedata/nested_quest_donedata.py ================================================ from statemachine import Event from statemachine import State from statemachine import StateChart class NestedQuestDoneData(StateChart): class outer(State.Compound): class inner(State.Compound): start = State(initial=True) end = State(final=True, donedata="inner_result") go = start.to(end) def inner_result(self): return {"level": "inner"} assert isinstance(inner, State) after_inner = State(final=True) done_state_inner = Event(inner.to(after_inner)) # type: ignore[arg-type] final = State(final=True) done_state_outer = Event(outer.to(final)) # type: ignore[arg-type] ================================================ FILE: tests/machines/donedata/quest_for_erebor_done_convention.py ================================================ from statemachine import State from statemachine import StateChart class QuestForEreborDoneConvention(StateChart): class quest(State.Compound): traveling = State(initial=True) arrived = State(final=True) finish = traveling.to(arrived) celebration = State(final=True) done_state_quest = quest.to(celebration) ================================================ FILE: tests/machines/donedata/quest_for_erebor_explicit_id.py ================================================ from statemachine import Event from statemachine import State from statemachine import StateChart class QuestForEreborExplicitId(StateChart): class quest(State.Compound): traveling = State(initial=True) arrived = State(final=True) finish = traveling.to(arrived) celebration = State(final=True) done_state_quest = Event(quest.to(celebration), id="done.state.quest") # type: ignore[arg-type] ================================================ FILE: tests/machines/donedata/quest_for_erebor_multi_word.py ================================================ from statemachine import State from statemachine import StateChart class QuestForEreborMultiWord(StateChart): class lonely_mountain(State.Compound): approach = State(initial=True) inside = State(final=True) enter_mountain = approach.to(inside) victory = State(final=True) done_state_lonely_mountain = lonely_mountain.to(victory) ================================================ FILE: tests/machines/donedata/quest_for_erebor_with_event.py ================================================ from statemachine import Event from statemachine import State from statemachine import StateChart class QuestForEreborWithEvent(StateChart): class quest(State.Compound): traveling = State(initial=True) arrived = State(final=True) finish = traveling.to(arrived) celebration = State(final=True) done_state_quest = Event(quest.to(celebration)) # type: ignore[arg-type] ================================================ FILE: tests/machines/error/__init__.py ================================================ ================================================ FILE: tests/machines/error/error_convention_event.py ================================================ from statemachine import Event from statemachine import State from statemachine import StateChart class ErrorConventionEventSC(StateChart): """Using Event without explicit id with error_ prefix auto-registers dot notation.""" s1 = State("s1", initial=True) error_state = State("error_state", final=True) go = s1.to(s1, on="bad_action") error_execution = Event(s1.to(error_state)) def bad_action(self): raise RuntimeError("action failed") ================================================ FILE: tests/machines/error/error_convention_transition_list.py ================================================ from statemachine import State from statemachine import StateChart class ErrorConventionTransitionListSC(StateChart): """Using bare TransitionList with error_ prefix auto-registers dot notation.""" s1 = State("s1", initial=True) error_state = State("error_state", final=True) go = s1.to(s1, on="bad_action") error_execution = s1.to(error_state) def bad_action(self): raise RuntimeError("action failed") ================================================ FILE: tests/machines/error/error_in_action_sc.py ================================================ from statemachine import Event from statemachine import State from statemachine import StateChart class ErrorInActionSC(StateChart): s1 = State("s1", initial=True) s2 = State("s2") error_state = State("error_state", final=True) go = s1.to(s2, on="bad_action") error_execution = Event(s1.to(error_state) | s2.to(error_state), id="error.execution") def bad_action(self): raise RuntimeError("action failed") ================================================ FILE: tests/machines/error/error_in_action_sm_with_flag.py ================================================ from statemachine import Event from statemachine import State from statemachine import StateChart class ErrorInActionSMWithFlag(StateChart): """StateChart subclass (catch_errors_as_events = True by default).""" s1 = State("s1", initial=True) s2 = State("s2") error_state = State("error_state", final=True) go = s1.to(s2, on="bad_action") error_execution = Event(s1.to(error_state) | s2.to(error_state), id="error.execution") def bad_action(self): raise RuntimeError("action failed") ================================================ FILE: tests/machines/error/error_in_after_sc.py ================================================ from statemachine import Event from statemachine import State from statemachine import StateChart class ErrorInAfterSC(StateChart): s1 = State("s1", initial=True) s2 = State("s2") error_state = State("error_state", final=True) go = s1.to(s2, after="bad_after") error_execution = Event(s2.to(error_state), id="error.execution") def bad_after(self): raise RuntimeError("after failed") ================================================ FILE: tests/machines/error/error_in_error_handler_sc.py ================================================ from statemachine import Event from statemachine import State from statemachine import StateChart class ErrorInErrorHandlerSC(StateChart): """Error in error.execution handler should not cause infinite loop.""" s1 = State("s1", initial=True) s2 = State("s2") s3 = State("s3", final=True) go = s1.to(s2, on="bad_action") finish = s2.to(s3) error_execution = Event( s1.to(s1, on="bad_error_handler") | s2.to(s2, on="bad_error_handler"), id="error.execution", ) def bad_action(self): raise RuntimeError("action failed") def bad_error_handler(self): raise RuntimeError("error handler also failed") ================================================ FILE: tests/machines/error/error_in_guard_sc.py ================================================ from statemachine import Event from statemachine import State from statemachine import StateChart class ErrorInGuardSC(StateChart): initial = State("initial", initial=True) error_state = State("error_state", final=True) go = initial.to(initial, cond="bad_guard") | initial.to(initial) error_execution = Event(initial.to(error_state), id="error.execution") def bad_guard(self): raise RuntimeError("guard failed") ================================================ FILE: tests/machines/error/error_in_guard_sm.py ================================================ from statemachine import State from statemachine import StateChart class ErrorInGuardSM(StateChart): """StateChart subclass with catch_errors_as_events=False: exceptions should propagate.""" catch_errors_as_events = False initial = State("initial", initial=True) go = initial.to(initial, cond="bad_guard") | initial.to(initial) def bad_guard(self): raise RuntimeError("guard failed") ================================================ FILE: tests/machines/error/error_in_on_enter_sc.py ================================================ from statemachine import Event from statemachine import State from statemachine import StateChart class ErrorInOnEnterSC(StateChart): s1 = State("s1", initial=True) s2 = State("s2") error_state = State("error_state", final=True) go = s1.to(s2) error_execution = Event(s1.to(error_state) | s2.to(error_state), id="error.execution") def on_enter_s2(self): raise RuntimeError("on_enter failed") ================================================ FILE: tests/machines/eventless/__init__.py ================================================ ================================================ FILE: tests/machines/eventless/auto_advance.py ================================================ from statemachine import State from statemachine import StateChart class AutoAdvance(StateChart): class journey(State.Compound): step1 = State(initial=True) step2 = State() step3 = State(final=True) step1.to(step2) step2.to(step3) done = State(final=True) done_state_journey = journey.to(done) ================================================ FILE: tests/machines/eventless/beacon_chain.py ================================================ from statemachine import State from statemachine import StateChart class BeaconChain(StateChart): class beacons(State.Compound): first = State(initial=True) last = State(final=True) first.to(last) signal_received = State(final=True) done_state_beacons = beacons.to(signal_received) ================================================ FILE: tests/machines/eventless/beacon_chain_lighting.py ================================================ from statemachine import State from statemachine import StateChart class BeaconChainLighting(StateChart): class chain(State.Compound): amon_din = State(initial=True) eilenach = State() nardol = State() halifirien = State(final=True) # Eventless chain: each fires immediately amon_din.to(eilenach) eilenach.to(nardol) nardol.to(halifirien) all_lit = State(final=True) done_state_chain = chain.to(all_lit) ================================================ FILE: tests/machines/eventless/coordinated_advance.py ================================================ from statemachine import State from statemachine import StateChart class CoordinatedAdvance(StateChart): class forces(State.Parallel): class vanguard(State.Compound): waiting = State(initial=True) advanced = State(final=True) move_forward = waiting.to(advanced) class rearguard(State.Compound): holding = State(initial=True) moved_up = State(final=True) # Eventless: advance only when vanguard has advanced holding.to(moved_up, cond="In('advanced')") ================================================ FILE: tests/machines/eventless/ring_corruption.py ================================================ from statemachine import State from statemachine import StateChart class RingCorruption(StateChart): resisting = State(initial=True) corrupted = State(final=True) # eventless: no event name resisting.to(corrupted, cond="is_corrupted") ring_power = 0 def is_corrupted(self): return self.ring_power > 5 def increase_power(self): self.ring_power += 3 ================================================ FILE: tests/machines/eventless/ring_corruption_with_bear_ring.py ================================================ from statemachine import State from statemachine import StateChart class RingCorruptionWithBearRing(StateChart): resisting = State(initial=True) corrupted = State(final=True) resisting.to(corrupted, cond="is_corrupted") bear_ring = resisting.to.itself(internal=True, on="increase_power") ring_power = 0 def is_corrupted(self): return self.ring_power > 5 def increase_power(self): self.ring_power += 2 ================================================ FILE: tests/machines/eventless/ring_corruption_with_tick.py ================================================ from statemachine import State from statemachine import StateChart class RingCorruptionWithTick(StateChart): resisting = State(initial=True) corrupted = State(final=True) resisting.to(corrupted, cond="is_corrupted") tick = resisting.to.itself(internal=True) ring_power = 0 def is_corrupted(self): return self.ring_power > 5 ================================================ FILE: tests/machines/history/__init__.py ================================================ ================================================ FILE: tests/machines/history/deep_memory_of_moria.py ================================================ from statemachine import HistoryState from statemachine import State from statemachine import StateChart class DeepMemoryOfMoria(StateChart): class moria(State.Compound): class halls(State.Compound): entrance = State(initial=True) chamber = State() explore = entrance.to(chamber) assert isinstance(halls, State) h = HistoryState(type="deep") bridge = State(final=True) flee = halls.to(bridge) outside = State() escape = moria.to(outside) return_deep = outside.to(moria.h) # type: ignore[has-type] ================================================ FILE: tests/machines/history/gollum_personality.py ================================================ from statemachine import HistoryState from statemachine import State from statemachine import StateChart class GollumPersonality(StateChart): class personality(State.Compound): smeagol = State(initial=True) gollum = State() h = HistoryState() dark_side = smeagol.to(gollum) light_side = gollum.to(smeagol) outside = State() leave = personality.to(outside) return_via_history = outside.to(personality.h) ================================================ FILE: tests/machines/history/gollum_personality_default_gollum.py ================================================ from statemachine import HistoryState from statemachine import State from statemachine import StateChart class GollumPersonalityDefaultGollum(StateChart): class personality(State.Compound): smeagol = State(initial=True) gollum = State() h = HistoryState() dark_side = smeagol.to(gollum) _ = h.to(gollum) # default: gollum (not the initial smeagol) outside = State(initial=True) enter_via_history = outside.to(personality.h) leave = personality.to(outside) ================================================ FILE: tests/machines/history/gollum_personality_with_default.py ================================================ from statemachine import HistoryState from statemachine import State from statemachine import StateChart class GollumPersonalityWithDefault(StateChart): class personality(State.Compound): smeagol = State(initial=True) gollum = State() h = HistoryState() dark_side = smeagol.to(gollum) _ = h.to(smeagol) # default: smeagol outside = State(initial=True) enter_via_history = outside.to(personality.h) leave = personality.to(outside) ================================================ FILE: tests/machines/history/shallow_moria.py ================================================ from statemachine import HistoryState from statemachine import State from statemachine import StateChart class ShallowMoria(StateChart): class moria(State.Compound): class halls(State.Compound): entrance = State(initial=True) chamber = State() explore = entrance.to(chamber) assert isinstance(halls, State) h = HistoryState() bridge = State(final=True) flee = halls.to(bridge) outside = State() escape = moria.to(outside) return_shallow = outside.to(moria.h) # type: ignore[has-type] ================================================ FILE: tests/machines/in_condition/__init__.py ================================================ ================================================ FILE: tests/machines/in_condition/combined_guard.py ================================================ from statemachine import State from statemachine import StateChart class CombinedGuard(StateChart): class positions(State.Parallel): class scout(State.Compound): out = State(initial=True) back = State(final=True) return_scout = out.to(back) class warrior(State.Compound): idle = State(initial=True) attacking = State(final=True) # Only attacks when scout is back charge = idle.to(attacking, cond="In('back')") ================================================ FILE: tests/machines/in_condition/descendant_check.py ================================================ from statemachine import State from statemachine import StateChart class DescendantCheck(StateChart): class realm(State.Compound): village = State(initial=True) castle = State() ascend = village.to(castle) conquered = State(final=True) # Guarded by being inside the castle conquer = realm.to(conquered, cond="In('castle')") explore = realm.to.itself(internal=True) # type: ignore[attr-defined] ================================================ FILE: tests/machines/in_condition/eventless_in.py ================================================ from statemachine import State from statemachine import StateChart class EventlessIn(StateChart): class coordination(State.Parallel): class leader(State.Compound): planning = State(initial=True) ready = State(final=True) get_ready = planning.to(ready) class follower(State.Compound): waiting = State(initial=True) moving = State(final=True) # Eventless: move when leader is ready waiting.to(moving, cond="In('ready')") ================================================ FILE: tests/machines/in_condition/fellowship.py ================================================ from statemachine import State from statemachine import StateChart class Fellowship(StateChart): class positions(State.Parallel): class frodo(State.Compound): shire_f = State(initial=True) mordor_f = State(final=True) journey = shire_f.to(mordor_f) class sam(State.Compound): shire_s = State(initial=True) mordor_s = State(final=True) # Sam follows Frodo: eventless, guarded by In('mordor_f') shire_s.to(mordor_s, cond="In('mordor_f')") ================================================ FILE: tests/machines/in_condition/fellowship_coordination.py ================================================ from statemachine import State from statemachine import StateChart class FellowshipCoordination(StateChart): class mission(State.Parallel): class scouts(State.Compound): scouting = State(initial=True) reported = State(final=True) report = scouting.to(reported) class army(State.Compound): waiting = State(initial=True) marching = State(final=True) # Army marches only after scouts report waiting.to(marching, cond="In('reported')") ================================================ FILE: tests/machines/in_condition/gate_of_moria.py ================================================ from statemachine import State from statemachine import StateChart class GateOfMoria(StateChart): outside = State(initial=True) at_gate = State() inside = State(final=True) approach = outside.to(at_gate) # Can only enter if we are at the gate enter_gate = outside.to(inside, cond="In('at_gate')") speak_friend = at_gate.to(inside) ================================================ FILE: tests/machines/parallel/__init__.py ================================================ ================================================ FILE: tests/machines/parallel/session.py ================================================ from statemachine import State from statemachine import StateChart class Session(StateChart): class session(State.Parallel): class ui(State.Compound): active = State(initial=True) closed = State(final=True) close_ui = active.to(closed) class backend(State.Compound): running = State(initial=True) stopped = State(final=True) stop_backend = running.to(stopped) ================================================ FILE: tests/machines/parallel/session_with_done_state.py ================================================ from statemachine import State from statemachine import StateChart class SessionWithDoneState(StateChart): class session(State.Parallel): class ui(State.Compound): active = State(initial=True) closed = State(final=True) close_ui = active.to(closed) class backend(State.Compound): running = State(initial=True) stopped = State(final=True) stop_backend = running.to(stopped) finished = State(final=True) done_state_session = session.to(finished) ================================================ FILE: tests/machines/parallel/two_towers.py ================================================ from statemachine import State from statemachine import StateChart class TwoTowers(StateChart): class battle(State.Parallel): class helms_deep(State.Compound): fighting = State(initial=True) victory = State(final=True) win = fighting.to(victory) class isengard(State.Compound): besieging = State(initial=True) flooded = State(final=True) flood = besieging.to(flooded) aftermath = State(final=True) done_state_battle = battle.to(aftermath) ================================================ FILE: tests/machines/parallel/war_of_the_ring.py ================================================ from statemachine import State from statemachine import StateChart class WarOfTheRing(StateChart): class war(State.Parallel): class frodos_quest(State.Compound): shire = State(initial=True) mordor = State() mount_doom = State(final=True) journey = shire.to(mordor) destroy_ring = mordor.to(mount_doom) class aragorns_path(State.Compound): ranger = State(initial=True) king = State(final=True) coronation = ranger.to(king) class gandalfs_defense(State.Compound): rohan = State(initial=True) gondor = State(final=True) ride_to_gondor = rohan.to(gondor) ================================================ FILE: tests/machines/parallel/war_with_exit.py ================================================ from statemachine import State from statemachine import StateChart class WarWithExit(StateChart): class war(State.Parallel): class front_a(State.Compound): fighting = State(initial=True) won = State(final=True) win_a = fighting.to(won) class front_b(State.Compound): holding = State(initial=True) held = State(final=True) hold_b = holding.to(held) peace = State(final=True) truce = war.to(peace) ================================================ FILE: tests/machines/showcase_actions.py ================================================ from statemachine import State from statemachine import StateChart class ActionsSC(StateChart): off = State(initial=True) on = State() done = State(final=True) power_on = off.to(on) shutdown = on.to(done) def on_exit_off(self): ... def on_enter_on(self): ... def on_exit_on(self): ... def on_enter_done(self): ... ================================================ FILE: tests/machines/showcase_compound.py ================================================ from statemachine import State from statemachine import StateChart class CompoundSC(StateChart): class active(State.Compound, name="Active"): idle = State(initial=True) working = State() begin = idle.to(working) off = State(initial=True) done = State(final=True) turn_on = off.to(active) turn_off = active.to(done) ================================================ FILE: tests/machines/showcase_deep_history.py ================================================ from statemachine import HistoryState from statemachine import State from statemachine import StateChart class DeepHistorySC(StateChart): class outer(State.Compound, name="Outer"): class inner(State.Compound, name="Inner"): a = State(initial=True) b = State() go = a.to(b) start = State(initial=True) enter_inner = start.to(inner) h = HistoryState(type="deep") away = State(initial=True) dive = away.to(outer) leave = outer.to(away) restore = away.to(outer.h) ================================================ FILE: tests/machines/showcase_guards.py ================================================ from statemachine import State from statemachine import StateChart class GuardSC(StateChart): pending = State(initial=True) approved = State(final=True) rejected = State(final=True) def is_valid(self): return True def is_invalid(self): return False review = pending.to(approved, cond="is_valid") | pending.to(rejected, cond="is_invalid") ================================================ FILE: tests/machines/showcase_history.py ================================================ from statemachine import HistoryState from statemachine import State from statemachine import StateChart class HistorySC(StateChart): class process(State.Compound, name="Process"): step1 = State(initial=True) step2 = State() advance = step1.to(step2) h = HistoryState() paused = State(initial=True) pause = process.to(paused) resume = paused.to(process.h) begin = paused.to(process) ================================================ FILE: tests/machines/showcase_internal.py ================================================ from statemachine import State from statemachine import StateChart class InternalSC(StateChart): monitoring = State(initial=True) done = State(final=True) def log_status(self): ... check = monitoring.to.itself(internal=True, on="log_status") stop = monitoring.to(done) ================================================ FILE: tests/machines/showcase_parallel.py ================================================ from statemachine import State from statemachine import StateChart class ParallelSC(StateChart): class both(State.Parallel, name="Both"): class left(State.Compound, name="Left"): l1 = State(initial=True) l2 = State(final=True) go_l = l1.to(l2) class right(State.Compound, name="Right"): r1 = State(initial=True) r2 = State(final=True) go_r = r1.to(r2) start = State(initial=True) end = State(final=True) enter = start.to(both) done_state_both = both.to(end) ================================================ FILE: tests/machines/showcase_parallel_compound.py ================================================ from statemachine import State from statemachine import StateChart class ParallelCompoundSC(StateChart): """Parallel regions with a cross-boundary transition into an inner compound. The ``rebuild`` transition targets ``pipeline.build`` — a compound state inside a parallel region. This is the exact pattern that triggers `mermaid-js/mermaid#4052 `_; the Mermaid renderer works around it by redirecting the arrow to the compound's initial child. {statechart:rst} """ class pipeline(State.Parallel, name="Pipeline"): class build(State.Compound, name="Build"): compile = State(initial=True) link = State(final=True) do_build = compile.to(link) class test(State.Compound, name="Test"): unit = State(initial=True) e2e = State(final=True) do_test = unit.to(e2e) idle = State(initial=True) review = State() start = idle.to(pipeline) done_state_pipeline = pipeline.to(review) rebuild = review.to(pipeline.build) accept = review.to(idle) ================================================ FILE: tests/machines/showcase_self_transition.py ================================================ from statemachine import State from statemachine import StateChart class SelfTransitionSC(StateChart): counting = State(initial=True) done = State(final=True) increment = counting.to.itself() stop = counting.to(done) ================================================ FILE: tests/machines/showcase_simple.py ================================================ from statemachine import State from statemachine import StateChart class SimpleSC(StateChart): """A simple three-state machine. {statechart:rst} """ idle = State(initial=True) running = State() done = State(final=True) start = idle.to(running) finish = running.to(done) ================================================ FILE: tests/machines/transition_from_any.py ================================================ from statemachine import State from statemachine import StateChart class OrderWorkflow(StateChart): pending = State(initial=True) processing = State() done = State() completed = State(final=True) cancelled = State(final=True) process = pending.to(processing) complete = processing.to(done) finish = done.to(completed) cancel = cancelled.from_.any() class OrderWorkflowCompound(StateChart): class active(State.Compound): pending = State(initial=True) processing = State() done = State(final=True) process = pending.to(processing) complete = processing.to(done) completed = State(final=True) cancelled = State(final=True) done_state_active = active.to(completed) cancel = active.to(cancelled) ================================================ FILE: tests/machines/tutorial_coffee_order.py ================================================ from statemachine import State from statemachine import StateChart class CoffeeOrder(StateChart): pending = State(initial=True) preparing = State() ready = State() picked_up = State(final=True) start = pending.to(preparing) finish = preparing.to(ready) pick_up = ready.to(picked_up) ================================================ FILE: tests/machines/validators/__init__.py ================================================ ================================================ FILE: tests/machines/validators/multi_validator.py ================================================ from statemachine import State from statemachine import StateChart class MultiValidator(StateChart): """Machine with multiple validators — first failure stops the chain.""" idle = State(initial=True) active = State(final=True) start = idle.to(active, validators=["check_a", "check_b"]) def check_a(self, **kwargs): if not kwargs.get("a_ok"): raise ValueError("A failed") def check_b(self, **kwargs): if not kwargs.get("b_ok"): raise ValueError("B failed") ================================================ FILE: tests/machines/validators/order_validation.py ================================================ from statemachine import State from statemachine import StateChart class OrderValidation(StateChart): """StateChart with catch_errors_as_events=True (the default).""" pending = State(initial=True) confirmed = State() cancelled = State(final=True) confirm = pending.to(confirmed, validators="check_stock") cancel = confirmed.to(cancelled) def check_stock(self, quantity=0, **kwargs): if quantity <= 0: raise ValueError("Quantity must be positive") ================================================ FILE: tests/machines/validators/order_validation_no_error_events.py ================================================ from statemachine import State from statemachine import StateChart class OrderValidationNoErrorEvents(StateChart): """Same machine but with catch_errors_as_events=False.""" catch_errors_as_events = False pending = State(initial=True) confirmed = State() cancelled = State(final=True) confirm = pending.to(confirmed, validators="check_stock") cancel = confirmed.to(cancelled) def check_stock(self, quantity=0, **kwargs): if quantity <= 0: raise ValueError("Quantity must be positive") ================================================ FILE: tests/machines/validators/validator_fallthrough.py ================================================ from statemachine import State from statemachine import StateChart class ValidatorFallthrough(StateChart): """Machine with multiple transitions for the same event. When the first transition's validator rejects, the exception propagates immediately — the engine does NOT fall through to the next transition. """ idle = State(initial=True) path_a = State(final=True) path_b = State(final=True) go = idle.to(path_a, validators="must_be_premium") | idle.to(path_b) def must_be_premium(self, **kwargs): if not kwargs.get("premium"): raise PermissionError("Premium required") ================================================ FILE: tests/machines/validators/validator_with_cond.py ================================================ from statemachine import State from statemachine import StateChart class ValidatorWithCond(StateChart): """Machine that combines validators and conditions on the same transition.""" idle = State(initial=True) active = State(final=True) start = idle.to(active, validators="check_auth", cond="has_permission") has_permission = False def check_auth(self, token=None, **kwargs): if token != "valid": raise PermissionError("Invalid token") ================================================ FILE: tests/machines/validators/validator_with_error_transition.py ================================================ from statemachine import State from statemachine import StateChart class ValidatorWithErrorTransition(StateChart): """Machine with both a validator and an error.execution transition. The error.execution transition should NOT be triggered by validator rejection — only by actual execution errors in actions. """ idle = State(initial=True) active = State() error_state = State(final=True) start = idle.to(active, validators="check_input") do_work = active.to.itself(on="risky_action") error_execution = active.to(error_state) def check_input(self, value=None, **kwargs): if value is None: raise ValueError("Input required") def risky_action(self, **kwargs): raise RuntimeError("Boom") ================================================ FILE: tests/machines/workflow/__init__.py ================================================ ================================================ FILE: tests/machines/workflow/campaign_machine.py ================================================ from statemachine import State from statemachine import StateChart class CampaignMachine(StateChart): "A workflow machine" draft = State(initial=True) producing = State("Being produced") closed = State(final=True) add_job = draft.to(draft) | producing.to(producing) produce = draft.to(producing) deliver = producing.to(closed) ================================================ FILE: tests/machines/workflow/campaign_machine_with_validator.py ================================================ from statemachine import State from statemachine import StateChart class CampaignMachineWithValidator(StateChart): "A workflow machine" draft = State(initial=True) producing = State("Being produced") closed = State(final=True) add_job = draft.to(draft) | producing.to(producing) produce = draft.to(producing, validators="can_produce") deliver = producing.to(closed) def can_produce(*args, **kwargs): if "goods" not in kwargs: raise LookupError("Goods not found.") ================================================ FILE: tests/machines/workflow/campaign_machine_with_values.py ================================================ from statemachine import State from statemachine import StateChart class CampaignMachineWithValues(StateChart): "A workflow machine" draft = State(initial=True, value=1) producing = State("Being produced", value=2) closed = State(value=3, final=True) add_job = draft.to(draft) | producing.to(producing) produce = draft.to(producing) deliver = producing.to(closed) ================================================ FILE: tests/machines/workflow/reverse_traffic_light.py ================================================ from statemachine import State from statemachine import StateChart class ReverseTrafficLightMachine(StateChart): "A traffic light machine" green = State(initial=True) yellow = State() red = State() stop = red.from_(yellow, green, red) cycle = green.from_(red) | yellow.from_(green) | red.from_(yellow) | red.from_.itself() ================================================ FILE: tests/models.py ================================================ class MyModel: "A class that can be used to hold arbitrary key/value pairs as attributes." def __init__(self, **kwargs): for k, v in kwargs.items(): setattr(self, k, v) super().__init__() def __repr__(self): return f"{type(self).__name__}({self.__dict__!r})" ================================================ FILE: tests/scrape_images.py ================================================ import os import re from statemachine.contrib.diagram import DotGraphMachine from statemachine.factory import StateMachineMetaclass from .helpers import import_module_by_path class MachineScraper: """Scrapes images of the statemachines defined into the examples for the gallery""" re_replace_png_extension = re.compile(r"\.png$") def __init__(self, project_root): self.project_root = project_root sanitized_path = re.escape(os.path.abspath(self.project_root)) self.re_machine_module_name = re.compile(f"{sanitized_path}/(.*)\\.py$") self.seen = set() def __repr__(self): return "MachineScraper" def _get_module(self, src_file): module_name = self.re_machine_module_name.findall(src_file) if len(module_name) != 1: return return import_module_by_path(module_name[0]) def generate_image(self, sm_class, original_path): image_path = self.re_replace_png_extension.sub(".svg", original_path) svg = DotGraphMachine(sm_class).get_graph().create_svg().decode() with open(image_path, "w") as f: f.write(svg) return image_path def __call__(self, block, block_vars, gallery_conf): "Find all PNG files in the directory of this example." from sphinx_gallery.scrapers import figure_rst module = self._get_module(block_vars["src_file"]) if module is None: return "" image_names = [] image_path_iterator = block_vars["image_path_iterator"] for key, value in module.__dict__.items(): unique_key = f"{module.__name__}.{key}" if ( key.startswith("__") or unique_key in self.seen or not isinstance(value, StateMachineMetaclass) or value._abstract ): continue self.seen.add(unique_key) image_names.append(self.generate_image(value, image_path_iterator.next())) # Use the `figure_rst` helper function to generate rST for image files return figure_rst(image_names, gallery_conf["src_dir"]) ================================================ FILE: tests/scxml/__init__.py ================================================ ================================================ FILE: tests/scxml/conftest.py ================================================ from pathlib import Path import pytest CURRENT_DIR = Path(__file__).parent TESTCASES_DIR = CURRENT_DIR # xfail sets — tests that fail identically on both engines XFAIL_BOTH = { # mandatory — invoke-related (still failing) "test187", # delayed cancelled when sending session terminates before delay "test229", # autoforward: parent forwards events to child automatically "test236", # done.invoke.id arrives after all other child-generated events "test240", # datamodel values passed to invoked child via namelist and "test554", # invocation cancelled when evaluation of invoke arguments errors # optional — ecmascript/JSON datamodel "test201", # JSON data in parsed in ecmascript datamodel "test446", # JSON data loaded via src attribute parsed as array # optional — Basic HTTP Event I/O Processor "test509", # basic HTTP event I/O processor: send with target "test510", # basic HTTP event I/O processor: send without target "test518", # basic HTTP event I/O processor: event field in POST "test519", # basic HTTP event I/O processor: namelist data in POST body "test520", # basic HTTP event I/O processor: data in POST body "test522", # basic HTTP event I/O processor: in POST body "test531", # basic HTTP event I/O processor: POST response populates _event.data "test532", # basic HTTP event I/O processor: error.communication on bad target "test534", # basic HTTP event I/O processor: #_scxml_sessionid target # optional — data/content handling "test557", # XML data in content becomes DOM-like object (python datamodel) "test558", # text data in preserves string type (python datamodel) "test561", # XML content in events creates DOM object "test567", # HTTP message parameters populate _event.data "test577", # without target causes error.communication } XFAIL_SYNC_ONLY: set[str] = set() XFAIL_ASYNC_ONLY: set[str] = set() XFAIL_SYNC = XFAIL_BOTH | XFAIL_SYNC_ONLY XFAIL_ASYNC = XFAIL_BOTH | XFAIL_ASYNC_ONLY @pytest.fixture(scope="session") def should_generate_debug_diagram(request): return request.config.getoption("--gen-diagram") def compute_testcase_marks(testcase_path: Path, is_async: bool) -> list[pytest.MarkDecorator]: marks: list[pytest.MarkDecorator] = [pytest.mark.scxml] test_id = testcase_path.stem xfail_set = XFAIL_ASYNC if is_async else XFAIL_SYNC if test_id in xfail_set: marks.append(pytest.mark.xfail) return marks def pytest_generate_tests(metafunc): if "testcase_path" not in metafunc.fixturenames: return is_async = "async" in metafunc.function.__name__ metafunc.parametrize( "testcase_path", [ pytest.param( testcase_path, id=str(testcase_path.relative_to(TESTCASES_DIR)), marks=compute_testcase_marks(testcase_path, is_async), ) for testcase_path in TESTCASES_DIR.glob("**/*.scxml") if "sub" not in testcase_path.name ], ) ================================================ FILE: tests/scxml/test_microwave.py ================================================ import pytest from statemachine.io.scxml.processor import SCXMLProcessor from statemachine.state import State from statemachine import StateChart """ The specifies a transition that specifies the default child initial states. The problem is that the transition must occur, and the state itself is not marked as `initial` in the model. """ MICROWAVE_SCXML = """ """ @pytest.mark.scxml() def test_microwave_scxml(): processor = SCXMLProcessor() processor.parse_scxml("microwave", MICROWAVE_SCXML) sm = processor.start() assert "unplugged" in sm.current_state_value sm.send("plug-in") assert "idle" in sm.current_state_value assert "plugged-in" in sm.current_state_value sm.send("start") assert "cooking" in sm.current_state_value assert "idle" not in sm.current_state_value assert "plugged-in" in sm.current_state_value sm.send("unplug") assert "unplugged" in sm.current_state_value assert "idle" not in sm.current_state_value assert "plugged-in" not in sm.current_state_value assert "cooking" not in sm.current_state_value class TestMicrowave: @pytest.fixture() def microwave_cls(self): class MicroWave(StateChart): door_closed: bool = True class oven(State.Parallel, name="Microwave oven"): class engine(State.Compound): off = State(initial=True) class on(State.Compound): idle = State(initial=True) cooking = State() idle.to(cooking, cond="In('closed')") cooking.to(idle, cond="In('open')") time = cooking.to.itself(internal=True, on="increment_timer") def increment_timer(self): self.timer += 1 assert isinstance(on, State) # so mypy stop complaining on.to(off, event="turn-off") off.to(on, event="turn-on") on.to(off, cond="timer >= cook_time") # eventless transition class door(State.Compound): closed = State(initial=True) open = State() closed.to(open, event="door.open") open.to(closed, event="door.close") def on_enter_open(self): self.door_closed = False def on_enter_closed(self): self.door_closed = True def __init__(self): self.cook_time = 5 self.timer = 0 super().__init__() return MicroWave def test_microwave(self, microwave_cls): sm = microwave_cls() assert {"door", "closed", "oven", "engine", "off"} == {*sm.current_state_value} assert sm.door_closed is True sm.send("turn-on") assert {"door", "closed", "oven", "engine", "on", "cooking"} == {*sm.current_state_value} sm.send("door.open") assert {"door", "open", "oven", "engine", "on", "idle"} == {*sm.current_state_value} assert sm.door_closed is False sm.send("door.close") assert {"door", "closed", "oven", "engine", "on", "cooking"} == {*sm.current_state_value} assert sm.door_closed is True for _ in range(5): sm.send("time") assert {"door", "closed", "oven", "engine", "off"} == {*sm.current_state_value} assert sm.door_closed is True ================================================ FILE: tests/scxml/test_scxml_cases.py ================================================ import time from pathlib import Path import pytest from statemachine.io.scxml.processor import SCXMLProcessor from statemachine import StateChart """ Test cases as defined by W3C SCXML Test Suite - https://www.w3.org/Voice/2013/scxml-irp/ - https://alexzhornyak.github.io/SCXML-tutorial/Tests/ecma/W3C/Mandatory/Auto/report__USCXML_2_0_0___msvc2015_32bit__Win7_1.html - https://github.com/alexzhornyak/PyBlendSCXML/tree/master/w3c_tests - https://github.com/jbeard4/SCION/wiki/Pseudocode-for-SCION-step-algorithm """ # noqa: E501 class AsyncListener: """No-op async listener to trigger AsyncEngine selection.""" async def on_enter_state( self, **kwargs ): ... # No-op: presence of async callback triggers AsyncEngine selection def _run_scxml_testcase( testcase_path: Path, should_generate_debug_diagram, *, async_mode: bool = False, ) -> StateChart: """Shared logic for sync and async SCXML test variants. Parses the SCXML file, starts the state machine, and asserts the final configuration contains ``pass``. Returns the SM instance. """ from statemachine.contrib.diagram import DotGraphMachine listeners: list = [] if async_mode: listeners.append(AsyncListener()) processor = SCXMLProcessor() processor.parse_scxml_file(testcase_path) sm = processor.start(listeners=listeners) if should_generate_debug_diagram: DotGraphMachine(sm).get_graph().write_png( testcase_path.parent / f"{testcase_path.stem}.png" ) assert isinstance(sm, StateChart) return sm def _assert_passed(sm: StateChart): assert isinstance(sm, StateChart) assert "pass" in {s.id for s in sm.configuration} def _wait_for_completion(sm: StateChart, timeout_s: float = 5.0): """Poll the processing loop until the SM reaches a final state or times out.""" deadline = time.monotonic() + timeout_s while not sm.is_terminated and time.monotonic() < deadline: time.sleep(0.02) # Trigger processing loop to handle events from invoke threads sm._engine.processing_loop() def test_scxml_usecase_sync(testcase_path: Path, should_generate_debug_diagram, caplog): sm = _run_scxml_testcase( testcase_path, should_generate_debug_diagram, async_mode=False, ) _wait_for_completion(sm) _assert_passed(sm) async def _async_wait_for_completion(sm: StateChart, timeout_s: float = 5.0): """Poll the processing loop until the SM reaches a final state or times out.""" import asyncio deadline = time.monotonic() + timeout_s while not sm.is_terminated and time.monotonic() < deadline: await asyncio.sleep(0.02) await sm._engine.processing_loop() @pytest.mark.asyncio() async def test_scxml_usecase_async(testcase_path: Path, should_generate_debug_diagram, caplog): sm = _run_scxml_testcase( testcase_path, should_generate_debug_diagram, async_mode=True, ) # In async context, the engine only queued __initial__ during __init__. # Activate now within the running event loop. await sm.activate_initial_state() await _async_wait_for_completion(sm) _assert_passed(sm) ================================================ FILE: tests/scxml/w3c/LICENSE ================================================ BSD 3-Clause License Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions, and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions, and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of [Your Name or Your Organization] nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ================================================ FILE: tests/scxml/w3c/mandatory/test144.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test145.scxml ================================================  ================================================ FILE: tests/scxml/w3c/mandatory/test147.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test148.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test149.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test150.scxml ================================================ [1,2,3] ================================================ FILE: tests/scxml/w3c/mandatory/test151.scxml ================================================  [1,2,3] ================================================ FILE: tests/scxml/w3c/mandatory/test152.scxml ================================================  [1,2,3] ================================================ FILE: tests/scxml/w3c/mandatory/test153.scxml ================================================ [1,2,3] ================================================ FILE: tests/scxml/w3c/mandatory/test155.scxml ================================================ [1,2,3] ================================================ FILE: tests/scxml/w3c/mandatory/test156.scxml ================================================ [1,2,3] ================================================ FILE: tests/scxml/w3c/mandatory/test158.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test159.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test172.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test173.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test174.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test175.scxml ================================================  ================================================ FILE: tests/scxml/w3c/mandatory/test176.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test179.scxml ================================================ 123 ================================================ FILE: tests/scxml/w3c/mandatory/test183.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test185.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test186.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test187.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test189.scxml ================================================  ================================================ FILE: tests/scxml/w3c/mandatory/test190.scxml ================================================  ================================================ FILE: tests/scxml/w3c/mandatory/test191.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test192.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test194.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test198.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test199.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test200.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test205.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test207.scxml ================================================  ================================================ FILE: tests/scxml/w3c/mandatory/test208.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test210.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test215.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test216.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test216sub1.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test220.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test223.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test224.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test225.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test226.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test226sub1.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test228.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test229.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test232.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test233.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test234.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test235.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test236.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test237.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test239.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test239sub1.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test240.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test241.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test242.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test242sub1.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test243.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test244.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test245.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test247.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test252.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test253.scxml ================================================  ================================================ FILE: tests/scxml/w3c/mandatory/test276.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test276sub1.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test277.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test279.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test280.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test286.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test287.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test294.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test298.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test302.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test303.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test304.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test309.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test310.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test311.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test312.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test318.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test319.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test321.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test322.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test323.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test324.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test325.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test326.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test329.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test330.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test331.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test332.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test333.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test335.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test336.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test337.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test338.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test339.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test342.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test343.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test344.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test346.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test347.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test348.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test349.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test350.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test351.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test352.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test354.scxml ================================================ foo ================================================ FILE: tests/scxml/w3c/mandatory/test355.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test364.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test372.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test375.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test376.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test377.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test378.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test387.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test388.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test396.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test399.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test401.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test402.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test403a.scxml ================================================  ================================================ FILE: tests/scxml/w3c/mandatory/test403b.scxml ================================================  ================================================ FILE: tests/scxml/w3c/mandatory/test403c.scxml ================================================  ================================================ FILE: tests/scxml/w3c/mandatory/test404.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test405.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test406.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test407.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test409.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test411.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test412.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test413.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test416.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test417.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test419.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test421.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test422.scxml ================================================  ================================================ FILE: tests/scxml/w3c/mandatory/test423.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test487.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test488.scxml ================================================  ================================================ FILE: tests/scxml/w3c/mandatory/test495.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test496.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test500.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test501.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test503.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test504.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test505.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test506.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test521.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test525.scxml ================================================  [1, 2, 3] ================================================ FILE: tests/scxml/w3c/mandatory/test527.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test528.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test529.scxml ================================================  21 ================================================ FILE: tests/scxml/w3c/mandatory/test530.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test533.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test550.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test551.scxml ================================================ 123 ================================================ FILE: tests/scxml/w3c/mandatory/test552.scxml ================================================  ================================================ FILE: tests/scxml/w3c/mandatory/test552.txt ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test553.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test554.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test570.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test576.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test579.scxml ================================================ ================================================ FILE: tests/scxml/w3c/mandatory/test580.scxml ================================================ ================================================ FILE: tests/scxml/w3c/optional/test193.scxml ================================================ ================================================ FILE: tests/scxml/w3c/optional/test201.scxml ================================================ ================================================ FILE: tests/scxml/w3c/optional/test278.scxml ================================================ ================================================ FILE: tests/scxml/w3c/optional/test444.scxml ================================================ ================================================ FILE: tests/scxml/w3c/optional/test445.scxml ================================================ ================================================ FILE: tests/scxml/w3c/optional/test446.scxml ================================================ [1, 2, 3] ================================================ FILE: tests/scxml/w3c/optional/test446.txt ================================================ [1,2,3] ================================================ FILE: tests/scxml/w3c/optional/test448.scxml ================================================ ================================================ FILE: tests/scxml/w3c/optional/test449.scxml ================================================ ================================================ FILE: tests/scxml/w3c/optional/test451.scxml ================================================ ================================================ FILE: tests/scxml/w3c/optional/test452.scxml ================================================ ================================================ FILE: tests/scxml/w3c/optional/test453.scxml ================================================ ================================================ FILE: tests/scxml/w3c/optional/test456.scxml ================================================ ================================================ FILE: tests/scxml/w3c/optional/test457.scxml ================================================ ================================================ FILE: tests/scxml/w3c/optional/test459.scxml ================================================ ================================================ FILE: tests/scxml/w3c/optional/test460.scxml ================================================ ================================================ FILE: tests/scxml/w3c/optional/test509.scxml ================================================ ================================================ FILE: tests/scxml/w3c/optional/test510.scxml ================================================ ================================================ FILE: tests/scxml/w3c/optional/test518.scxml ================================================ ================================================ FILE: tests/scxml/w3c/optional/test519.scxml ================================================ ================================================ FILE: tests/scxml/w3c/optional/test520.scxml ================================================ this is some content ================================================ FILE: tests/scxml/w3c/optional/test522.scxml ================================================ ================================================ FILE: tests/scxml/w3c/optional/test531.scxml ================================================ ================================================ FILE: tests/scxml/w3c/optional/test532.scxml ================================================ some content ================================================ FILE: tests/scxml/w3c/optional/test534.scxml ================================================ ================================================ FILE: tests/scxml/w3c/optional/test557.scxml ================================================  ================================================ FILE: tests/scxml/w3c/optional/test557.txt ================================================ ================================================ FILE: tests/scxml/w3c/optional/test558.scxml ================================================  this is a string ================================================ FILE: tests/scxml/w3c/optional/test558.txt ================================================ this is a string ================================================ FILE: tests/scxml/w3c/optional/test560.scxml ================================================ ================================================ FILE: tests/scxml/w3c/optional/test561.scxml ================================================  ================================================ FILE: tests/scxml/w3c/optional/test562.scxml ================================================  this is a string ================================================ FILE: tests/scxml/w3c/optional/test567.scxml ================================================ ================================================ FILE: tests/scxml/w3c/optional/test569.scxml ================================================ ================================================ FILE: tests/scxml/w3c/optional/test577.scxml ================================================ ================================================ FILE: tests/scxml/w3c/optional/test578.scxml ================================================ { "productName" : "bar", "size" : 27 } ================================================ FILE: tests/test_actions.py ================================================ from statemachine.callbacks import CallbackGroup from statemachine.callbacks import CallbackSpec class TestActions: def test_should_return_all_before_results(self, AllActionsMachine): import tests.examples.all_actions_machine # noqa def test_should_allow_actions_on_the_model(self): # just importing, as the example has assertions import tests.examples.order_control_rich_model_machine # noqa def test_should_should_compute_callbacks_meta_list(self, campaign_machine): sm = campaign_machine() assert list(sm.draft.enter) == [ CallbackSpec("on_enter_state", CallbackGroup.ENTER, is_convention=True), CallbackSpec("on_enter_draft", CallbackGroup.ENTER, is_convention=True), ] assert list(sm.draft.exit) == [ CallbackSpec("on_exit_state", CallbackGroup.EXIT, is_convention=True), CallbackSpec("on_exit_draft", CallbackGroup.EXIT, is_convention=True), ] ================================================ FILE: tests/test_api_contract.py ================================================ """Contract tests: observable behavior of public Configuration APIs. Documents the exact values returned by each public API across all supported topologies (flat, compound, parallel, complex parallel) and lifecycle phases (initial state, after transitions, final state). APIs under test (StateChart): sm.current_state_value -- raw value stored on the model sm.configuration_values -- OrderedSet of raw values sm.configuration -- OrderedSet[State] sm.current_state -- State or OrderedSet[State] (deprecated) API under test (Model): model.state -- raw attribute on the model object """ import warnings from typing import Any import pytest from statemachine.orderedset import OrderedSet from statemachine import State from statemachine import StateChart # --------------------------------------------------------------------------- # Model # --------------------------------------------------------------------------- class Model: """Explicit model to verify raw state persistence independently.""" def __init__(self): self.state: Any = None # --------------------------------------------------------------------------- # Topologies # --------------------------------------------------------------------------- class FlatSC(StateChart): s1 = State(initial=True) s2 = State() s3 = State(final=True) go = s1.to(s2) finish = s2.to(s3) class CompoundSC(StateChart): class parent(State.Compound): child1 = State(initial=True) child2 = State() move = child1.to(child2) done = State(final=True) leave = parent.to(done) class ParallelSC(StateChart): class regions(State.Parallel): class region_a(State.Compound): a1 = State(initial=True) a2 = State() go_a = a1.to(a2) class region_b(State.Compound): b1 = State(initial=True) b2 = State() go_b = b1.to(b2) class ComplexParallelSC(StateChart): class top(State.Parallel): class left(State.Compound): class nested(State.Compound): l1 = State(initial=True) l2 = State() move_l = l1.to(l2) left_done = State(final=True) finish_left = nested.to(left_done) class right(State.Compound): r1 = State(initial=True) r2 = State() move_r = r1.to(r2) # --------------------------------------------------------------------------- # Assertion helper # --------------------------------------------------------------------------- def assert_contract(sm, model, expected_ids: set): """Assert the full observable API contract. When exactly one state is active, the model stores a scalar and ``current_state`` returns a single ``State``. When multiple states are active (compound/parallel), the model stores an ``OrderedSet`` and ``current_state`` returns ``OrderedSet[State]``. """ scalar = len(expected_ids) == 1 # model.state and current_state_value point to the same object assert model.state is sm.current_state_value if scalar: val = next(iter(expected_ids)) assert model.state == val assert not isinstance(model.state, OrderedSet) else: assert isinstance(model.state, OrderedSet) assert set(model.state) == expected_ids # configuration_values -- always OrderedSet of raw values assert isinstance(sm.configuration_values, OrderedSet) assert set(sm.configuration_values) == expected_ids # configuration -- always OrderedSet[State] assert len(sm.configuration) == len(expected_ids) assert {s.id for s in sm.configuration} == expected_ids # current_state (deprecated) -- unwrapped when single with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) cs = sm.current_state if scalar: assert not isinstance(cs, OrderedSet) assert cs.id == next(iter(expected_ids)) else: assert isinstance(cs, OrderedSet) assert {s.id for s in cs} == expected_ids # --------------------------------------------------------------------------- # Main contract matrix: topology x lifecycle x engine # --------------------------------------------------------------------------- SCENARIOS = [ # -- Flat -- pytest.param(FlatSC, [], {"s1"}, id="flat-initial"), pytest.param(FlatSC, ["go"], {"s2"}, id="flat-after-go"), pytest.param(FlatSC, ["go", "finish"], {"s3"}, id="flat-final"), # -- Compound -- pytest.param(CompoundSC, [], {"parent", "child1"}, id="compound-initial"), pytest.param(CompoundSC, ["move"], {"parent", "child2"}, id="compound-inner-move"), pytest.param(CompoundSC, ["leave"], {"done"}, id="compound-exit"), # -- Parallel -- pytest.param( ParallelSC, [], {"regions", "region_a", "a1", "region_b", "b1"}, id="parallel-initial", ), pytest.param( ParallelSC, ["go_a"], {"regions", "region_a", "a2", "region_b", "b1"}, id="parallel-one-region", ), pytest.param( ParallelSC, ["go_a", "go_b"], {"regions", "region_a", "a2", "region_b", "b2"}, id="parallel-both-regions", ), # -- Complex parallel -- pytest.param( ComplexParallelSC, [], {"top", "left", "nested", "l1", "right", "r1"}, id="complex-initial", ), pytest.param( ComplexParallelSC, ["move_l"], {"top", "left", "nested", "l2", "right", "r1"}, id="complex-nested-move", ), pytest.param( ComplexParallelSC, ["move_r"], {"top", "left", "nested", "l1", "right", "r2"}, id="complex-other-region", ), pytest.param( ComplexParallelSC, ["move_l", "move_r"], {"top", "left", "nested", "l2", "right", "r2"}, id="complex-both-regions", ), pytest.param( ComplexParallelSC, ["finish_left"], {"top", "left", "left_done", "right", "r1"}, id="complex-exit-nested", ), ] @pytest.mark.parametrize(("sc_class", "events", "expected_ids"), SCENARIOS) async def test_configuration_contract(sm_runner, sc_class, events, expected_ids): model = Model() sm = await sm_runner.start(sc_class, model=model) for event in events: await sm_runner.send(sm, event) assert_contract(sm, model, expected_ids) # --------------------------------------------------------------------------- # Model setter contract # --------------------------------------------------------------------------- SETTER_SCENARIOS = [ pytest.param(FlatSC, "s2", {"s2"}, id="scalar-on-flat"), pytest.param( CompoundSC, OrderedSet(["parent", "child2"]), {"parent", "child2"}, id="orderedset-on-compound", ), pytest.param(CompoundSC, "done", {"done"}, id="scalar-collapses-orderedset"), ] @pytest.mark.parametrize(("sc_class", "new_value", "expected_ids"), SETTER_SCENARIOS) async def test_setter_contract(sm_runner, sc_class, new_value, expected_ids): model = Model() sm = await sm_runner.start(sc_class, model=model) sm.current_state_value = new_value assert_contract(sm, model, expected_ids) async def test_set_none_clears_configuration(sm_runner): model = Model() sm = await sm_runner.start(FlatSC, model=model) sm.current_state_value = None assert model.state is None assert sm.current_state_value is None assert sm.configuration_values == OrderedSet() assert sm.configuration == OrderedSet() # --------------------------------------------------------------------------- # Uninitialized state (async-only: sync enters initial state in __init__) # --------------------------------------------------------------------------- UNINITIALIZED_SCENARIOS = [ pytest.param(FlatSC, {"s1"}, id="flat"), pytest.param(CompoundSC, {"parent", "child1"}, id="compound"), pytest.param( ParallelSC, {"regions", "region_a", "a1", "region_b", "b1"}, id="parallel", ), ] @pytest.mark.parametrize(("sc_class", "expected_ids"), UNINITIALIZED_SCENARIOS) async def test_uninitialized_then_activated(sc_class, expected_ids): from tests.conftest import _AsyncListener model = Model() sm = sc_class(model=model, listeners=[_AsyncListener()]) # Before activation: all APIs reflect empty configuration assert model.state is None assert sm.current_state_value is None assert sm.configuration_values == OrderedSet() assert sm.configuration == OrderedSet() # After activation: full contract holds await sm.activate_initial_state() assert_contract(sm, model, expected_ids) ================================================ FILE: tests/test_async.py ================================================ import re import pytest from statemachine.exceptions import InvalidDefinition from statemachine.exceptions import InvalidStateValue from statemachine import State from statemachine import StateChart @pytest.fixture() def async_order_control_machine(): # noqa: C901 class OrderControl(StateChart): allow_event_without_transition = False waiting_for_payment = State(initial=True) processing = State() shipping = State() completed = State(final=True) add_to_order = waiting_for_payment.to(waiting_for_payment) receive_payment = waiting_for_payment.to( processing, cond="payments_enough" ) | waiting_for_payment.to(waiting_for_payment, unless="payments_enough") process_order = processing.to(shipping, cond="payment_received") ship_order = shipping.to(completed) def __init__(self): self.order_total = 0 self.payments = [] self.payment_received = False super().__init__() async def payments_enough(self, amount): return sum(self.payments) + amount >= self.order_total async def before_add_to_order(self, amount): self.order_total += amount return self.order_total async def before_receive_payment(self, amount): self.payments.append(amount) return self.payments async def after_receive_payment(self): self.payment_received = True async def on_enter_waiting_for_payment(self): self.payment_received = False return OrderControl async def test_async_order_control_machine(async_order_control_machine): sm = async_order_control_machine() assert await sm.add_to_order(3) == 3 assert await sm.add_to_order(7) == 10 assert await sm.receive_payment(4) == [4] assert sm.waiting_for_payment.is_active with pytest.raises(sm.TransitionNotAllowed): await sm.process_order() assert sm.waiting_for_payment.is_active assert await sm.receive_payment(6) == [4, 6] await sm.process_order() await sm.ship_order() assert sm.order_total == 10 assert sm.payments == [4, 6] assert sm.completed.is_active def test_async_state_from_sync_context(async_order_control_machine): """Test that an async state machine can be used from a synchronous context""" sm = async_order_control_machine() assert sm.add_to_order(3) == 3 assert sm.add_to_order(7) == 10 assert sm.receive_payment(4) == [4] assert sm.waiting_for_payment.is_active with pytest.raises(sm.TransitionNotAllowed): sm.process_order() assert sm.waiting_for_payment.is_active assert sm.send("receive_payment", 6) == [4, 6] # test the sync version of the `.send()` method sm.send("process_order") # test the sync version of the `.send()` method sm.ship_order() assert sm.order_total == 10 assert sm.payments == [4, 6] assert sm.completed.is_active class AsyncConditionExpressionMachine(StateChart): """Regression test for issue #535: async conditions in boolean expressions.""" allow_event_without_transition = False s1 = State(initial=True) go_not = s1.to.itself(cond="not cond_false") go_and = s1.to.itself(cond="cond_true and cond_true") go_or_false_first = s1.to.itself(cond="cond_false or cond_true") go_or_true_first = s1.to.itself(cond="cond_true or cond_false") go_blocked = s1.to.itself(cond="not cond_true") go_and_blocked = s1.to.itself(cond="cond_true and cond_false") go_or_both_false = s1.to.itself(cond="cond_false or cond_false") async def cond_true(self): return True async def cond_false(self): return False async def on_enter_state(self, target): """Async callback to ensure the SM uses AsyncEngine.""" async def test_async_condition_not(recwarn): """Issue #535: 'not cond_false' should allow the transition.""" sm = AsyncConditionExpressionMachine() await sm.activate_initial_state() await sm.go_not() assert sm.s1.is_active assert not any("coroutine" in str(w.message) for w in recwarn.list) async def test_async_condition_not_blocked(): """Issue #535: 'not cond_true' should block the transition.""" sm = AsyncConditionExpressionMachine() await sm.activate_initial_state() with pytest.raises(sm.TransitionNotAllowed): await sm.go_blocked() async def test_async_condition_and(): """Issue #535: 'cond_true and cond_true' should allow the transition.""" sm = AsyncConditionExpressionMachine() await sm.activate_initial_state() await sm.go_and() assert sm.s1.is_active async def test_async_condition_and_blocked(): """Issue #535: 'cond_true and cond_false' should block the transition.""" sm = AsyncConditionExpressionMachine() await sm.activate_initial_state() with pytest.raises(sm.TransitionNotAllowed): await sm.go_and_blocked() async def test_async_condition_or_false_first(): """Issue #535: 'cond_false or cond_true' should allow the transition.""" sm = AsyncConditionExpressionMachine() await sm.activate_initial_state() await sm.go_or_false_first() assert sm.s1.is_active async def test_async_condition_or_true_first(): """'cond_true or cond_false' should allow the transition.""" sm = AsyncConditionExpressionMachine() await sm.activate_initial_state() await sm.go_or_true_first() assert sm.s1.is_active async def test_async_condition_or_both_false(): """'cond_false or cond_false' should block the transition.""" sm = AsyncConditionExpressionMachine() await sm.activate_initial_state() with pytest.raises(sm.TransitionNotAllowed): await sm.go_or_both_false() async def test_async_state_should_be_initialized(async_order_control_machine): """Test that the state machine is initialized before any event is triggered Given how async works on python, there's no built-in way to activate the initial state that may depend on async code from the StateMachine.__init__ method. We do a `_ensure_is_initialized()` check before each event, but to check the current state just before the state machine is created, the user must await the activation of the initial state explicitly. """ sm = async_order_control_machine() import warnings with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) with pytest.raises( InvalidStateValue, match=re.escape( r"There's no current state set. In async code, " r"did you activate the initial state? (e.g., `await sm.activate_initial_state()`)" ), ): sm.current_state # noqa: B018 await sm.activate_initial_state() assert sm.waiting_for_payment.is_active @pytest.mark.timeout(5) async def test_async_catch_errors_as_events_in_condition(): """Async engine catches errors in conditions with catch_errors_as_events.""" class SM(StateChart): s1 = State(initial=True) s2 = State(final=True) error_state = State(final=True) go = s1.to(s2, cond="bad_cond") error_execution = s1.to(error_state) def bad_cond(self, **kwargs): raise RuntimeError("Condition boom") sm = SM() sm.send("go") assert sm.configuration == {sm.error_state} @pytest.mark.timeout(5) async def test_async_catch_errors_as_events_in_transition(): """Async engine catches errors in transition callbacks with catch_errors_as_events.""" class SM(StateChart): s1 = State(initial=True) s2 = State() error_state = State(final=True) go = s1.to(s2, on="bad_action") finish = s2.to(error_state) # Transition 'on' content error is caught per-block, so the transition # completes to s2. error.execution fires from s2. error_execution = s1.to(error_state) | s2.to(error_state) def bad_action(self, **kwargs): raise RuntimeError("Transition boom") sm = SM() sm.send("go") assert sm.configuration == {sm.error_state} @pytest.mark.timeout(5) async def test_async_catch_errors_as_events_in_after(): """Async engine catches errors in after callbacks with catch_errors_as_events.""" class SM(StateChart): s1 = State(initial=True) s2 = State() error_state = State(final=True) go = s1.to(s2) error_execution = s2.to(error_state) def after_go(self, **kwargs): raise RuntimeError("After boom") sm = SM() sm.send("go") assert sm.configuration == {sm.error_state} @pytest.mark.timeout(5) async def test_async_catch_errors_as_events_in_before(): """Async engine catches errors in before callbacks with catch_errors_as_events.""" class SM(StateChart): s1 = State(initial=True) error_state = State(final=True) go = s1.to(s1) error_execution = s1.to(error_state) def before_go(self, **kwargs): raise RuntimeError("Before boom") async def on_enter_state(self, **kwargs): """Async callback to force the async engine.""" sm = SM() await sm.activate_initial_state() await sm.go() assert sm.configuration == {sm.error_state} @pytest.mark.timeout(5) async def test_async_invalid_definition_in_transition_propagates(): """InvalidDefinition in async transition propagates.""" class SM(StateChart): s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2, on="bad_action") def bad_action(self, **kwargs): raise InvalidDefinition("Bad async") sm = SM() with pytest.raises(InvalidDefinition, match="Bad async"): sm.send("go") @pytest.mark.timeout(5) async def test_async_invalid_definition_in_after_propagates(): """InvalidDefinition in async after callback propagates.""" class SM(StateChart): s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) def after_go(self, **kwargs): raise InvalidDefinition("Bad async after") sm = SM() with pytest.raises(InvalidDefinition, match="Bad async after"): sm.send("go") @pytest.mark.timeout(5) async def test_async_runtime_error_in_after_without_catch_errors_as_events(): """RuntimeError in async after callback without catch_errors_as_events propagates.""" class SM(StateChart): catch_errors_as_events = False s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) def after_go(self, **kwargs): raise RuntimeError("Async after boom") sm = SM() with pytest.raises(RuntimeError, match="Async after boom"): sm.send("go") # --- Actual async engine tests (async callbacks trigger AsyncEngine) --- # Note: async engine catch_errors_as_events with async callbacks has a known limitation: # _send_error_execution calls sm.send() which returns an unawaited coroutine. # The tests below cover the paths that DO work in the async engine. @pytest.mark.timeout(5) async def test_async_engine_invalid_definition_in_condition_propagates(): """AsyncEngine: InvalidDefinition in async condition always propagates.""" class SM(StateChart): s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2, cond="bad_cond") async def bad_cond(self, **kwargs): raise InvalidDefinition("Async bad definition") sm = SM() await sm.activate_initial_state() with pytest.raises(InvalidDefinition, match="Async bad definition"): await sm.send("go") @pytest.mark.timeout(5) async def test_async_engine_invalid_definition_in_transition_propagates(): """AsyncEngine: InvalidDefinition in async transition execution always propagates.""" class SM(StateChart): s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2, on="bad_action") async def bad_action(self, **kwargs): raise InvalidDefinition("Async bad transition") sm = SM() await sm.activate_initial_state() with pytest.raises(InvalidDefinition, match="Async bad transition"): await sm.send("go") @pytest.mark.timeout(5) async def test_async_engine_invalid_definition_in_after_propagates(): """AsyncEngine: InvalidDefinition in async after callback propagates.""" class SM(StateChart): s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) async def after_go(self, **kwargs): raise InvalidDefinition("Async bad after") sm = SM() await sm.activate_initial_state() with pytest.raises(InvalidDefinition, match="Async bad after"): await sm.send("go") @pytest.mark.timeout(5) async def test_async_engine_runtime_error_in_after_without_catch_errors_as_events_propagates(): """AsyncEngine: RuntimeError in async after callback without catch_errors_as_events raises.""" class SM(StateChart): catch_errors_as_events = False s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) async def after_go(self, **kwargs): raise RuntimeError("Async after boom no catch") sm = SM() await sm.activate_initial_state() with pytest.raises(RuntimeError, match="Async after boom no catch"): await sm.send("go") @pytest.mark.timeout(5) async def test_async_engine_start_noop_when_already_initialized(): """BaseEngine.start() is a no-op when state machine is already initialized.""" class SM(StateChart): s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) async def on_go( self, ): ... # No-op: presence of async callback triggers AsyncEngine selection sm = SM() await sm.activate_initial_state() assert sm.current_state_value is not None sm._engine.start() # Should return early assert sm.s1.is_active class TestAsyncEnabledEvents: async def test_passing_async_condition(self): class MyMachine(StateChart): s0 = State(initial=True) s1 = State(final=True) go = s0.to(s1, cond="is_ready") async def is_ready(self): return True sm = MyMachine() await sm.activate_initial_state() assert [e.id for e in await sm.enabled_events()] == ["go"] async def test_failing_async_condition(self): class MyMachine(StateChart): s0 = State(initial=True) s1 = State(final=True) go = s0.to(s1, cond="is_ready") async def is_ready(self): return False sm = MyMachine() await sm.activate_initial_state() assert await sm.enabled_events() == [] async def test_kwargs_forwarded_to_async_conditions(self): class MyMachine(StateChart): s0 = State(initial=True) s1 = State(final=True) go = s0.to(s1, cond="check_value") async def check_value(self, value=0): return value > 10 sm = MyMachine() await sm.activate_initial_state() assert await sm.enabled_events() == [] assert [e.id for e in await sm.enabled_events(value=20)] == ["go"] async def test_async_condition_exception_treated_as_enabled(self): class MyMachine(StateChart): s0 = State(initial=True) s1 = State(final=True) go = s0.to(s1, cond="bad_cond") async def bad_cond(self): raise RuntimeError("boom") sm = MyMachine() await sm.activate_initial_state() assert [e.id for e in await sm.enabled_events()] == ["go"] async def test_duplicate_event_across_transitions_deduplicated(self): """Same event on multiple passing transitions appears only once.""" class MyMachine(StateChart): s0 = State(initial=True) s1 = State(final=True) s2 = State(final=True) go = s0.to(s1, cond="cond_a") | s0.to(s2, cond="cond_b") async def cond_a(self): return True async def cond_b(self): return True sm = MyMachine() await sm.activate_initial_state() ids = [e.id for e in await sm.enabled_events()] assert ids == ["go"] assert len(ids) == 1 async def test_mixed_enabled_and_disabled_async(self): class MyMachine(StateChart): s0 = State(initial=True) s1 = State(final=True) s2 = State(final=True) go = s0.to(s1, cond="cond_true") stop = s0.to(s2, cond="cond_false") async def cond_true(self): return True async def cond_false(self): return False sm = MyMachine() await sm.activate_initial_state() assert [e.id for e in await sm.enabled_events()] == ["go"] ================================================ FILE: tests/test_async_futures.py ================================================ """Tests for future-based result routing in the async engine. When multiple coroutines send events concurrently, only one acquires the processing lock. The others must still receive their own event's result (or exception) via an ``asyncio.Future`` attached to each ``TriggerData``. See: https://github.com/fgmacedo/python-statemachine/issues/509 """ import asyncio import pytest from statemachine.engines.base import EventQueue from statemachine.event_data import TriggerData from statemachine import State from statemachine import StateChart # --------------------------------------------------------------------------- # Fixtures / helpers # --------------------------------------------------------------------------- class TrafficLight(StateChart): green = State(initial=True) yellow = State() red = State() slow_down = green.to(yellow) stop = yellow.to(red) go = red.to(green) async def on_slow_down(self): return "slowing" async def on_stop(self): return "stopped" async def on_go(self): return "going" class FailingMachine(StateChart): s1 = State(initial=True) s2 = State() s3 = State(final=True) ok = s1.to(s2) fail = s2.to(s3) async def on_ok(self): return "ok_result" async def on_fail(self): raise RuntimeError("boom") # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- class TestConcurrentSendsGetCorrectResults: """asyncio.gather(sm.send("a"), sm.send("b")) — each caller gets its own result.""" @pytest.mark.asyncio() async def test_sequential_sends(self): """Baseline: sequential sends return correct results.""" sm = TrafficLight() await sm.activate_initial_state() r1 = await sm.send("slow_down") assert r1 == "slowing" r2 = await sm.send("stop") assert r2 == "stopped" @pytest.mark.asyncio() async def test_single_async_caller_gets_result(self): """Single async caller gets its callback result (backward compat).""" sm = TrafficLight() await sm.activate_initial_state() result = await sm.slow_down() assert result == "slowing" class TestExceptionRouting: """Exceptions from one event must be routed to the correct caller.""" @pytest.mark.asyncio() async def test_exception_reaches_caller(self): """When catch_errors_as_events=False (not default for StateChart), the exception propagates to the caller of that event.""" class FailingSC(StateChart): catch_errors_as_events = False s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) async def on_go(self): raise ValueError("broken") sm = FailingSC() await sm.activate_initial_state() with pytest.raises(ValueError, match="broken"): await sm.send("go") class TestTransitionNotAllowedRouting: """TransitionNotAllowed from an unknown event reaches the correct caller.""" @pytest.mark.asyncio() async def test_transition_not_allowed(self): class StrictSC(StateChart): allow_event_without_transition = False s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) async def on_go(self): return "went" sm = StrictSC() await sm.activate_initial_state() # "go" works result = await sm.send("go") assert result == "went" # Now in s2, "go" has no transition with pytest.raises(sm.TransitionNotAllowed): await sm.send("go") class TestFutureEdgeCases: """Edge cases for future-based routing.""" @pytest.mark.asyncio() async def test_initial_activation_no_future(self): """activate_initial_state has no caller_trigger, should work fine.""" sm = TrafficLight() await sm.activate_initial_state() assert "green" in sm.configuration_values @pytest.mark.asyncio() async def test_allow_event_without_transition_resolves_none(self): """When allow_event_without_transition=True and no transition matches, the caller should get None (not hang).""" sm = TrafficLight() await sm.activate_initial_state() # "stop" is not valid from "green", but allow_event_without_transition=True result = await sm.send("stop") assert result is None @pytest.mark.asyncio() async def test_concurrent_sends_via_gather(self): """Two coroutines sending events concurrently via asyncio.gather. One coroutine will hold the lock; the other awaits its future. Both should get their own results. """ class SlowMachine(StateChart): s1 = State(initial=True) s2 = State() s3 = State(final=True) step1 = s1.to(s2) step2 = s2.to(s3) async def on_step1(self): # Yield control so the second coroutine can enqueue its event await asyncio.sleep(0) return "result_1" async def on_step2(self): return "result_2" sm = SlowMachine() await sm.activate_initial_state() r1, r2 = await asyncio.gather( sm.send("step1"), sm.send("step2"), ) assert r1 == "result_1" assert r2 == "result_2" @pytest.mark.asyncio() async def test_concurrent_sends_exception_with_catch_errors_as_events_off(self): """When catch_errors_as_events=False and one event raises, the exception is routed to that caller's future; the other caller is unaffected. With catch_errors_as_events=False, the exception propagates and the processing loop clears the external queue, so the second event is never processed. """ class ConcurrentFailMachine(StateChart): catch_errors_as_events = False s1 = State(initial=True) s2 = State() s3 = State(final=True) step1 = s1.to(s2) step2 = s2.to(s3) async def on_step1(self): await asyncio.sleep(0) raise RuntimeError("step1 failed") async def on_step2(self): return "step2_ok" sm = ConcurrentFailMachine() await sm.activate_initial_state() # step1 raises — the exception should reach step1's caller via its future. # step2 was queued but the processing loop rejects all pending futures # and clears the queue on exception. r1, r2 = await asyncio.gather( sm.send("step1"), sm.send("step2"), return_exceptions=True, ) # step1's caller gets the RuntimeError assert isinstance(r1, RuntimeError) assert str(r1) == "step1 failed" # step2 also gets the RuntimeError (pending future rejected with same exception) assert isinstance(r2, RuntimeError) assert str(r2) == "step1 failed" @pytest.mark.asyncio() async def test_separate_tasks_with_slow_callback(self): """Reproduces the scenario from issue #509: two separate asyncio tasks send events to the same state machine. The first callback does a slow ``await asyncio.sleep()``, yielding control so the second task can enqueue its event. Both tasks must receive their own results. This specifically tests that concurrent external tasks (as opposed to reentrant calls from within callbacks) correctly get futures and don't return ``None``. """ class SlowSC(StateChart): s1 = State(initial=True) s2 = State() noop = s1.to(s2) noop2 = s2.to.itself() async def on_noop(self, name): await asyncio.sleep(0.01) return f"noop done by {name}" async def on_noop2(self, name): return f"noop2 done by {name}" sm = SlowSC() await sm.activate_initial_state() results = {} async def fn1(): results["fn1"] = await sm.send("noop", "fn1") async def fn2(): # Small delay so fn1 acquires the lock first await asyncio.sleep(0.005) results["fn2"] = await sm.send("noop2", "fn2") await asyncio.gather(fn1(), fn2()) assert results["fn1"] == "noop done by fn1" assert results["fn2"] == "noop2 done by fn2" @pytest.mark.asyncio() async def test_separate_tasks_validator_exception_routing(self): """Issue #509 scenario: validator exception must reach the correct caller task, not the task that holds the processing lock. """ class ValidatorSC(StateChart): catch_errors_as_events = False s1 = State(initial=True) s2 = State() noop = s1.to(s2) noop2 = s2.to.itself(validators="check_allowed") async def on_noop(self): await asyncio.sleep(0.01) return "noop ok" def check_allowed(self): raise ValueError("noop2 is not allowed") sm = ValidatorSC() await sm.activate_initial_state() results = {} errors = {} async def fn1(): results["fn1"] = await sm.send("noop") async def fn2(): await asyncio.sleep(0.005) try: await sm.send("noop2") except ValueError as e: errors["fn2"] = e await asyncio.gather(fn1(), fn2()) assert results["fn1"] == "noop ok" assert "fn2" in errors assert str(errors["fn2"]) == "noop2 is not allowed" class TestEventQueueRejectFutures: """Unit tests for EventQueue.reject_futures.""" def test_reject_futures_skips_items_without_future(self): """Items with future=None are silently skipped.""" sm = TrafficLight() queue = EventQueue() td = TriggerData(machine=sm, event=None) assert td.future is None queue.put(td) queue.reject_futures(RuntimeError("boom")) # No exception raised, item still in queue assert not queue.is_empty() ================================================ FILE: tests/test_callbacks.py ================================================ from unittest import mock import pytest from statemachine.callbacks import CallbackGroup from statemachine.callbacks import CallbacksExecutor from statemachine.callbacks import CallbackSpec from statemachine.callbacks import CallbackSpecList from statemachine.callbacks import CallbacksRegistry from statemachine.dispatcher import resolver_factory_from_objects from statemachine.exceptions import InvalidDefinition from statemachine import State from statemachine import StateChart @pytest.fixture() def ObjectWithCallbacks(): class ObjectWithCallbacks: def __init__(self): super().__init__() self.name = "statemachine" self.callbacks = CallbackSpecList().add( ["life_meaning", "name", "a_method"], group=CallbackGroup.ON, ) self.can_be_called = self.callbacks.grouper(CallbackGroup.ON) self.registry = CallbacksRegistry() resolver_factory_from_objects(self).resolve(self.callbacks, registry=self.registry) @property def life_meaning(self): return 42 def a_method(self, *args, **kwargs): return args, kwargs return ObjectWithCallbacks class TestCallbacksMachinery: def test_callback_meta_is_hashable(self): wrapper = CallbackSpec("something", group=CallbackGroup.ON) set().add(wrapper) def test_can_add_callback_that_is_a_string(self): specs = CallbackSpecList() func = mock.Mock() registry = CallbacksRegistry() class MyObject: def my_method(self, *args, **kwargs): return func("my_method", *args, **kwargs) def other_method(self, *args, **kwargs): return func("other_method", *args, **kwargs) def last_one(self, *args, **kwargs): return func("last_one", *args, **kwargs) obj = MyObject() specs.add("my_method", group=CallbackGroup.ON).add("other_method", group=CallbackGroup.ON) specs.add("last_one", group=CallbackGroup.ON) resolver_factory_from_objects(obj).resolve(specs, registry) registry[CallbackGroup.ON.build_key(specs)].call(1, 2, 3, a="x", b="y") assert func.call_args_list == [ mock.call("my_method", 1, 2, 3, a="x", b="y"), mock.call("other_method", 1, 2, 3, a="x", b="y"), mock.call("last_one", 1, 2, 3, a="x", b="y"), ] def test_callbacks_are_iterable(self): specs = CallbackSpecList() specs.add("my_method", 1).add("other_method", 1) specs.add("last_one", 1) assert [c.func for c in specs] == ["my_method", "other_method", "last_one"] def test_add_many_callbacks_at_once(self): specs = CallbackSpecList() method_names = ["my_method", "other_method", "last_one"] specs.add(method_names, group=CallbackGroup.ON) assert [c.func for c in specs] == method_names @pytest.mark.parametrize("is_convention", [False, True]) def test_raise_error_if_didnt_found_attr(self, is_convention): specs = CallbackSpecList() registry = CallbacksRegistry() specs.add( "this_does_no_exist", group=CallbackGroup.ON, is_convention=is_convention, ) resolver_factory_from_objects(self).resolve(specs, registry=registry) if is_convention: registry.check(specs) else: with pytest.raises(InvalidDefinition): registry.check(specs) def test_collect_results(self): specs = CallbackSpecList() registry = CallbacksRegistry() def func1(): return 10 def func2(): return ("a", True) def func3(): return {"key": "value"} specs.add([func1, func2, func3], group=CallbackGroup.ON) resolver_factory_from_objects(object()).resolve(specs, registry=registry) results = registry[CallbackGroup.ON.build_key(specs)].call(1, 2, 3, a="x", b="y") assert results == [ 10, ("a", True), {"key": "value"}, ] def test_callbacks_values_resolution(self, ObjectWithCallbacks): x = ObjectWithCallbacks() assert x.registry[CallbackGroup.ON.build_key(x.callbacks)].call(xablau=True) == [ 42, "statemachine", ((), {"xablau": True}), ] class TestCallbacksAsDecorator: def test_decorate_unbounded_function(self, ObjectWithCallbacks): x = ObjectWithCallbacks() @x.can_be_called def hero_lowercase(hero): return hero.lower() @x.can_be_called def race_uppercase(race): return race.upper() resolver_factory_from_objects(x).resolve(x.callbacks, registry=x.registry) assert x.registry[CallbackGroup.ON.build_key(x.callbacks)].call( hero="Gandalf", race="Maia" ) == [ 42, "statemachine", ((), {"hero": "Gandalf", "race": "Maia"}), "gandalf", "MAIA", ] assert race_uppercase("Hobbit") == "HOBBIT" def test_decorate_unbounded_machine_methods(self): class MiniHeroJourneyMachine(StateChart): ordinary_world = State(initial=True) call_to_adventure = State(final=True) refusal_of_call = State(final=True) adventure_called = ordinary_world.to(call_to_adventure) def __init__(self, *args, **kwargs): self.spy = mock.Mock(side_effect=lambda *x: x) super().__init__(*args, **kwargs) @ordinary_world.enter def enter_ordinary_world(self): """This is the hero's life before they begin their journey. It is their "normal" world, where they are comfortable and familiar. """ self.spy("enter_ordinary_world") @call_to_adventure.enter def enter_call_to_adventure(self, request): """Something happens that forces the hero to leave their ordinary world and embark on a journey. This might be a direct call, like a prophecy or a request for help, or it might be a more subtle nudge, like a feeling of restlessness or a sense of something missing in their life.""" self.spy("call_to_adventure", request) @ordinary_world.to(refusal_of_call) def refuse_call(self, reason): self.spy("refuse_call", reason) sm = MiniHeroJourneyMachine() sm.adventure_called(request="The darkness is coming") assert sm.spy.call_args_list == [ mock.call("enter_ordinary_world"), mock.call("call_to_adventure", "The darkness is coming"), ] sm = MiniHeroJourneyMachine() sm.refuse_call(reason="Not prepared yet") assert sm.spy.call_args_list == [ mock.call("enter_ordinary_world"), mock.call("refuse_call", "Not prepared yet"), ] class TestIssue406: """ A StateMachine that exercises the example given on issue #[406](https://github.com/fgmacedo/python-statemachine/issues/406). In this example, the event callback must be registered only once. """ def test_issue_406(self, mocker): mock = mocker.Mock() class ExampleStateMachine(StateChart): created = State(initial=True) inited = State(final=True) initialize = created.to(inited) @initialize.before def before_initialize(self): mock("before init") @initialize.on def on_initialize(self): mock("on init") sm = ExampleStateMachine() sm.initialize() assert mock.call_args_list == [ mocker.call("before init"), mocker.call("on init"), ] class TestIssue417: """ A StateMachine that exercises the example given on issue #[417](https://github.com/fgmacedo/python-statemachine/issues/417). """ @pytest.fixture() def mock_calls(self, mocker): return mocker.Mock() @pytest.fixture() def model_class(self): class Model: def __init__(self, counter: int = 0): self.state = None self.counter = counter def can_be_started_on_model(self) -> bool: return self.counter > 0 @property def can_be_started_as_property_on_model(self) -> bool: return self.counter > 1 @property def can_be_started_as_property_str_on_model(self) -> bool: return self.counter > 2 return Model @pytest.fixture() def sm_class(self, model_class, mock_calls): class ExampleStateMachine(StateChart): allow_event_without_transition = False catch_errors_as_events = False created = State(initial=True) started = State(final=True) def can_be_started(self) -> bool: return self.counter > 0 @property def can_be_started_as_property(self) -> bool: return self.counter > 1 @property def can_be_started_as_property_str(self) -> bool: return self.counter > 2 start = created.to( started, cond=[ can_be_started, can_be_started_as_property, "can_be_started_as_property_str", model_class.can_be_started_on_model, model_class.can_be_started_as_property_on_model, "can_be_started_as_property_str_on_model", ], ) def __init__(self, model=None, counter: int = 0): self.counter = counter super().__init__(model=model) def on_start(self): mock_calls("started") return ExampleStateMachine def test_issue_417_cannot_start(self, model_class, sm_class, mock_calls): model = model_class(0) sm = sm_class(model, 0) with pytest.raises(sm.TransitionNotAllowed, match="Can't Start when in Created"): sm.start() mock_calls.assert_not_called() def test_issue_417_can_start(self, model_class, sm_class, mock_calls, mocker): model = model_class(3) sm = sm_class(model, 3) sm.start() assert mock_calls.call_args_list == [ mocker.call("started"), ] def test_raise_exception_if_property_is_not_found(self): class StrangeObject: @property def this_cannot_resolve(self) -> bool: return True class ExampleStateMachine(StateChart): catch_errors_as_events = False created = State(initial=True) started = State(final=True) start = created.to(started, cond=[StrangeObject.this_cannot_resolve]) with pytest.raises( InvalidDefinition, match="Error on transition start from Created to Started when resolving callbacks", ): ExampleStateMachine() class TestVisitConditionFalse: """visit/async_visit skip callbacks whose condition returns False.""" def test_visit_skips_when_condition_is_false(self): visited = [] spec = CallbackSpec( "never_called", group=CallbackGroup.INVOKE, is_convention=True, cond=lambda *a, **kw: False, ) executor = CallbacksExecutor() executor.add("test_key", spec, lambda: lambda **kw: True) executor.visit(lambda cb, *a, **kw: visited.append(str(cb))) assert visited == [] async def test_async_visit_skips_when_condition_is_false(self): visited = [] spec = CallbackSpec( "never_called", group=CallbackGroup.INVOKE, is_convention=True, cond=lambda *a, **kw: False, ) executor = CallbacksExecutor() executor.add("test_key", spec, lambda: lambda **kw: True) await executor.async_visit(lambda cb, *a, **kw: visited.append(str(cb))) assert visited == [] ================================================ FILE: tests/test_callbacks_isolation.py ================================================ import pytest from statemachine import State from statemachine import StateChart @pytest.fixture() def simple_sm_cls(): class TestStateMachine(StateChart): allow_event_without_transition = True # States initial = State(initial=True) final = State(final=True, enter="do_enter_final") finish = initial.to(final, cond="can_finish", on="do_finish") def __init__(self, name): self.name = name self.can_finish = False self.finalized = False super().__init__() def do_finish(self): return self.name, self.can_finish def do_enter_final(self): self.finalized = True return TestStateMachine class TestCallbacksIsolation: def test_should_conditions_be_isolated(self, simple_sm_cls): sm1 = simple_sm_cls("sm1") sm2 = simple_sm_cls("sm2") sm3 = simple_sm_cls("sm3") sm1.can_finish = True sm1.send("finish") sm2.send("finish") sm3.send("finish") assert sm1.final.is_active assert sm2.initial.is_active assert sm2.initial.is_active def test_should_actions_be_isolated(self, simple_sm_cls): sm1 = simple_sm_cls("sm1") sm2 = simple_sm_cls("sm2") sm1.can_finish = True sm2.can_finish = True sm1_initial = sm1.initial sm1_final = sm1.final assert sm2.finish() == ("sm2", True) assert not sm2.initial.is_active assert sm2.final.is_active assert sm2.finalized is True assert sm1_initial.is_active assert not sm1_final.is_active assert sm1.finalized is False assert sm1.initial.is_active assert not sm1.final.is_active assert sm1.finish() == ("sm1", True) assert sm1.finalized is True assert not sm1.initial.is_active assert sm1.final.is_active ================================================ FILE: tests/test_class_listeners.py ================================================ import pickle from functools import partial import pytest from statemachine.exceptions import InvalidDefinition from statemachine import State from statemachine import StateChart class RecordingListener: """Listener that records transitions for testing.""" def __init__(self): self.transitions = [] def after_transition(self, event, source, target): self.transitions.append((event, source.id, target.id)) class SetupListener: """Listener that uses setup() to receive runtime dependencies.""" def __init__(self): self.session = None self.transitions = [] def setup(self, sm, session=None, **kwargs): self.session = session def after_transition(self, event, source, target): self.transitions.append((event, source.id, target.id, self.session)) class TestClassLevelListeners: def test_class_level_listener_callable_creates_per_instance(self): class MyChart(StateChart): listeners = [RecordingListener] s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) sm1 = MyChart() sm2 = MyChart() sm1.send("go") # Each SM gets its own listener instance assert len(sm1.active_listeners) == 1 assert len(sm2.active_listeners) == 1 assert sm1.active_listeners[0] is not sm2.active_listeners[0] # Only sm1 should have the transition recorded assert sm1.active_listeners[0].transitions == [("go", "s1", "s2")] assert sm2.active_listeners[0].transitions == [] def test_class_level_listener_shared_instance(self): shared = RecordingListener() class MyChart(StateChart): listeners = [shared] s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) sm1 = MyChart() sm2 = MyChart() sm1.send("go") sm2.send("go") # Both SMs share the same listener instance assert sm1.active_listeners[0] is shared assert sm2.active_listeners[0] is shared assert len(shared.transitions) == 2 def test_class_level_listener_partial(self): class ConfigurableListener: def __init__(self, prefix="default"): self.prefix = prefix self.messages = [] def after_transition(self, event, source, target): self.messages.append(f"{self.prefix}: {source.id} -> {target.id}") class MyChart(StateChart): listeners = [partial(ConfigurableListener, prefix="custom")] s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) sm = MyChart() sm.send("go") listener = sm.active_listeners[0] assert listener.prefix == "custom" assert listener.messages == ["custom: s1 -> s2"] def test_class_level_listener_lambda(self): class SimpleListener: def __init__(self, tag): self.tag = tag class MyChart(StateChart): listeners = [lambda: SimpleListener("from_lambda")] s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) sm = MyChart() assert sm.active_listeners[0].tag == "from_lambda" def test_runtime_listeners_merge_with_class_level(self): class MyChart(StateChart): listeners = [RecordingListener] s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) runtime_listener = RecordingListener() sm = MyChart(listeners=[runtime_listener]) sm.send("go") assert len(sm.active_listeners) == 2 # Both listeners should have recorded for listener in sm.active_listeners: assert listener.transitions == [("go", "s1", "s2")] # Runtime listener is the one we passed in assert runtime_listener in sm.active_listeners class TestClassListenerInheritance: def test_child_extends_parent_listeners(self): class ParentListener: pass class ChildListener: pass class Parent(StateChart): listeners = [ParentListener] s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) class Child(Parent): listeners = [ChildListener] sm = Child() assert len(sm.active_listeners) == 2 assert isinstance(sm.active_listeners[0], ParentListener) assert isinstance(sm.active_listeners[1], ChildListener) def test_child_replaces_parent_listeners(self): class ParentListener: pass class ChildListener: pass class Parent(StateChart): listeners = [ParentListener] s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) class Child(Parent): listeners_inherit = False listeners = [ChildListener] sm = Child() assert len(sm.active_listeners) == 1 assert isinstance(sm.active_listeners[0], ChildListener) def test_grandchild_inherits_full_chain(self): class L1: pass class L2: pass class L3: pass class Base(StateChart): listeners = [L1] s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) class Mid(Base): listeners = [L2] class Leaf(Mid): listeners = [L3] sm = Leaf() assert len(sm.active_listeners) == 3 assert isinstance(sm.active_listeners[0], L1) assert isinstance(sm.active_listeners[1], L2) assert isinstance(sm.active_listeners[2], L3) def test_no_listeners_declared_inherits_parent(self): class ParentListener: pass class Parent(StateChart): listeners = [ParentListener] s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) class Child(Parent): pass sm = Child() assert len(sm.active_listeners) == 1 assert isinstance(sm.active_listeners[0], ParentListener) class TestListenerSetupProtocol: def test_setup_receives_kwargs(self): class MyChart(StateChart): listeners = [SetupListener] s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) sm = MyChart(session="my_db_session") listener = sm.active_listeners[0] assert listener.session == "my_db_session" def test_setup_ignores_unknown_kwargs(self): class MyChart(StateChart): listeners = [SetupListener] s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) sm = MyChart(session="db", unknown_arg="ignored") listener = sm.active_listeners[0] assert listener.session == "db" def test_setup_not_called_on_shared_instances(self): shared = SetupListener() class MyChart(StateChart): listeners = [shared] s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) MyChart(session="db") # Shared instance should NOT have setup() called assert shared.session is None def test_multiple_listeners_with_different_deps(self): class DBListener: def __init__(self): self.session = None def setup(self, sm, session=None, **kwargs): self.session = session class CacheListener: def __init__(self): self.redis = None def setup(self, sm, redis=None, **kwargs): self.redis = redis class MyChart(StateChart): listeners = [DBListener, CacheListener] s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) sm = MyChart(session="db_conn", redis="redis_conn") db, cache = sm.active_listeners assert db.session == "db_conn" assert cache.redis == "redis_conn" def test_setup_receives_sm_instance(self): class IntrospectiveListener: def __init__(self): self.sm = None def setup(self, sm, **kwargs): self.sm = sm class MyChart(StateChart): listeners = [IntrospectiveListener] s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) sm = MyChart() listener = sm.active_listeners[0] assert listener.sm is sm def test_setup_optional_kwargs_default_to_none(self): class MyChart(StateChart): listeners = [SetupListener] s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) sm = MyChart() # No session kwarg provided listener = sm.active_listeners[0] assert listener.session is None def test_setup_required_kwarg_missing_raises_error(self): class StrictListener: def setup(self, sm, session): self.session = session class MyChart(StateChart): listeners = [StrictListener] s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) with pytest.raises(TypeError, match="Error calling setup.*StrictListener"): MyChart() def test_setup_required_kwarg_provided(self): class StrictListener: def setup(self, sm, session): self.session = session class MyChart(StateChart): listeners = [StrictListener] s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) sm = MyChart(session="db_conn") assert sm.active_listeners[0].session == "db_conn" class TestListenerValidation: def test_rejects_none_in_listeners(self): with pytest.raises(InvalidDefinition, match="Invalid entry"): class MyChart(StateChart): listeners = [None] s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) def test_rejects_string_in_listeners(self): with pytest.raises(InvalidDefinition, match="Invalid entry"): class MyChart(StateChart): listeners = ["not_a_listener"] s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) def test_rejects_number_in_listeners(self): with pytest.raises(InvalidDefinition, match="Invalid entry"): class MyChart(StateChart): listeners = [42] s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) def test_rejects_bool_in_listeners(self): with pytest.raises(InvalidDefinition, match="Invalid entry"): class MyChart(StateChart): listeners = [True] s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) class _PickleChart(StateChart): listeners = [RecordingListener] s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) class _PickleMultiStepChart(StateChart): listeners = [RecordingListener] s1 = State(initial=True) s2 = State() s3 = State(final=True) step1 = s1.to(s2) step2 = s2.to(s3) class TestListenerSerialization: def test_pickle_with_class_listeners(self): sm = _PickleChart() sm.send("go") data = pickle.dumps(sm) sm2 = pickle.loads(data) # Class listener instances are preserved through serialization assert len(sm2.active_listeners) == 1 assert sm2.active_listeners[0].transitions == [("go", "s1", "s2")] assert "s2" in sm2.configuration_values def test_pickle_does_not_duplicate_class_listeners(self): sm = _PickleChart() assert len(sm.active_listeners) == 1 data = pickle.dumps(sm) sm2 = pickle.loads(data) # Must not duplicate class listeners after deserialization assert len(sm2.active_listeners) == 1 def test_pickle_with_runtime_listeners(self): runtime = RecordingListener() sm = _PickleMultiStepChart(listeners=[runtime]) sm.send("step1") data = pickle.dumps(sm) sm2 = pickle.loads(data) # After deserialization, both class and runtime listeners are re-registered assert "s2" in sm2.configuration_values sm2.send("step2") assert "s3" in sm2.configuration_values class TestEmptyClassListeners: def test_no_listeners_attribute(self): class MyChart(StateChart): s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) sm = MyChart() assert sm.active_listeners == [] def test_empty_listeners_list(self): class MyChart(StateChart): listeners = [] s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) sm = MyChart() assert sm.active_listeners == [] ================================================ FILE: tests/test_conditions_algebra.py ================================================ import pytest from statemachine.exceptions import InvalidDefinition from statemachine import State from statemachine import StateChart class AnyConditionSM(StateChart): allow_event_without_transition = False catch_errors_as_events = False start = State(initial=True) end = State(final=True) submit = start.to(end, cond="used_money or used_credit") used_money: bool = False used_credit: bool = False def test_conditions_algebra_any_false(): sm = AnyConditionSM() with pytest.raises(sm.TransitionNotAllowed): sm.submit() assert sm.start.is_active def test_conditions_algebra_any_left_true(): sm = AnyConditionSM() sm.used_money = True sm.submit() assert sm.end.is_active def test_conditions_algebra_any_right_true(): sm = AnyConditionSM() sm.used_credit = True sm.submit() assert sm.end.is_active def test_should_raise_invalid_definition_if_cond_is_not_valid_sintax(): class AnyConditionSM(StateChart): start = State(initial=True) end = State(final=True) submit = start.to(end, cond="used_money xxx") used_money: bool = False used_credit: bool = False with pytest.raises(InvalidDefinition, match="Failed to parse boolean expression"): AnyConditionSM() def test_should_raise_invalid_definition_if_cond_is_not_found(): class AnyConditionSM(StateChart): start = State(initial=True) end = State(final=True) submit = start.to(end, cond="used_money and xxx") used_money: bool = False used_credit: bool = False with pytest.raises(InvalidDefinition, match="Did not found name 'xxx'"): AnyConditionSM() ================================================ FILE: tests/test_configuration.py ================================================ """Tests for the Configuration class internals. These tests cover branches in statemachine/configuration.py that are not exercised by the higher-level state machine tests. """ import warnings from statemachine.orderedset import OrderedSet from statemachine import State from statemachine import StateChart class ParallelSM(StateChart): """A parallel state chart for testing multi-element configuration.""" s1 = State(initial=True) s2 = State() s3 = State(final=True) go = s1.to(s2) finish = s2.to(s3) class TestConfigurationStatesSetter: def test_set_empty_configuration(self): sm = ParallelSM() assert len(sm.configuration) > 0 sm.configuration = OrderedSet() assert sm.current_state_value is None def test_set_multi_element_configuration(self): sm = ParallelSM() s1_inst = sm.s1 s2_inst = sm.s2 sm.configuration = OrderedSet([s1_inst, s2_inst]) assert isinstance(sm.current_state_value, OrderedSet) assert sm.current_state_value == OrderedSet([ParallelSM.s1.value, ParallelSM.s2.value]) class TestConfigurationValueSetter: def test_set_value_none_writes_none_to_model(self): sm = ParallelSM() assert sm.current_state_value is not None sm.current_state_value = None assert sm.current_state_value is None assert sm.configuration_values == OrderedSet() def test_set_value_plain_set_coerces_to_ordered_set(self): sm = ParallelSM() s1_val = ParallelSM.s1.value s2_val = ParallelSM.s2.value # Assign a plain set (MutableSet but not OrderedSet) sm.current_state_value = {s1_val, s2_val} # Model should store an OrderedSet (denormalized back to it) assert isinstance(sm.current_state_value, OrderedSet) assert sm.current_state_value == OrderedSet([s1_val, s2_val]) class TestReadFromModelNonOrderedSet: def test_read_from_model_coerces_plain_set(self): """When the model stores a plain set, _read_from_model coerces it.""" sm = ParallelSM() s1_val = ParallelSM.s1.value s2_val = ParallelSM.s2.value # Bypass the value setter to place a plain set on the model setattr(sm._config._model, sm._config._state_field, {s1_val, s2_val}) values = sm._config._read_from_model() assert isinstance(values, OrderedSet) assert values == OrderedSet([s1_val, s2_val]) class TestConfigurationDiscard: def test_discard_nonmatching_scalar(self): sm = ParallelSM() # current value is s1 (scalar) assert sm.current_state_value == ParallelSM.s1.value # discard s2 — should be a no-op since s2 is not active sm._config.discard(ParallelSM.s2) assert sm.current_state_value == ParallelSM.s1.value class TestConfigurationCurrentState: def test_current_state_with_multiple_active_states(self): sm = ParallelSM() s1_inst = sm.s1 s2_inst = sm.s2 sm.configuration = OrderedSet([s1_inst, s2_inst]) with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) result = sm.current_state assert isinstance(result, OrderedSet) assert len(result) == 2 # --------------------------------------------------------------------------- # Regression tests: add()/discard() must go through the property setter # so that models with deserializing properties persist the updated value. # --------------------------------------------------------------------------- class SerializingModel: """A model that serializes/deserializes state on every access, simulating a DB-backed property (e.g., Django model field). """ def __init__(self): self._raw: str | None = None @property def state(self): if self._raw is None: return None parts = self._raw.split(",") if len(parts) == 1: return parts[0] return OrderedSet(parts) @state.setter def state(self, value): if value is None: self._raw = None elif isinstance(value, OrderedSet): self._raw = ",".join(str(v) for v in value) else: self._raw = str(value) class WarSC(StateChart): """Parallel state chart with two regions for testing.""" class war(State.Parallel): class region_a(State.Compound): a1 = State(initial=True) a2 = State() move_a = a1.to(a2) class region_b(State.Compound): b1 = State(initial=True) b2 = State() move_b = b1.to(b2) class TestAddDiscard: """Verify add()/discard() always write back through model setter.""" def test_add_calls_setter_on_serializing_model(self): model = SerializingModel() sm = WarSC(model=model) # After initial entry, all parallel states should be active config_values = sm.configuration_values assert len(config_values) == 5 # war, region_a, a1, region_b, b1 def test_discard_calls_setter_on_serializing_model(self): model = SerializingModel() sm = WarSC(model=model) initial_count = len(sm.configuration_values) assert initial_count == 5 # Trigger a transition in region_a: a1 -> a2 sm.send("move_a") config_values = sm.configuration_values # a1 should be replaced by a2; still 5 states assert len(config_values) == 5 assert "a2" in config_values assert "a1" not in config_values def test_parallel_lifecycle_with_serializing_model(self): model = SerializingModel() sm = WarSC(model=model) # Move both regions sm.send("move_a") sm.send("move_b") config_values = sm.configuration_values assert len(config_values) == 5 assert "a2" in config_values assert "b2" in config_values assert "a1" not in config_values assert "b1" not in config_values def test_state_restoration_from_serialized_model(self): model = SerializingModel() sm = WarSC(model=model) sm.send("move_a") # Save the raw state raw_state = model._raw # Create a new model with the same raw state and a new SM model2 = SerializingModel() model2._raw = raw_state sm2 = WarSC(model=model2) assert sm2.configuration_values == sm.configuration_values async def test_parallel_with_serializing_model_both_engines(self, sm_runner): model = SerializingModel() sm = await sm_runner.start(WarSC, model=model) assert len(sm.configuration_values) == 5 await sm_runner.send(sm, "move_a") assert "a2" in sm.configuration_values assert len(sm.configuration_values) == 5 ================================================ FILE: tests/test_contrib_diagram.py ================================================ import re from contextlib import contextmanager from unittest import mock from xml.etree import ElementTree import pytest from docutils import nodes from statemachine.contrib.diagram import DotGraphMachine from statemachine.contrib.diagram import main from statemachine.contrib.diagram import quickchart_write_svg from statemachine.contrib.diagram.extract import _format_event_names from statemachine.contrib.diagram.model import ActionType from statemachine.contrib.diagram.model import StateType from statemachine.contrib.diagram.renderers.dot import DotRenderer from statemachine.event import Event from statemachine import State from statemachine import StateChart pytestmark = pytest.mark.usefixtures("requires_dot_installed") SVG_NS = {"svg": "http://www.w3.org/2000/svg"} def _parse_svg(graph): """Generate SVG from a pydot graph and parse it as XML.""" svg_bytes = graph.create_svg() return ElementTree.fromstring(svg_bytes) def _find_state_node(svg_root, state_id): """Find the SVG element for a state node by its title text.""" for g in svg_root.iter("{http://www.w3.org/2000/svg}g"): if g.get("class") != "node": continue title = g.find("{http://www.w3.org/2000/svg}title") if title is not None and title.text == state_id: return g return None def _has_rectangular_fill(node_g): """Check if a node group has a with a colored fill. A fill inside a state node means the background is rectangular (no rounded corners), which is a visual regression — state backgrounds should use with curves to match the rounded border. Ignores white fills and arrow-related polygons (which are in edge groups). """ for polygon in node_g.findall("{http://www.w3.org/2000/svg}polygon"): fill = polygon.get("fill", "none") if fill not in ("none", "white", "black", "#ffffff"): return True return False def _path_has_curves(d_attr): """Check if an SVG path `d` attribute contains curve commands (C, c, Q, q, A, a). Rounded corners are drawn with cubic Bezier curves (C command). A rectangular shape only has M (move) and L (line) commands. """ return bool(re.search(r"[CcQqAa]", d_attr)) @pytest.fixture( params=[ ( "_repr_svg_", '\n\n\n 0 def test_generate_complain_about_module_without_sm(self, tmp_path): out = tmp_path / "sm.svg" expected_error = "No StateMachine subclass found in module" with pytest.raises(ValueError, match=expected_error): main(["tests.examples", str(out)]) def test_format_mermaid(self, tmp_path): out = tmp_path / "sm.mmd" main( [ "tests.examples.traffic_light_machine.TrafficLightMachine", str(out), "--format", "mermaid", ] ) content = out.read_text() assert "stateDiagram-v2" in content assert "green --> yellow : Cycle" in content def test_format_md(self, tmp_path): out = tmp_path / "sm.md" main( [ "tests.examples.traffic_light_machine.TrafficLightMachine", str(out), "--format", "md", ] ) content = out.read_text() assert "| State" in content assert "Cycle" in content def test_format_rst(self, tmp_path): out = tmp_path / "sm.rst" main( [ "tests.examples.traffic_light_machine.TrafficLightMachine", str(out), "--format", "rst", ] ) content = out.read_text() assert "+---" in content assert "Cycle" in content def test_format_mermaid_stdout(self, capsys): main( [ "tests.examples.traffic_light_machine.TrafficLightMachine", "-", "--format", "mermaid", ] ) captured = capsys.readouterr() assert "stateDiagram-v2" in captured.out def test_format_md_stdout(self, capsys): main( [ "tests.examples.traffic_light_machine.TrafficLightMachine", "-", "--format", "md", ] ) captured = capsys.readouterr() assert "| State" in captured.out def test_stdout_default_svg(self, capsys): """Default format to stdout writes SVG bytes.""" main( [ "tests.examples.traffic_light_machine.TrafficLightMachine", "-", ] ) captured = capsys.readouterr() assert " child1" not in dot assert "-> child1" in dot def test_history_state_shallow_diagram(): """DOT output contains an 'H' circle node for shallow history state.""" from statemachine.contrib.diagram.model import DiagramState state = DiagramState(id="h_shallow", name="H", type=StateType.HISTORY_SHALLOW) renderer = DotRenderer() node = renderer._create_history_node(state) attrs = node.obj_dict["attributes"] assert attrs["label"] in ("H", '"H"') assert attrs["shape"] == "circle" def test_history_state_deep_diagram(): """DOT output contains an 'H*' circle node for deep history state.""" from statemachine.contrib.diagram.model import DiagramState state = DiagramState(id="h_deep", name="H*", type=StateType.HISTORY_DEEP) renderer = DotRenderer() node = renderer._create_history_node(state) dot_str = node.to_string() assert "H*" in dot_str assert "circle" in dot_str def test_history_state_default_transition(): """History state's default transition appears as an edge in the diagram.""" from statemachine.contrib.diagram.model import DiagramTransition transition = DiagramTransition(source="hist", targets=["child1"], event="") renderer = DotRenderer() renderer._compound_ids = set() edges = renderer._create_edges(transition) assert len(edges) == 1 edge = edges[0] assert edge.obj_dict["points"] == ("hist", "child1") def test_parallel_state_label_indicator(): """Parallel subgraph label includes a visual indicator.""" class SM(StateChart): class p(State.Parallel, name="p"): class r1(State.Compound, name="r1"): a = State(initial=True) class r2(State.Compound, name="r2"): b = State(initial=True) start = State(initial=True) begin = start.to(p) graph = DotGraphMachine(SM) dot = graph().to_string() # The parallel state label should contain an HTML-like label with the indicator assert "☷" in dot def test_history_state_in_graph_states(): """History pseudo-state nodes appear in the full graph output.""" from tests.examples.statechart_history_machine import PersonalityMachine graph = DotGraphMachine(PersonalityMachine) dot = graph().to_string() # History node should render as an 'H' circle assert '"H"' in dot or "H" in dot def test_multi_target_transition_diagram(): """Edges are created for all targets of a multi-target transition.""" from statemachine.contrib.diagram.model import DiagramTransition transition = DiagramTransition(source="source", targets=["target1", "target2"], event="go") renderer = DotRenderer() renderer._compound_ids = set() edges = renderer._create_edges(transition) assert len(edges) == 2 assert edges[0].obj_dict["points"] == ("source", "target1") assert edges[1].obj_dict["points"] == ("source", "target2") # Only the first edge gets a label assert "go" in edges[0].obj_dict["attributes"]["label"] assert edges[1].obj_dict["attributes"]["label"] == "" def test_compound_and_parallel_mixed(): """Full diagram with compound and parallel states renders without error.""" class SM(StateChart): class top(State.Compound, name="Top"): class par(State.Parallel, name="Par"): class region1(State.Compound, name="Region1"): r1_a = State(initial=True) r1_b = State(final=True) r1_go = r1_a.to(r1_b) class region2(State.Compound, name="Region2"): r2_a = State(initial=True) r2_b = State(final=True) r2_go = r2_a.to(r2_b) entry = State(initial=True) start_par = entry.to(par) begin = State(initial=True) enter_top = begin.to(top) graph = DotGraphMachine(SM) dot = graph().to_string() assert "cluster_top" in dot assert "cluster_par" in dot assert "cluster_region1" in dot assert "cluster_region2" in dot # Parallel indicator assert "☷" in dot # Implicit initial transitions from compound states are NOT rendered as edges — # they are represented by the black-dot initial node inside each cluster. assert "top_anchor -> entry" not in dot assert "-> entry" in dot class TestSVGShapeConsistency: """Verify that active and inactive states render with the same shape in SVG. These tests parse the generated SVG to catch visual regressions that are hard to spot by inspecting DOT source alone. For example, using `bgcolor` on a `` instead of a `` causes Graphviz to render a rectangular `` behind a rounded `` border — the DOT looks fine but the visual result is broken. """ def test_active_state_has_no_rectangular_fill(self): """Active state background must use rounded , not rectangular .""" from tests.examples.traffic_light_machine import TrafficLightMachine sm = TrafficLightMachine() # starts in Green graph = DotGraphMachine(sm).get_graph() svg = _parse_svg(graph) green_node = _find_state_node(svg, "green") assert green_node is not None, "Could not find 'green' node in SVG" assert not _has_rectangular_fill(green_node), ( "Active state 'green' has a rectangular fill — " "expected a rounded fill to match the border shape" ) def test_active_and_inactive_states_use_same_svg_element_type(self): """Active and inactive states must both render as rounded elements. With ``shape=rectangle`` + ``style="rounded, filled"``, Graphviz renders each state as a single ```` with cubic Bezier curves (``C`` commands) for rounded corners. Both the fill and stroke are in the same ````. A regression would be if the active state rendered differently — e.g., a rectangular ```` for the fill behind a rounded ```` border. """ from tests.examples.traffic_light_machine import TrafficLightMachine sm = TrafficLightMachine() graph = DotGraphMachine(sm).get_graph() svg = _parse_svg(graph) for state_id in ("green", "yellow", "red"): node = _find_state_node(svg, state_id) assert node is not None, f"Could not find '{state_id}' node in SVG" # Each state should have at least one with rounded curves paths = node.findall("{http://www.w3.org/2000/svg}path") assert len(paths) >= 1, ( f"State '{state_id}' should have at least 1 , found {len(paths)}" ) for p in paths: assert _path_has_curves(p.get("d", "")), ( f"State '{state_id}' has a without curves — not rounded" ) def test_no_state_node_has_rectangular_colored_fill(self): """No state in the diagram should have a rectangular colored fill.""" class SM(StateChart): s1 = State(initial=True) s2 = State() s3 = State(final=True) go = s1.to(s2) finish = s2.to(s3) sm = SM() sm.go() # move to s2 graph = DotGraphMachine(sm).get_graph() svg = _parse_svg(graph) for state_id in ("s1", "s2", "s3"): node = _find_state_node(svg, state_id) if node is None: continue assert not _has_rectangular_fill(node), ( f"State '{state_id}' has a rectangular colored fill" ) class TestExtract: """Tests for extract.py edge cases.""" def test_deep_history_state_type(self): """Deep history state is correctly typed in the extracted graph.""" from statemachine.contrib.diagram.extract import extract from tests.machines.showcase_deep_history import DeepHistorySC graph = extract(DeepHistorySC) # Find the history state in the outer compound's children outer = next(s for s in graph.states if s.id == "outer") h_state = next(s for s in outer.children if s.type == StateType.HISTORY_DEEP) assert h_state is not None def test_internal_transition_actions_extracted(self): """Internal transitions with actions are extracted into state actions.""" from statemachine.contrib.diagram.extract import extract from tests.machines.showcase_internal import InternalSC graph = extract(InternalSC) monitoring = next(s for s in graph.states if s.id == "monitoring") internal_actions = [a for a in monitoring.actions if a.type == ActionType.INTERNAL] assert len(internal_actions) >= 1 assert any("check" in a.body for a in internal_actions) def test_internal_transition_skipped_in_bidirectional(self): """Internal transitions are skipped in _collect_bidirectional_compound_ids.""" from statemachine.contrib.diagram.extract import extract class SM(StateChart): class parent(State.Compound, name="Parent"): child1 = State(initial=True) child2 = State(final=True) def log(self): ... check = child1.to.itself(internal=True, on="log") go = child1.to(child2) start = State(initial=True) end = State(final=True) enter = start.to(parent) finish = parent.to(end) graph = extract(SM) # parent has both incoming and outgoing, so it should be bidirectional assert "parent" in graph.bidirectional_compound_ids def test_internal_transition_without_action(self): """Internal transition without on action has no internal action in diagram.""" from statemachine.contrib.diagram.extract import extract class SM(StateChart): s1 = State(initial=True) s2 = State(final=True) noop = s1.to.itself(internal=True) go = s1.to(s2) graph = extract(SM) s1 = next(s for s in graph.states if s.id == "s1") internal_actions = [a for a in s1.actions if a.type == ActionType.INTERNAL] assert internal_actions == [] def test_extract_invalid_type_raises(self): """extract() raises TypeError for invalid input.""" from statemachine.contrib.diagram.extract import extract with pytest.raises(TypeError, match="Expected a StateChart"): extract("not a machine") # type: ignore[arg-type] def test_resolve_initial_fallback(self): """When no explicit initial, first candidate gets is_initial=True.""" from statemachine.contrib.diagram.extract import _resolve_initial_states from statemachine.contrib.diagram.model import DiagramState states = [ DiagramState(id="a", name="A", type=StateType.REGULAR), DiagramState(id="b", name="B", type=StateType.REGULAR), ] _resolve_initial_states(states) assert states[0].is_initial is True class TestFormatEventNames: """Tests for _format_event_names — alias filtering for diagram display.""" def test_simple_event_uses_name(self): """A plain event displays its human-readable name.""" class SM(StateChart): s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) t = SM.s1.transitions[0] assert _format_event_names(t) == "Go" def test_done_state_alias_filtered(self): """done_state_X registers both underscore and dot forms; only underscore is shown.""" class SM(StateChart): class parent(State.Compound): child = State(initial=True) done = State(final=True) finish = child.to(done) end = State(final=True) done_state_parent = parent.to(end) t = next(t for t in SM.parent.transitions if t.event and "done_state" in t.event) result = _format_event_names(t) assert result == "Done state parent" assert "done.state" not in result def test_done_invoke_alias_filtered(self): """done_invoke_X alias filtering works the same as done_state_X.""" class SM(StateChart): s1 = State(initial=True) s2 = State(final=True) done_invoke_child = s1.to(s2) t = SM.s1.transitions[0] result = _format_event_names(t) assert result == "Done invoke child" assert "done.invoke" not in result def test_error_alias_filtered(self): """error_X registers both error_X and error.X; only underscore is shown.""" class SM(StateChart): s1 = State(initial=True) s2 = State(final=True) error_execution = s1.to(s2) t = SM.s1.transitions[0] result = _format_event_names(t) assert result == "Error execution" assert "error.execution" not in result def test_multiple_distinct_events_preserved(self): """Multiple distinct events on one transition are all preserved.""" class SM(StateChart): s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) also = s1.to(s2) # Add a second event to the first transition t = SM.s1.transitions[0] t.add_event("also") result = _format_event_names(t) assert "Go" in result assert "Also" in result def test_eventless_transition_returns_empty(self): """A transition with no events returns an empty string.""" class SM(StateChart): s1 = State(initial=True) s2 = State(final=True) s1.to(s2, cond="always_true") def always_true(self): return True # Find the eventless transition t = next(t for t in SM.s1.transitions if not list(t.events)) assert _format_event_names(t) == "" def test_dot_only_event_preserved(self): """An event whose ID contains dots but has no underscore alias is preserved.""" class SM(StateChart): s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) from statemachine.transition import Transition t = Transition(source=SM.s1, target=SM.s2, event="custom.event") assert _format_event_names(t) == "Custom event" def test_explicit_event_name_displayed(self): """An Event with an explicit name= shows the human-readable name.""" class SM(StateChart): active = State(initial=True) suspended = State(final=True) suspend = Event( active.to(suspended), name="Human Suspend", ) t = SM.active.transitions[0] assert _format_event_names(t) == "Human Suspend" class TestDotRendererEdgeCases: """Tests for dot.py edge cases.""" def test_compound_state_with_actions_label(self): """Compound state with entry/exit actions renders action rows in label.""" class SM(StateChart): class parent(State.Compound, name="Parent"): child = State(initial=True) def on_enter_parent(self): ... start = State(initial=True) enter = start.to(parent) dot = DotGraphMachine(SM)().to_string() # The compound label should contain the entry action assert "entry" in dot.lower() or "on_enter_parent" in dot def test_internal_action_format(self): """Internal action uses body directly (no 'entry /' prefix).""" renderer = DotRenderer() from statemachine.contrib.diagram.model import DiagramAction action = DiagramAction(type=ActionType.INTERNAL, body="check / log_status") result = renderer._format_action(action) assert result == "check / log_status" def test_targetless_transition_self_loop(self): """Transition with no target falls back to source as destination.""" from statemachine.contrib.diagram.model import DiagramTransition transition = DiagramTransition(source="s1", targets=[], event="tick") renderer = DotRenderer() renderer._compound_ids = set() edges = renderer._create_edges(transition) assert len(edges) == 1 # With no targets, target_ids becomes [None], and dst becomes source assert edges[0].obj_dict["points"][1] == "s1" def test_compound_edge_anchor_non_bidirectional(self): """Non-bidirectional compound state uses generic _anchor node.""" renderer = DotRenderer() renderer._compound_bidir_ids = {"other"} result = renderer._compound_edge_anchor("my_state", "out") assert result == "my_state_anchor" class TestDiagramMainModule: """Tests for __main__.py.""" def test_main_module_execution(self, tmp_path): """python -m statemachine.contrib.diagram works.""" import runpy out = tmp_path / "sm.svg" with mock.patch( "sys.argv", [ "statemachine.contrib.diagram", "tests.examples.traffic_light_machine.TrafficLightMachine", str(out), ], ): with pytest.raises(SystemExit) as exc_info: runpy.run_module( "statemachine.contrib.diagram", run_name="__main__", alter_sys=True ) assert exc_info.value.code is None assert out.exists() class TestSphinxDirective: """Unit tests for the statemachine-diagram Sphinx directive.""" def test_parse_events(self): from statemachine.contrib.diagram.sphinx_ext import _parse_events assert _parse_events("start, ship") == ["start", "ship"] assert _parse_events("single") == ["single"] assert _parse_events(" a , b , c ") == ["a", "b", "c"] assert _parse_events("") == [] def test_import_and_render_class(self, tmp_path): """Directive logic: import a class and generate SVG.""" from statemachine.contrib.diagram import DotGraphMachine from statemachine.contrib.diagram import import_sm sm_class = import_sm("tests.examples.order_control_machine.OrderControl") graph = DotGraphMachine(sm_class).get_graph() svg_bytes = graph.create_svg() assert svg_bytes.startswith(b"\n\n' '' "" ) directive = self._make_directive() svg_tag, _, _ = directive._prepare_svg(svg_text) assert not svg_tag.startswith("" in svg_tag def test_extracts_intrinsic_dimensions(self): svg_text = '' directive = self._make_directive() _, w, h = directive._prepare_svg(svg_text) assert w == "702pt" assert h == "170pt" def test_removes_fixed_dimensions(self): svg_text = '' directive = self._make_directive() svg_tag, _, _ = directive._prepare_svg(svg_text) assert 'width="702pt"' not in svg_tag assert 'height="170pt"' not in svg_tag assert "viewBox" in svg_tag def test_handles_no_dimensions(self): svg_text = '' directive = self._make_directive() _, w, h = directive._prepare_svg(svg_text) assert w == "" assert h == "" def test_handles_px_dimensions(self): svg_text = '' directive = self._make_directive() _, w, h = directive._prepare_svg(svg_text) assert w == "200px" assert h == "100px" class TestBuildSvgStyles: """Tests for StateMachineDiagram._build_svg_styles.""" def _make_directive(self, options=None): from statemachine.contrib.diagram.sphinx_ext import StateMachineDiagram directive = StateMachineDiagram.__new__(StateMachineDiagram) directive.options = options or {} return directive def test_intrinsic_width_as_max_width(self): directive = self._make_directive() result = directive._build_svg_styles("702pt", "170pt") assert "max-width: 702pt" in result assert "height: auto" in result def test_explicit_width(self): directive = self._make_directive({"width": "400px"}) result = directive._build_svg_styles("702pt", "170pt") assert "width: 400px" in result assert "max-width" not in result def test_explicit_height(self): directive = self._make_directive({"height": "200px"}) result = directive._build_svg_styles("702pt", "170pt") assert "height: 200px" in result assert "height: auto" not in result def test_scale(self): directive = self._make_directive({"scale": "50%"}) result = directive._build_svg_styles("702pt", "170pt") assert "width: 351.0pt" in result assert "height: 85.0pt" in result def test_scale_without_intrinsic(self): directive = self._make_directive({"scale": "50%"}) result = directive._build_svg_styles("", "") # No width/height when no intrinsic dimensions to scale assert "max-width" not in result assert "height: auto" in result def test_no_dimensions(self): directive = self._make_directive() result = directive._build_svg_styles("", "") assert "height: auto" in result def test_explicit_width_overrides_scale(self): directive = self._make_directive({"width": "300px", "scale": "50%"}) result = directive._build_svg_styles("702pt", "170pt") assert "width: 300px" in result assert "351" not in result class TestBuildWrapperClasses: """Tests for StateMachineDiagram._build_wrapper_classes.""" def _make_directive(self, options=None): from statemachine.contrib.diagram.sphinx_ext import StateMachineDiagram directive = StateMachineDiagram.__new__(StateMachineDiagram) directive.options = options or {} return directive def test_default_center_align(self): directive = self._make_directive() classes = directive._build_wrapper_classes() assert classes == ["statemachine-diagram", "align-center"] def test_custom_align(self): directive = self._make_directive({"align": "left"}) classes = directive._build_wrapper_classes() assert classes == ["statemachine-diagram", "align-left"] def test_extra_css_classes(self): directive = self._make_directive({"class": ["my-class", "another"]}) classes = directive._build_wrapper_classes() assert classes == ["statemachine-diagram", "align-center", "my-class", "another"] class TestResolveTarget: """Tests for StateMachineDiagram._resolve_target.""" def _make_directive(self, options=None, tmp_path=None): from statemachine.contrib.diagram.sphinx_ext import StateMachineDiagram directive = StateMachineDiagram.__new__(StateMachineDiagram) directive.options = options or {} directive.arguments = ["my.module.MyMachine"] if tmp_path is not None: directive.state = mock.MagicMock() directive.state.document.settings.env.app.outdir = str(tmp_path) return directive def test_no_target_option(self): directive = self._make_directive() assert directive._resolve_target("") == "" def test_explicit_target_url(self): directive = self._make_directive({"target": "https://example.com/diagram.svg"}) assert directive._resolve_target("") == "https://example.com/diagram.svg" def test_empty_target_generates_file(self, tmp_path): directive = self._make_directive({"target": ""}, tmp_path=tmp_path) svg_data = "" result = directive._resolve_target(svg_data) assert result.startswith("/_images/statemachine-") assert result.endswith(".svg") # Verify the file was written images_dir = tmp_path / "_images" svg_files = list(images_dir.glob("statemachine-*.svg")) assert len(svg_files) == 1 assert svg_files[0].read_text(encoding="utf-8") == svg_data def test_empty_target_deterministic_filename(self, tmp_path): """Same qualname + events produces the same filename.""" directive1 = self._make_directive({"target": "", "events": "go"}, tmp_path=tmp_path) directive2 = self._make_directive({"target": "", "events": "go"}, tmp_path=tmp_path) result1 = directive1._resolve_target("1") result2 = directive2._resolve_target("2") assert result1 == result2 def test_different_events_different_filename(self, tmp_path): """Different events produce different filenames.""" d1 = self._make_directive({"target": "", "events": "a"}, tmp_path=tmp_path) d2 = self._make_directive({"target": "", "events": "b"}, tmp_path=tmp_path) assert d1._resolve_target("") != d2._resolve_target("") class TestDirectiveRun: """Integration tests for StateMachineDiagram.run().""" _QUALNAME = "tests.examples.traffic_light_machine.TrafficLightMachine" def _make_directive(self, tmp_path, options=None): from statemachine.contrib.diagram.sphinx_ext import StateMachineDiagram directive = StateMachineDiagram.__new__(StateMachineDiagram) directive.options = options or {} directive.lineno = 1 directive.state_machine = mock.MagicMock() directive.state = mock.MagicMock() directive.state.document.settings.env.app.outdir = str(tmp_path) directive.content_offset = 0 return directive def _run(self, tmp_path, qualname=None, options=None): directive = self._make_directive(tmp_path, options=options) directive.arguments = [qualname or self._QUALNAME] return directive, directive.run() def test_render_class_diagram(self, tmp_path): """Renders a class diagram (no events) as inline SVG.""" _, result = self._run(tmp_path) assert len(result) == 1 node = result[0] assert isinstance(node, nodes.raw) assert node["format"] == "html" html = node.astext() assert " element.""" _, result = self._run(tmp_path, options={"caption": "My caption"}) html = result[0].astext() assert "My caption" in html def test_render_with_figclass(self, tmp_path): """figclass adds extra CSS classes to the figure wrapper.""" _, result = self._run(tmp_path, options={"caption": "Test", "figclass": ["extra-fig"]}) assert "extra-fig" in result[0].astext() def test_render_with_alt(self, tmp_path): """Custom alt text appears in aria-label.""" _, result = self._run(tmp_path, options={"alt": "Traffic light diagram"}) assert 'aria-label="Traffic light diagram"' in result[0].astext() def test_render_default_alt(self, tmp_path): """Default alt text uses the class name from the qualname.""" _, result = self._run(tmp_path) assert 'aria-label="TrafficLightMachine"' in result[0].astext() def test_render_with_explicit_target(self, tmp_path): """Explicit target wraps diagram in a link.""" _, result = self._run(tmp_path, options={"target": "https://example.com"}) html = result[0].astext() assert 'href="https://example.com"' in html assert 'target="_blank"' in html def test_render_with_empty_target(self, tmp_path): """Empty target auto-generates a zoom SVG file.""" _, result = self._run(tmp_path, options={"target": ""}) assert 'href="/_images/statemachine-' in result[0].astext() images_dir = tmp_path / "_images" assert any(images_dir.glob("statemachine-*.svg")) def test_render_with_align(self, tmp_path): """Align option controls CSS class.""" _, result = self._run(tmp_path, options={"align": "left"}) assert "align-left" in result[0].astext() def test_render_with_width(self, tmp_path): """Width option is applied as inline style.""" _, result = self._run(tmp_path, options={"width": "400px"}) assert "width: 400px" in result[0].astext() def test_render_with_name(self, tmp_path): """Name option calls add_name for cross-referencing.""" directive = self._make_directive(tmp_path, options={"name": "my-diagram"}) directive.arguments = [self._QUALNAME] result = directive.run() assert len(result) == 1 def test_render_with_class(self, tmp_path): """Custom CSS classes appear in the wrapper.""" _, result = self._run(tmp_path, options={"class": ["custom-class"]}) assert "custom-class" in result[0].astext() def test_invalid_qualname_returns_warning(self, tmp_path): """Invalid qualname returns a warning node.""" directive, result = self._run(tmp_path, qualname="nonexistent.module.BadMachine") assert len(result) == 1 directive.state_machine.reporter.warning.assert_called_once() call_args = directive.state_machine.reporter.warning.call_args assert "could not import" in call_args[0][0] def test_render_failure_returns_warning(self, tmp_path): """Diagram generation failure returns a warning node.""" with mock.patch( "statemachine.contrib.diagram.DotGraphMachine", side_effect=RuntimeError("render failed"), ): directive, result = self._run(tmp_path) assert len(result) == 1 directive.state_machine.reporter.warning.assert_called_once() call_args = directive.state_machine.reporter.warning.call_args assert "failed to generate" in call_args[0][0] def test_render_without_caption_uses_div(self, tmp_path): """Without caption, the wrapper is a plain
.""" _, result = self._run(tmp_path) html = result[0].astext() assert " yellow : Cycle" in result def test_format_md_instance(self): from tests.examples.traffic_light_machine import TrafficLightMachine sm = TrafficLightMachine() result = f"{sm:md}" assert "| State" in result assert "Cycle" in result def test_format_md_class(self): from tests.examples.traffic_light_machine import TrafficLightMachine result = f"{TrafficLightMachine:md}" assert "| State" in result def test_format_markdown_alias(self): from tests.examples.traffic_light_machine import TrafficLightMachine result = format(TrafficLightMachine, "markdown") assert "| State" in result def test_format_rst_instance(self): from tests.examples.traffic_light_machine import TrafficLightMachine sm = TrafficLightMachine() result = f"{sm:rst}" assert "+---" in result def test_format_rst_class(self): from tests.examples.traffic_light_machine import TrafficLightMachine result = f"{TrafficLightMachine:rst}" assert "+---" in result def test_format_dot_instance(self): from tests.examples.traffic_light_machine import TrafficLightMachine sm = TrafficLightMachine() result = f"{sm:dot}" assert result.startswith("digraph TrafficLightMachine {") assert "green" in result def test_format_dot_class(self): from tests.examples.traffic_light_machine import TrafficLightMachine result = f"{TrafficLightMachine:dot}" assert result.startswith("digraph TrafficLightMachine {") def test_format_empty_falls_back_to_repr(self): from tests.examples.traffic_light_machine import TrafficLightMachine sm = TrafficLightMachine() result = f"{sm:}" assert "TrafficLightMachine(" in result def test_format_empty_class(self): from tests.examples.traffic_light_machine import TrafficLightMachine result = f"{TrafficLightMachine:}" assert "TrafficLightMachine" in result def test_format_invalid_raises(self): from tests.examples.traffic_light_machine import TrafficLightMachine sm = TrafficLightMachine() with pytest.raises(ValueError, match="Unsupported format"): f"{sm:invalid}" def test_format_invalid_class_raises(self): from tests.examples.traffic_light_machine import TrafficLightMachine with pytest.raises(ValueError, match="Unsupported format"): f"{TrafficLightMachine:invalid}" class TestDocstringExpansion: """Tests for {statechart:FORMAT} placeholder expansion in docstrings.""" def test_md_placeholder(self): from statemachine.state import State from statemachine.statemachine import StateChart class MyMachine(StateChart): """Machine. {statechart:md} """ s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) assert "| State" in MyMachine.__doc__ assert "{statechart:md}" not in MyMachine.__doc__ def test_rst_placeholder(self): from statemachine.state import State from statemachine.statemachine import StateChart class MyMachine(StateChart): """Machine. {statechart:rst} """ s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) assert "+---" in MyMachine.__doc__ assert "{statechart:rst}" not in MyMachine.__doc__ def test_mermaid_placeholder(self): from statemachine.state import State from statemachine.statemachine import StateChart class MyMachine(StateChart): """{statechart:mermaid}""" s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) assert "stateDiagram-v2" in MyMachine.__doc__ def test_no_placeholder_unchanged(self): from statemachine.state import State from statemachine.statemachine import StateChart class MyMachine(StateChart): """Just a plain docstring.""" s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) assert MyMachine.__doc__ == "Just a plain docstring." def test_no_docstring(self): from statemachine.state import State from statemachine.statemachine import StateChart class MyMachine(StateChart): s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) assert MyMachine.__doc__ is None def test_indentation_preserved(self): from statemachine.state import State from statemachine.statemachine import StateChart class MyMachine(StateChart): __doc__ = "Doc.\n\n Table:\n\n {statechart:md}\n\n End.\n" s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) lines = MyMachine.__doc__.split("\n") table_lines = [line for line in lines if "|" in line] for line in table_lines: assert line.startswith(" |") assert "End." in MyMachine.__doc__ def test_multiple_placeholders(self): from statemachine.state import State from statemachine.statemachine import StateChart class MyMachine(StateChart): """MD: {statechart:md} Mermaid: {statechart:mermaid} """ s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) assert "| State" in MyMachine.__doc__ assert "stateDiagram-v2" in MyMachine.__doc__ class TestFormatter: """Tests for the Formatter facade (render, register_format, supported_formats).""" def test_render_mermaid(self): from statemachine.contrib.diagram import formatter from tests.examples.traffic_light_machine import TrafficLightMachine result = formatter.render(TrafficLightMachine, "mermaid") assert "stateDiagram-v2" in result def test_render_dot(self): from statemachine.contrib.diagram import formatter from tests.examples.traffic_light_machine import TrafficLightMachine result = formatter.render(TrafficLightMachine, "dot") assert result.startswith("digraph TrafficLightMachine {") def test_render_svg(self): from statemachine.contrib.diagram import formatter from tests.examples.traffic_light_machine import TrafficLightMachine result = formatter.render(TrafficLightMachine, "svg") assert isinstance(result, str) assert " when no custom event is specified.""" async def test_timeout_fires_done_invoke(self, sm_runner): class SM(StateChart): waiting = State(initial=True, invoke=timeout(0.05)) done = State(final=True) done_invoke_waiting = waiting.to(done) sm = await sm_runner.start(SM) await sm_runner.sleep(0.15) await sm_runner.processing_loop(sm) assert "done" in sm.configuration_values async def test_timeout_cancelled_on_early_exit(self, sm_runner): """If the machine transitions out before the timeout, nothing fires.""" class SM(StateChart): waiting = State(initial=True, invoke=timeout(10)) other = State(final=True) go = waiting.to(other) # No done_invoke_waiting — would fail if timeout fired unexpectedly done_invoke_waiting = waiting.to(waiting) sm = await sm_runner.start(SM) await sm_runner.send(sm, "go") assert "other" in sm.configuration_values class TestTimeoutCustomEvent: """Timeout fires a custom event via the `on` parameter.""" async def test_custom_event_fires(self, sm_runner): class SM(StateChart): waiting = State(initial=True, invoke=timeout(0.05, on="expired")) timed_out = State(final=True) expired = waiting.to(timed_out) sm = await sm_runner.start(SM) await sm_runner.sleep(0.15) await sm_runner.processing_loop(sm) assert "timed_out" in sm.configuration_values async def test_custom_event_cancelled_on_early_exit(self, sm_runner): class SM(StateChart): waiting = State(initial=True, invoke=timeout(10, on="expired")) other = State(final=True) go = waiting.to(other) expired = waiting.to(waiting) sm = await sm_runner.start(SM) await sm_runner.send(sm, "go") assert "other" in sm.configuration_values class TestTimeoutComposition: """Timeout combined with other invoke handlers — first to complete wins.""" async def test_invoke_completes_before_timeout(self, sm_runner): """A fast invoke handler transitions out, cancelling the timeout.""" def fast_handler(): return "fast_result" class SM(StateChart): loading = State(initial=True, invoke=[fast_handler, timeout(10, on="too_slow")]) ready = State(final=True) stuck = State(final=True) done_invoke_loading = loading.to(ready) too_slow = loading.to(stuck) sm = await sm_runner.start(SM) await sm_runner.sleep(0.15) await sm_runner.processing_loop(sm) assert "ready" in sm.configuration_values async def test_timeout_fires_before_slow_invoke(self, sm_runner): """Timeout fires while a slow invoke handler is still running.""" handler_cancelled = threading.Event() class SlowHandler: def run(self, ctx): # Wait until cancelled (state exit) — simulates long-running work ctx.cancelled.wait() handler_cancelled.set() class SM(StateChart): loading = State(initial=True, invoke=[SlowHandler(), timeout(0.05, on="too_slow")]) ready = State(final=True) stuck = State(final=True) done_invoke_loading = loading.to(ready) too_slow = loading.to(stuck) sm = await sm_runner.start(SM) await sm_runner.sleep(0.15) await sm_runner.processing_loop(sm) assert "stuck" in sm.configuration_values # The slow handler should have been cancelled when the state exited handler_cancelled.wait(timeout=2) assert handler_cancelled.is_set() ================================================ FILE: tests/test_copy.py ================================================ import asyncio import logging import pickle from copy import deepcopy from enum import Enum from enum import auto import pytest from statemachine.states import States from statemachine import State from statemachine import StateChart logger = logging.getLogger(__name__) def copy_pickle(obj): return pickle.loads(pickle.dumps(obj)) @pytest.fixture(params=[deepcopy, copy_pickle], ids=["deepcopy", "pickle"]) def copy_method(request): return request.param class GameStates(str, Enum): GAME_START = auto() GAME_PLAYING = auto() TURN_END = auto() GAME_END = auto() class GameStateMachine(StateChart): s = States.from_enum(GameStates, initial=GameStates.GAME_START, final=GameStates.GAME_END) play = s.GAME_START.to(s.GAME_PLAYING) stop = s.GAME_PLAYING.to(s.TURN_END) end_game = s.TURN_END.to(s.GAME_END) @end_game.cond def game_is_over(self) -> bool: return True advance_round = end_game | s.TURN_END.to(s.GAME_END) class MyStateMachine(StateChart): created = State(initial=True) started = State(final=True) start = created.to(started) def __init__(self): super().__init__() self.custom = 1 self.value = [1, 2, 3] class MySM(StateChart): draft = State("Draft", initial=True, value="draft") published = State("Published", value="published", final=True) publish = draft.to(published, cond="let_me_be_visible") def let_me_be_visible(self): return True class MyModel: def __init__(self, name: str) -> None: self.name = name self._let_me_be_visible = False def __repr__(self) -> str: return f"{type(self).__name__}@{id(self)}({self.name!r})" @property def let_me_be_visible(self): return self._let_me_be_visible @let_me_be_visible.setter def let_me_be_visible(self, value): self._let_me_be_visible = value def test_copy(copy_method): sm = MySM(MyModel("main_model")) sm2 = copy_method(sm) assert sm.model is not sm2.model assert sm.model.name == sm2.model.name assert sm2.draft.is_active sm2.model.let_me_be_visible = True sm2.send("publish") assert sm2.published.is_active def test_copy_with_listeners(copy_method): model1 = MyModel("main_model") sm1 = MySM(model1) listener_1 = MyModel("observer_1") listener_2 = MyModel("observer_2") sm1.add_listener(listener_1) sm1.add_listener(listener_2) sm2 = copy_method(sm1) assert sm1.model is not sm2.model assert len(sm1._listeners) == len(sm2._listeners) assert all( listener.name == copied_listener.name # zip(strict=True) requires python 3.10 for listener, copied_listener in zip(sm1._listeners.values(), sm2._listeners.values()) # noqa: B905 ) sm2.model.let_me_be_visible = True for listener in sm2._listeners.values(): listener.let_me_be_visible = True sm2.send("publish") assert sm2.published.is_active def test_copy_with_enum(copy_method): sm = GameStateMachine() sm.play() assert GameStates.GAME_PLAYING in sm.configuration_values sm2 = copy_method(sm) assert GameStates.GAME_PLAYING in sm2.configuration_values def test_copy_with_custom_init_and_vars(copy_method): sm = MyStateMachine() sm.start() sm2 = copy_method(sm) assert sm2.custom == 1 assert sm2.value == [1, 2, 3] assert sm2.started.is_active class AsyncTrafficLightMachine(StateChart): green = State(initial=True) yellow = State() red = State() cycle = green.to(yellow) | yellow.to(red) | red.to(green) async def on_enter_state(self, target): """Async callback to ensure the SM uses AsyncEngine.""" def test_copy_async_statemachine_before_activation(copy_method): """Regression test for issue #544: async SM fails after pickle/deepcopy. When an async SM is copied before activation, the copy must still be activatable because ``__setstate__`` re-enqueues the ``__initial__`` event. """ sm = AsyncTrafficLightMachine() sm_copy = copy_method(sm) async def verify(): await sm_copy.activate_initial_state() assert sm_copy.green.is_active await sm_copy.cycle() assert sm_copy.yellow.is_active asyncio.run(verify()) def test_copy_async_statemachine_after_activation(copy_method): """Copying an async SM that is already activated preserves its current state.""" async def setup_and_verify(): sm = AsyncTrafficLightMachine() await sm.activate_initial_state() await sm.cycle() assert sm.yellow.is_active sm_copy = copy_method(sm) await sm_copy.activate_initial_state() assert sm_copy.yellow.is_active await sm_copy.cycle() assert sm_copy.red.is_active asyncio.run(setup_and_verify()) ================================================ FILE: tests/test_dispatcher.py ================================================ import pytest from statemachine.callbacks import CallbackGroup from statemachine.callbacks import CallbackSpec from statemachine.dispatcher import Listener from statemachine.dispatcher import Listeners from statemachine.dispatcher import resolver_factory_from_objects from statemachine.exceptions import InvalidDefinition from statemachine.state import State from statemachine.statemachine import StateChart def _take_first_callable(iterable): _key, builder = next(iterable) return builder() class Person: def __init__(self, first_name, last_name, legal_document=None): self.first_name = first_name self.last_name = last_name self.legal_document = legal_document def get_full_name(self): return f"{self.first_name} {self.last_name}" class Organization: def __init__(self, name, legal_document): self.name = name self.legal_document = legal_document def get_full_name(self): return self.name class TestEnsureCallable: @pytest.fixture( params=[ pytest.param([], id="no-args"), pytest.param([24, True, "Go!"], id="with-args"), ] ) def args(self, request): return request.param @pytest.fixture( params=[ pytest.param({}, id="no-kwargs"), pytest.param({"a": "x", "b": "y"}, id="with-kwargs"), ] ) def kwargs(self, request): return request.param def test_return_same_object_if_already_a_callable(self): model = Person("Frodo", "Bolseiro") expected = model.get_full_name actual = _take_first_callable( resolver_factory_from_objects([]).search( CallbackSpec(model.get_full_name, group=CallbackGroup.ON) ) ) assert actual.__name__ == expected.__name__ assert actual.__doc__ == expected.__doc__ def test_retrieve_a_method_from_its_name(self, args, kwargs): model = Person("Frodo", "Bolseiro") expected = model.get_full_name method = _take_first_callable( Listeners.from_listeners([Listener.from_obj(model)]).search( CallbackSpec("get_full_name", group=CallbackGroup.ON), ) ) assert method.__name__ == expected.__name__ assert method.__doc__ == expected.__doc__ assert method(*args, **kwargs) == "Frodo Bolseiro" def test_retrieve_a_callable_from_a_property_name(self, args, kwargs): model = Person("Frodo", "Bolseiro") method = _take_first_callable( Listeners.from_listeners([Listener.from_obj(model)]).search( CallbackSpec("first_name", group=CallbackGroup.ON), ) ) assert method(*args, **kwargs) == "Frodo" def test_retrieve_callable_from_a_property_name_that_should_keep_reference(self, args, kwargs): model = Person("Frodo", "Bolseiro") method = _take_first_callable( Listeners.from_listeners([Listener.from_obj(model)]).search( CallbackSpec("first_name", group=CallbackGroup.ON), ) ) model.first_name = "Bilbo" assert method(*args, **kwargs) == "Bilbo" class TestResolverFactory: @pytest.mark.parametrize( ("attr", "expected_value"), [ ("first_name", "Frodo"), ("last_name", "Bolseiro"), ("legal_document", "cnpj"), ("get_full_name", "The Lord fo the Rings"), ], ) def test_should_chain_resolutions(self, attr, expected_value): person = Person("Frodo", "Bolseiro", "cpf") org = Organization("The Lord fo the Rings", "cnpj") resolver = resolver_factory_from_objects(org, person) resolved_method = _take_first_callable( resolver.search(CallbackSpec(attr, group=CallbackGroup.ON)) ) assert resolved_method() == expected_value @pytest.mark.parametrize( ("attr", "expected_value"), [ ("first_name", "Frodo"), ("last_name", "Bolseiro"), ("legal_document", "cnpj"), ("get_full_name", "Frodo Bolseiro"), ], ) def test_should_ignore_list_of_attrs(self, attr, expected_value): person = Person("Frodo", "Bolseiro", "cpf") org = Organization("The Lord fo the Rings", "cnpj") org_config = Listener.from_obj(org, {"get_full_name"}) resolver = resolver_factory_from_objects(org_config, person) resolved_method = _take_first_callable( resolver.search(CallbackSpec(attr, group=CallbackGroup.ON)) ) assert resolved_method() == expected_value class TestSearchProperty: def test_not_found_property_with_same_name(self): class StrangeObject: @property def can_change_to_start(self): return False class StartMachine(StateChart): catch_errors_as_events = False created = State(initial=True) started = State(final=True) start = created.to(started, cond=StrangeObject.can_change_to_start) def can_change_to_start(self): return True with pytest.raises(InvalidDefinition, match="not found name"): StartMachine() ================================================ FILE: tests/test_error_execution.py ================================================ import pytest from statemachine.exceptions import InvalidDefinition from statemachine import Event from statemachine import State from statemachine import StateChart from tests.machines.error.error_convention_event import ErrorConventionEventSC from tests.machines.error.error_convention_transition_list import ErrorConventionTransitionListSC from tests.machines.error.error_in_action_sc import ErrorInActionSC from tests.machines.error.error_in_action_sm_with_flag import ErrorInActionSMWithFlag from tests.machines.error.error_in_after_sc import ErrorInAfterSC from tests.machines.error.error_in_error_handler_sc import ErrorInErrorHandlerSC from tests.machines.error.error_in_guard_sc import ErrorInGuardSC from tests.machines.error.error_in_guard_sm import ErrorInGuardSM from tests.machines.error.error_in_on_enter_sc import ErrorInOnEnterSC def test_exception_in_guard_sends_error_execution(): """Exception in guard returns False and sends error.execution event.""" sm = ErrorInGuardSC() assert sm.configuration == {sm.initial} sm.send("go") # The bad_guard raises, so error.execution is sent, transitioning to error_state assert sm.configuration == {sm.error_state} def test_exception_in_on_enter_sends_error_execution(): """Exception in on_enter sends error.execution and rolls back configuration.""" sm = ErrorInOnEnterSC() assert sm.configuration == {sm.s1} sm.send("go") # on_enter_s2 raises, config is rolled back to s1, then error.execution fires assert sm.configuration == {sm.error_state} def test_exception_in_action_sends_error_execution(): """Exception in transition 'on' action sends error.execution.""" sm = ErrorInActionSC() assert sm.configuration == {sm.s1} sm.send("go") # bad_action raises during transition, config rolls back to s1, # then error.execution fires assert sm.configuration == {sm.error_state} def test_exception_in_after_sends_error_execution_no_rollback(): """Exception in 'after' action sends error.execution but does NOT roll back.""" sm = ErrorInAfterSC() assert sm.configuration == {sm.s1} sm.send("go") # Transition s1->s2 completes, then bad_after raises, # error.execution fires from s2 -> error_state assert sm.configuration == {sm.error_state} def test_statemachine_exception_propagates(): """StateChart with catch_errors_as_events=False should propagate exceptions normally.""" sm = ErrorInGuardSM() assert sm.configuration == {sm.initial} # The bad_guard raises RuntimeError, which should propagate with pytest.raises(RuntimeError, match="guard failed"): sm.send("go") def test_invalid_definition_always_propagates(): """InvalidDefinition should always propagate regardless of catch_errors_as_events.""" class BadDefinitionSC(StateChart): s1 = State("s1", initial=True) s2 = State("s2", final=True) go = s1.to(s2, cond="bad_cond") def bad_cond(self): raise InvalidDefinition("bad definition") sm = BadDefinitionSC() with pytest.raises(InvalidDefinition, match="bad definition"): sm.send("go") def test_error_in_error_handler_no_infinite_loop(): """Error while processing error.execution should not cause infinite loop.""" sm = ErrorInErrorHandlerSC() assert sm.configuration == {sm.s1} # bad_action raises -> caught per-block, transition completes to s2 -> # error.execution fires -> bad_error_handler raises during error.execution # processing -> rolled back, second error ignored (logged as warning) sm.send("go") # Transition 'on' content error is caught per-block (SCXML spec), # so the transition s1->s2 completes. error.execution fires from s2, # bad_error_handler raises, which is ignored during error.execution. assert sm.configuration == {sm.s2} def test_statemachine_with_catch_errors_as_events_true(): """StateChart (catch_errors_as_events=True by default) should catch errors.""" sm = ErrorInActionSMWithFlag() assert sm.configuration == {sm.s1} sm.send("go") assert sm.configuration == {sm.error_state} def test_error_data_available_in_error_execution_handler(): """The error object should be available in the error.execution event kwargs.""" received_errors = [] class ErrorDataSC(StateChart): s1 = State("s1", initial=True) error_state = State("error_state", final=True) go = s1.to(s1, on="bad_action") error_execution = Event(s1.to(error_state, on="handle_error"), id="error.execution") def bad_action(self): raise RuntimeError("specific error message") def handle_error(self, error=None, **kwargs): received_errors.append(error) sm = ErrorDataSC() sm.send("go") assert sm.configuration == {sm.error_state} assert len(received_errors) == 1 assert isinstance(received_errors[0], RuntimeError) assert str(received_errors[0]) == "specific error message" # --- Tests for error_ naming convention --- def test_error_convention_with_transition_list(): """Bare TransitionList with error_ prefix matches error.execution event.""" sm = ErrorConventionTransitionListSC() assert sm.configuration == {sm.s1} sm.send("go") assert sm.configuration == {sm.error_state} def test_error_convention_with_event_no_explicit_id(): """Event without explicit id with error_ prefix matches error.execution event.""" sm = ErrorConventionEventSC() assert sm.configuration == {sm.s1} sm.send("go") assert sm.configuration == {sm.error_state} def test_error_convention_preserves_explicit_id(): """Event with explicit id= should NOT be modified by naming convention.""" class ExplicitIdSC(StateChart): s1 = State("s1", initial=True) error_state = State("error_state", final=True) go = s1.to(s1, on="bad_action") error_execution = Event(s1.to(error_state), id="error.execution") def bad_action(self): raise RuntimeError("action failed") sm = ExplicitIdSC() sm.send("go") assert sm.configuration == {sm.error_state} def test_non_error_prefix_unchanged(): """Attributes NOT starting with error_ should not get dot-notation alias.""" class NormalSC(StateChart): s1 = State("s1", initial=True) s2 = State("s2", final=True) go = s1.to(s2) sm = NormalSC() # The 'go' event should only match 'go', not 'g.o' sm.send("go") assert sm.configuration == {sm.s2} # --- LOTR-themed error_ convention and error handling edge cases --- @pytest.mark.timeout(5) class TestErrorConventionLOTR: """Error handling and error_ naming convention using Lord of the Rings theme.""" def test_ring_corrupts_bearer_convention_transition_list(self): """Frodo puts on the Ring (action fails) -> error.execution via bare TransitionList.""" class FrodoJourney(StateChart): the_shire = State("the_shire", initial=True) corrupted = State("corrupted", final=True) put_on_ring = the_shire.to(the_shire, on="bear_the_ring") error_execution = the_shire.to(corrupted) def bear_the_ring(self): raise RuntimeError("The Ring's corruption is too strong") sm = FrodoJourney() assert sm.configuration == {sm.the_shire} sm.send("put_on_ring") assert sm.configuration == {sm.corrupted} def test_ring_corrupts_bearer_convention_event(self): """Same as above but using Event() without explicit id.""" class FrodoJourney(StateChart): the_shire = State("the_shire", initial=True) corrupted = State("corrupted", final=True) put_on_ring = the_shire.to(the_shire, on="bear_the_ring") error_execution = Event(the_shire.to(corrupted)) def bear_the_ring(self): raise RuntimeError("The Ring's corruption is too strong") sm = FrodoJourney() sm.send("put_on_ring") assert sm.configuration == {sm.corrupted} def test_explicit_id_takes_precedence(self): """Explicit id='error.execution' is preserved, convention does not interfere.""" class GandalfBattle(StateChart): bridge = State("bridge", initial=True) fallen = State("fallen", final=True) fight_balrog = bridge.to(bridge, on="you_shall_not_pass") error_execution = Event(bridge.to(fallen), id="error.execution") def you_shall_not_pass(self): raise RuntimeError("Balrog breaks the bridge") sm = GandalfBattle() sm.send("fight_balrog") assert sm.configuration == {sm.fallen} def test_error_data_passed_to_handler(self): """The original error is available in the error handler kwargs.""" captured = [] class PalantirVision(StateChart): seeing = State("seeing", initial=True) madness = State("madness", final=True) gaze = seeing.to(seeing, on="look_into_palantir") error_execution = seeing.to(madness, on="saurons_influence") def look_into_palantir(self): raise RuntimeError("Sauron's eye burns") def saurons_influence(self, error=None, **kwargs): captured.append(error) sm = PalantirVision() sm.send("gaze") assert sm.configuration == {sm.madness} assert len(captured) == 1 assert str(captured[0]) == "Sauron's eye burns" def test_error_in_guard_with_convention(self): """Error in a guard condition triggers error.execution via convention.""" class GateOfMoria(StateChart): outside = State("outside", initial=True) trapped = State("trapped", final=True) speak_friend = outside.to(outside, cond="know_password") | outside.to(outside) error_execution = outside.to(trapped) def know_password(self): raise RuntimeError("The Watcher attacks") sm = GateOfMoria() sm.send("speak_friend") assert sm.configuration == {sm.trapped} def test_error_in_on_enter_with_convention(self): """Error in on_enter triggers error.execution via convention.""" class EnterMordor(StateChart): ithilien = State("ithilien", initial=True) mordor = State("mordor") captured = State("captured", final=True) march = ithilien.to(mordor) error_execution = ithilien.to(captured) | mordor.to(captured) def on_enter_mordor(self): raise RuntimeError("One does not simply walk into Mordor") sm = EnterMordor() sm.send("march") assert sm.configuration == {sm.captured} def test_error_in_after_with_convention(self): """Error in 'after' callback: transition completes, then error.execution fires.""" class HelmDeep(StateChart): defending = State("defending", initial=True) breached = State("breached") fallen = State("fallen", final=True) charge = defending.to(breached, after="wall_explodes") error_execution = breached.to(fallen) def wall_explodes(self): raise RuntimeError("Uruk-hai detonated the wall") sm = HelmDeep() sm.send("charge") # 'after' runs after the transition completes (defending->breached), # so error.execution fires from breached->fallen assert sm.configuration == {sm.fallen} def test_error_in_error_handler_no_loop_with_convention(self): """Error in error handler must NOT loop infinitely, even with convention.""" class OneRingTemptation(StateChart): carrying = State("carrying", initial=True) resisting = State("resisting", final=True) tempt = carrying.to(carrying, on="resist") error_execution = carrying.to(carrying, on="struggle") throw_ring = carrying.to(resisting) def resist(self): raise RuntimeError("The Ring whispers") def struggle(self): raise RuntimeError("Cannot resist the Ring") sm = OneRingTemptation() sm.send("tempt") # resist raises -> caught per-block, self-transition completes (carrying) -> # error.execution fires -> struggle raises during error.execution -> # rolled back, second error ignored -> stays in carrying assert sm.configuration == {sm.carrying} def test_multiple_source_states_with_convention(self): """error_execution from multiple states using | operator.""" class FellowshipPath(StateChart): rivendell = State("rivendell", initial=True) moria = State("moria") doom = State("doom", final=True) travel = rivendell.to(moria, on="enter_mines") error_execution = rivendell.to(doom) | moria.to(doom) def enter_mines(self): raise RuntimeError("The Balrog awakens") sm = FellowshipPath() sm.send("travel") assert sm.configuration == {sm.doom} def test_convention_with_self_transition_to_final(self): """Self-transition error leading to a different state via error handler.""" class GollumDilemma(StateChart): following = State("following", initial=True) betrayed = State("betrayed", final=True) precious = following.to(following, on="obsess") error_execution = following.to(betrayed) def obsess(self): raise RuntimeError("My precious!") sm = GollumDilemma() sm.send("precious") assert sm.configuration == {sm.betrayed} def test_statemachine_with_convention_and_flag(self): """StateChart (catch_errors_as_events=True by default) uses the error_ convention.""" class SarumanBetrayal(StateChart): white_council = State("white_council", initial=True) orthanc = State("orthanc", final=True) reveal = white_council.to(white_council, on="betray") error_execution = white_council.to(orthanc) def betray(self): raise RuntimeError("Saruman turns to Sauron") sm = SarumanBetrayal() sm.send("reveal") assert sm.configuration == {sm.orthanc} def test_statemachine_without_flag_propagates(self): """StateChart with catch_errors_as_events=False propagates errors even with convention.""" class AragornSword(StateChart): catch_errors_as_events = False broken = State("broken", initial=True) reforge = broken.to(broken, on="attempt_reforge") error_execution = broken.to(broken) def attempt_reforge(self): raise RuntimeError("Narsil cannot be reforged yet") sm = AragornSword() with pytest.raises(RuntimeError, match="Narsil cannot be reforged yet"): sm.send("reforge") def test_no_error_handler_defined(self): """error.execution fires but no matching transition -> silently ignored (StateChart).""" class Treebeard(StateChart): ent_moot = State("ent_moot", initial=True) deliberate = ent_moot.to(ent_moot, on="hasty_decision") def hasty_decision(self): raise RuntimeError("Don't be hasty!") sm = Treebeard() sm.send("deliberate") # No error_execution handler, so error.execution is ignored # (allow_event_without_transition=True on StateChart) assert sm.configuration == {sm.ent_moot} def test_recovery_from_error_allows_further_transitions(self): """After handling error.execution, the machine can continue processing events.""" class FrodoQuest(StateChart): shire = State("shire", initial=True) journey = State("journey") mount_doom = State("mount_doom", final=True) depart = shire.to(shire, on="pack_bags") error_execution = shire.to(journey) continue_quest = journey.to(mount_doom) def pack_bags(self): raise RuntimeError("Nazgul attack!") sm = FrodoQuest() sm.send("depart") assert sm.configuration == {sm.journey} # Machine is still alive, can process more events sm.send("continue_quest") assert sm.configuration == {sm.mount_doom} def test_error_nested_dots_convention(self): """error_communication_failed -> also matches error.communication.failed.""" class BeaconOfGondor(StateChart): waiting = State("waiting", initial=True) lit = State("lit") failed = State("failed", final=True) light_beacon = waiting.to(lit, on="kindle") error_communication_failed = lit.to(failed) def kindle(self): raise RuntimeError("The beacon wood is wet") sm = BeaconOfGondor() sm.send("light_beacon") # Transition 'on' content error is caught per-block (SCXML spec), # so waiting->lit completes. error.execution fires from lit, but # error_communication_failed does NOT match error.execution. # Error is unhandled and silently ignored (StateChart default). assert sm.configuration == {sm.lit} def test_multiple_errors_sequential(self): """Multiple events that fail are each handled by error.execution.""" error_count = [] class BoromirLastStand(StateChart): fighting = State("fighting", initial=True) wounded = State("wounded") fallen = State("fallen", final=True) strike = fighting.to(fighting, on="swing_sword") error_execution = fighting.to(wounded, on="take_arrow") | wounded.to( fallen, on="take_arrow" ) retreat = wounded.to(wounded) def swing_sword(self): raise RuntimeError("Arrow from Lurtz") def take_arrow(self, **kwargs): error_count.append(1) sm = BoromirLastStand() sm.send("strike") assert sm.configuration == {sm.wounded} assert len(error_count) == 1 # Second error from wounded state leads to fallen sm.send("retreat") # no error, just moves wounded->wounded assert sm.configuration == {sm.wounded} def test_invalid_definition_propagates_despite_convention(self): """InvalidDefinition always propagates even with error_ convention.""" class CursedRing(StateChart): wearing = State("wearing", initial=True) corrupted = State("corrupted", final=True) use_ring = wearing.to(wearing, cond="ring_check") error_execution = wearing.to(corrupted) def ring_check(self): raise InvalidDefinition("Ring of Power has no valid definition") sm = CursedRing() with pytest.raises(InvalidDefinition, match="Ring of Power"): sm.send("use_ring") @pytest.mark.timeout(5) class TestErrorHandlerBehaviorLOTR: """Advanced error handler behavior: on callbacks, conditions, flow control, and error-in-handler scenarios. SCXML spec compliance. All using Lord of the Rings theme. """ def test_on_callback_executes_on_error_transition(self): """An `on` callback on the error_execution transition is executed.""" actions_log = [] class MirrorOfGaladriel(StateChart): gazing = State("gazing", initial=True) shattered = State("shattered", final=True) look = gazing.to(gazing, on="peer_into_mirror") error_execution = gazing.to(shattered, on="vision_of_doom") def peer_into_mirror(self): raise RuntimeError("Visions of Sauron") def vision_of_doom(self, **kwargs): actions_log.append("vision_of_doom executed") sm = MirrorOfGaladriel() sm.send("look") assert sm.configuration == {sm.shattered} assert actions_log == ["vision_of_doom executed"] def test_on_callback_receives_error_kwarg(self): """The `on` callback receives the original error via `error` kwarg.""" captured = {} class DeadMarshes(StateChart): walking = State("walking", initial=True) lost = State("lost", final=True) follow_gollum = walking.to(walking, on="step_wrong") error_execution = walking.to(lost, on="fall_in_marsh") def step_wrong(self): raise RuntimeError("The dead faces call") def fall_in_marsh(self, error=None, **kwargs): captured["error"] = error captured["type"] = type(error).__name__ sm = DeadMarshes() sm.send("follow_gollum") assert sm.configuration == {sm.lost} assert captured["type"] == "RuntimeError" assert str(captured["error"]) == "The dead faces call" def test_error_in_on_callback_of_error_handler_is_ignored(self): """If the `on` callback of error.execution raises, the second error is ignored. Per SCXML spec: errors during error.execution processing must not recurse. During error.execution, transition 'on' content errors propagate to microstep(), which rolls back and ignores the second error. """ class MountDoom(StateChart): climbing = State("climbing", initial=True) fallen_into_lava = State("fallen_into_lava", final=True) ascend = climbing.to(climbing, on="slip") error_execution = climbing.to(fallen_into_lava, on="gollum_intervenes") survive = climbing.to(fallen_into_lava) # reachability def slip(self): raise RuntimeError("Rocks crumble") def gollum_intervenes(self): raise RuntimeError("Gollum bites the finger!") sm = MountDoom() sm.send("ascend") # slip raises -> caught per-block, self-transition completes (climbing) -> # error.execution fires -> gollum_intervenes raises during error.execution -> # rolled back to climbing, second error ignored assert sm.configuration == {sm.climbing} def test_condition_on_error_transition_routes_to_different_states(self): """Two error_execution transitions with different cond guards route errors to different target states based on runtime conditions.""" class BattleOfPelennor(StateChart): fighting = State("fighting", initial=True) retreating = State("retreating") fallen = State("fallen", final=True) charge = fighting.to(fighting, on="attack") error_execution = fighting.to(retreating, cond="is_recoverable") | fighting.to(fallen) regroup = retreating.to(fighting) is_minor_wound = False def attack(self): raise RuntimeError("Oliphant charges!") def is_recoverable(self, error=None, **kwargs): return self.is_minor_wound # Serious wound -> falls sm = BattleOfPelennor() sm.is_minor_wound = False sm.send("charge") assert sm.configuration == {sm.fallen} # Minor wound -> retreats sm2 = BattleOfPelennor() sm2.is_minor_wound = True sm2.send("charge") assert sm2.configuration == {sm2.retreating} def test_condition_inspects_error_type_to_route(self): """Conditions can inspect the error type to decide the error transition.""" class PathsOfTheDead(StateChart): entering = State("entering", initial=True) cursed = State("cursed") fled = State("fled", final=True) conquered = State("conquered", final=True) venture = entering.to(entering, on="face_the_dead") error_execution = entering.to(cursed, cond="is_fear") | entering.to(conquered) escape = cursed.to(fled) def face_the_dead(self): raise ValueError("The ghosts overwhelm with fear") def is_fear(self, error=None, **kwargs): return isinstance(error, ValueError) sm = PathsOfTheDead() sm.send("venture") assert sm.configuration == {sm.cursed} def test_condition_inspects_error_message_to_route(self): """Conditions can inspect the error message string.""" class WeathertopAmbush(StateChart): camping = State("camping", initial=True) wounded = State("wounded") safe = State("safe", final=True) rest = camping.to(camping, on="keep_watch") error_execution = camping.to(wounded, cond="is_morgul_blade") | camping.to(safe) heal = wounded.to(safe) def keep_watch(self): raise RuntimeError("Morgul blade strikes Frodo") def is_morgul_blade(self, error=None, **kwargs): return error is not None and "Morgul" in str(error) sm = WeathertopAmbush() sm.send("rest") assert sm.configuration == {sm.wounded} def test_error_handler_can_set_machine_attributes(self): """The `on` handler on error.execution can modify the state machine instance, effectively controlling flow for subsequent transitions.""" log = [] class IsengardSiege(StateChart): besieging = State("besieging", initial=True) flooding = State("flooding") victory = State("victory", final=True) attack = besieging.to(besieging, on="ram_gates") error_execution = besieging.to(flooding, on="release_river") finish = flooding.to(victory) def ram_gates(self): raise RuntimeError("Gates too strong") def release_river(self, error=None, **kwargs): log.append(f"Ents release the river after: {error}") self.battle_outcome = "flooded" sm = IsengardSiege() sm.send("attack") assert sm.configuration == {sm.flooding} assert sm.battle_outcome == "flooded" assert len(log) == 1 sm.send("finish") assert sm.configuration == {sm.victory} def test_error_recovery_then_second_error_handled(self): """After recovering from an error, a second error is also handled correctly.""" errors_seen = [] class MinasTirithDefense(StateChart): outer_wall = State("outer_wall", initial=True) inner_wall = State("inner_wall") citadel = State("citadel", final=True) defend_outer = outer_wall.to(outer_wall, on="hold_wall") error_execution = outer_wall.to(inner_wall, on="log_error") | inner_wall.to( citadel, on="log_error" ) defend_inner = inner_wall.to(inner_wall, on="hold_wall") def hold_wall(self): raise RuntimeError("Wall breached!") def log_error(self, error=None, **kwargs): errors_seen.append(str(error)) sm = MinasTirithDefense() # First error: outer_wall -> inner_wall sm.send("defend_outer") assert sm.configuration == {sm.inner_wall} assert errors_seen == ["Wall breached!"] # Second error: inner_wall -> citadel sm.send("defend_inner") assert sm.configuration == {sm.citadel} assert errors_seen == ["Wall breached!", "Wall breached!"] def test_all_conditions_false_error_unhandled(self): """If all error_execution conditions are False, error.execution is silently ignored.""" class Shelob(StateChart): tunnel = State("tunnel", initial=True) sneak = tunnel.to(tunnel, on="enter_lair") error_execution = tunnel.to(tunnel, cond="never_true") def enter_lair(self): raise RuntimeError("Shelob attacks!") def never_true(self, **kwargs): return False sm = Shelob() sm.send("sneak") # No condition matched, error.execution ignored, stays in tunnel assert sm.configuration == {sm.tunnel} def test_error_in_before_callback_with_convention(self): """Error in a `before` callback is also caught and triggers error.execution.""" class RivendellCouncil(StateChart): debating = State("debating", initial=True) disbanded = State("disbanded", final=True) propose = debating.to(debating, before="check_ring") error_execution = debating.to(disbanded) def check_ring(self): raise RuntimeError("Gimli tries to destroy the Ring") sm = RivendellCouncil() sm.send("propose") assert sm.configuration == {sm.disbanded} def test_error_in_exit_callback_with_convention(self): """Error in on_exit is caught per-block and triggers error.execution.""" class LothlorienDeparture(StateChart): resting = State("resting", initial=True) river = State("river") lost = State("lost", final=True) depart = resting.to(river) error_execution = resting.to(lost) | river.to(lost) def on_exit_resting(self): raise RuntimeError("Galadriel's gifts cause delay") sm = LothlorienDeparture() sm.send("depart") assert sm.configuration == {sm.lost} @pytest.mark.timeout(5) class TestEngineErrorPropagation: def test_invalid_definition_in_enter_propagates(self): """InvalidDefinition during enter_states propagates and restores configuration.""" class SM(StateChart): s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) def on_enter_s2(self, **kwargs): raise InvalidDefinition("Bad definition") sm = SM() with pytest.raises(InvalidDefinition, match="Bad definition"): sm.send("go") def test_invalid_definition_in_after_propagates(self): """InvalidDefinition in after callback propagates.""" class SM(StateChart): s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) def after_go(self, **kwargs): raise InvalidDefinition("Bad after") sm = SM() with pytest.raises(InvalidDefinition, match="Bad after"): sm.send("go") def test_runtime_error_in_after_without_catch_errors_as_events_propagates(self): """RuntimeError in after callback without catch_errors_as_events raises.""" class SM(StateChart): catch_errors_as_events = False s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) def after_go(self, **kwargs): raise RuntimeError("After boom") sm = SM() with pytest.raises(RuntimeError, match="After boom"): sm.send("go") def test_runtime_error_in_after_with_catch_errors_as_events_handled(self): """RuntimeError in after callback with catch_errors_as_events is caught.""" class SM(StateChart): s1 = State(initial=True) s2 = State() error_state = State(final=True) go = s1.to(s2) error_execution = s2.to(error_state) def after_go(self, **kwargs): raise RuntimeError("After boom") sm = SM() sm.send("go") assert sm.configuration == {sm.error_state} def test_runtime_error_in_microstep_without_catch_errors_as_events(self): """RuntimeError in microstep without catch_errors_as_events raises.""" class SM(StateChart): catch_errors_as_events = False s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) def on_enter_s2(self, **kwargs): raise RuntimeError("Microstep boom") sm = SM() with pytest.raises(RuntimeError, match="Microstep boom"): sm.send("go") @pytest.mark.timeout(5) def test_internal_queue_processes_raised_events(): """Internal events raised during processing are handled.""" class SM(StateChart): catch_errors_as_events = False s1 = State(initial=True) s2 = State() s3 = State(final=True) go = s1.to(s2) next_step = s2.to(s3) def on_enter_s2(self, **kwargs): self.raise_("next_step") sm = SM() sm.send("go") assert sm.s3.is_active @pytest.mark.timeout(5) def test_engine_start_when_already_started(): """start() is a no-op when state machine is already initialized.""" class SM(StateChart): catch_errors_as_events = False s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) sm = SM() sm._engine.start() assert sm.s1.is_active @pytest.mark.timeout(5) def test_error_in_internal_event_transition_caught_by_microstep(): """Error in a transition triggered by an internal event is caught by _run_microstep.""" class SM(StateChart): s1 = State(initial=True) s2 = State() s3 = State() error_state = State(final=True) go = s1.to(s2) step = s2.to(s3, on="bad_action") error_execution = s2.to(error_state) | s3.to(error_state) def on_enter_s2(self, **kwargs): self.raise_("step") def bad_action(self): raise RuntimeError("Internal event error") sm = SM() sm.send("go") assert sm.configuration == {sm.error_state} @pytest.mark.timeout(5) def test_invalid_definition_in_internal_event_propagates(): """InvalidDefinition in an internal event transition propagates through _run_microstep.""" class SM(StateChart): s1 = State(initial=True) s2 = State() s3 = State(final=True) error_state = State(final=True) go = s1.to(s2) step = s2.to(s3, on="bad_action") error_execution = s2.to(error_state) def on_enter_s2(self, **kwargs): self.raise_("step") def bad_action(self): raise InvalidDefinition("Internal event bad definition") sm = SM() with pytest.raises(InvalidDefinition, match="Internal event bad definition"): sm.send("go") @pytest.mark.timeout(5) def test_runtime_error_in_internal_event_propagates_without_catch_errors_as_events(): """RuntimeError in internal event propagates when catch_errors_as_events is False.""" class SM(StateChart): catch_errors_as_events = False s1 = State(initial=True) s2 = State() s3 = State(final=True) go = s1.to(s2) step = s2.to(s3, on="bad_action") def on_enter_s2(self, **kwargs): self.raise_("step") def bad_action(self): raise RuntimeError("Internal event boom") sm = SM() with pytest.raises(RuntimeError, match="Internal event boom"): sm.send("go") ================================================ FILE: tests/test_events.py ================================================ import pytest from statemachine.event import Event from statemachine.exceptions import InvalidDefinition from statemachine import State from statemachine import StateChart def test_assign_events_on_transitions(): class TrafficLightMachine(StateChart): "A traffic light machine" green = State(initial=True) yellow = State() red = State() green.to(yellow, event="cycle slowdown") yellow.to(red, event="cycle stop") red.to(green, event="cycle go") def on_cycle(self, event_data, event: str): assert event_data.event == event return ( f"Running {event} from {event_data.transition.source.id} to " f"{event_data.transition.target.id}" ) sm = TrafficLightMachine() assert sm.send("cycle") == "Running cycle from green to yellow" assert sm.send("cycle") == "Running cycle from yellow to red" assert sm.send("cycle") == "Running cycle from red to green" class TestExplicitEvent: def test_accept_event_instance(self): class StartMachine(StateChart): created = State(initial=True) started = State(final=True) start = Event(created.to(started)) assert [e.id for e in StartMachine.events] == ["start"] assert [e.name for e in StartMachine.events] == ["Start"] assert StartMachine.start.name == "Start" sm = StartMachine() sm.send("start") assert sm.started.is_active def test_accept_event_name(self): class StartMachine(StateChart): created = State(initial=True) started = State(final=True) start = Event(created.to(started), name="Start the machine") assert [e.id for e in StartMachine.events] == ["start"] assert [e.name for e in StartMachine.events] == ["Start the machine"] assert StartMachine.start.name == "Start the machine" def test_derive_name_from_id(self): class StartMachine(StateChart): created = State(initial=True) started = State(final=True) launch_the_machine = Event(created.to(started)) assert list(StartMachine.events) == ["launch_the_machine"] assert [e.id for e in StartMachine.events] == ["launch_the_machine"] assert [e.name for e in StartMachine.events] == ["Launch the machine"] assert StartMachine.launch_the_machine.name == "Launch the machine" assert str(StartMachine.launch_the_machine) == "launch_the_machine" assert StartMachine.launch_the_machine == StartMachine.launch_the_machine.id def test_not_derive_name_from_id_if_not_event_class(self): class StartMachine(StateChart): created = State(initial=True) started = State(final=True) launch_the_machine = created.to(started) assert list(StartMachine.events) == ["launch_the_machine"] assert [e.id for e in StartMachine.events] == ["launch_the_machine"] assert [e.name for e in StartMachine.events] == ["Launch the machine"] assert StartMachine.launch_the_machine.name == "Launch the machine" assert str(StartMachine.launch_the_machine) == "launch_the_machine" assert StartMachine.launch_the_machine == StartMachine.launch_the_machine.id def test_raise_invalid_definition_if_event_name_cannot_be_derived(self): with pytest.raises(InvalidDefinition, match="has no id"): class StartMachine(StateChart): created = State(initial=True) started = State() launch = Event(created.to(started)) started.to.itself(event=Event()) # event id not defined def test_derive_from_id(self): class StartMachine(StateChart): created = State(initial=True) started = State(final=True) created.to(started, event=Event("launch_rocket")) assert StartMachine.launch_rocket.name == "Launch rocket" def test_of_passing_event_as_parameters(self): class TrafficLightMachine(StateChart): "A traffic light machine" green = State(initial=True) yellow = State() red = State() cycle = Event(name="Loop") slowdown = Event(name="slow down") stop = Event(name="Please stop") go = Event(name="Go! Go! Go!") green.to(yellow, event=[cycle, slowdown]) yellow.to(red, event=[cycle, stop]) red.to(green, event=[cycle, go]) def on_cycle(self, event_data, event: str): assert event_data.event == event return ( f"Running {event} from {event_data.transition.source.id} to " f"{event_data.transition.target.id}" ) sm = TrafficLightMachine() assert sm.send("cycle") == "Running cycle from green to yellow" assert sm.send("cycle") == "Running cycle from yellow to red" assert sm.send("cycle") == "Running cycle from red to green" assert sm.cycle.name == "Loop" assert sm.slowdown.name == "slow down" assert sm.stop.name == "Please stop" assert sm.go.name == "Go! Go! Go!" def test_mixing_event_and_parameters(self): class TrafficLightMachine(StateChart): "A traffic light machine" green = State(initial=True) yellow = State() red = State() cycle = Event( green.to(yellow, event=Event("slowdown", name="Slow down")) | yellow.to(red, event=Event("stop", name="Please stop!")) | red.to(green, event=Event("go", name="Go! Go! Go!")), name="Loop", ) def on_cycle(self, event_data, event: str): assert event_data.event == event return ( f"Running {event} from {event_data.transition.source.id} to " f"{event_data.transition.target.id}" ) sm = TrafficLightMachine() assert sm.send("cycle") == "Running cycle from green to yellow" assert sm.send("cycle") == "Running cycle from yellow to red" assert sm.send("cycle") == "Running cycle from red to green" assert sm.cycle.name == "Loop" assert sm.slowdown.name == "Slow down" assert sm.stop.name == "Please stop!" assert sm.go.name == "Go! Go! Go!" def test_name_derived_from_identifier(self): class TrafficLightMachine(StateChart): "A traffic light machine" green = State(initial=True) yellow = State() red = State() cycle = Event(name="Loop") slow_down = Event() green.to(yellow, event=[cycle, slow_down]) yellow.to(red, event=[cycle, "stop"]) red.to(green, event=[cycle, "go"]) def on_cycle(self, event_data, event: str): assert event_data.event == event return ( f"Running {event} from {event_data.transition.source.id} to " f"{event_data.transition.target.id}" ) sm = TrafficLightMachine() assert sm.send("cycle") == "Running cycle from green to yellow" assert sm.send("cycle") == "Running cycle from yellow to red" assert sm.send("cycle") == "Running cycle from red to green" assert sm.cycle.name == "Loop" assert sm.slow_down.name == "Slow down" assert sm.stop.name == "Stop" assert sm.go.name == "Go" def test_multiple_ids_from_the_same_event_will_be_converted_to_multiple_events(self): class TrafficLightMachine(StateChart): "A traffic light machine" green = State(initial=True) yellow = State() red = State() green.to(yellow, event=Event("cycle slowdown", name="Will be ignored")) yellow.to(red, event=Event("cycle stop", name="Will be ignored")) red.to(green, event=Event("cycle go", name="Will be ignored")) def on_cycle(self, event_data, event: str): assert event_data.event == event return ( f"Running {event} from {event_data.transition.source.id} to " f"{event_data.transition.target.id}" ) sm = TrafficLightMachine() assert sm.slowdown.name == "Slowdown" assert sm.stop.name == "Stop" assert sm.go.name == "Go" assert sm.send("cycle") == "Running cycle from green to yellow" assert sm.send("cycle") == "Running cycle from yellow to red" assert sm.send("cycle") == "Running cycle from red to green" def test_allow_registering_callbacks_using_decorator(self): class TrafficLightMachine(StateChart): "A traffic light machine" green = State(initial=True) yellow = State() red = State() cycle = Event( green.to(yellow, event="slow_down") | yellow.to(red, event=["stop"]) | red.to(green, event=["go"]), name="Loop", ) @cycle.on def do_cycle(self, event_data, event: str): assert event_data.event == event return ( f"Running {event} from {event_data.transition.source.id} to " f"{event_data.transition.target.id}" ) sm = TrafficLightMachine() assert sm.send("cycle") == "Running cycle from green to yellow" def test_raise_registering_callbacks_using_decorator_if_no_transitions(self): with pytest.raises(InvalidDefinition, match="event with no transitions"): class TrafficLightMachine(StateChart): "A traffic light machine" green = State(initial=True) yellow = State() red = State() cycle = Event(name="Loop") slow_down = Event() green.to(yellow, event=[cycle, slow_down]) yellow.to(red, event=[cycle, "stop"]) red.to(green, event=[cycle, "go"]) @cycle.on def do_cycle(self, event_data, event: str): assert event_data.event == event return ( f"Running {event} from {event_data.transition.source.id} to " f"{event_data.transition.target.id}" ) def test_allow_using_events_as_commands(self): class StartMachine(StateChart): created = State(initial=True) started = State(final=True) created.to(started, event=Event("launch_rocket")) sm = StartMachine() event = next(iter(sm.events)) event() # events on an instance machine are "bounded events" assert sm.started.is_active def test_event_commands_fail_when_unbound_to_instance(self): class StartMachine(StateChart): created = State(initial=True) started = State(final=True) created.to(started, event=Event("launch_rocket")) event = next(iter(StartMachine.events)) with pytest.raises(AssertionError): event() def test_event_match_trailing_dot(): """Event descriptor ending with '.' matches the prefix.""" event = Event("error.") assert event.match("error") is True assert event.match("error.execution") is True def test_event_build_trigger_with_none_machine(): """build_trigger raises when machine is None.""" event = Event("go") with pytest.raises(RuntimeError, match="cannot be called without"): event.build_trigger(machine=None) def test_events_match_none_with_empty(): """Empty Events collection matches None event.""" from statemachine.events import Events events = Events() assert events.match(None) is True def test_event_raises_on_non_string_id(): """Event() should raise InvalidDefinition when id is not a string. This catches a common mistake where users pass multiple transitions as positional args (e.g. Event(t1, t2)) instead of combining them with |. """ s1 = State(initial=True) s2 = State(final=True) t1 = s1.to(s2) t2 = s2.to(s1) with pytest.raises(InvalidDefinition, match="non-string 'id'.*use the \\| operator"): Event(t1, t2) ================================================ FILE: tests/test_examples.py ================================================ from inspect import iscoroutinefunction from pathlib import Path import pytest from .helpers import import_module_by_path def pytest_generate_tests(metafunc): if "example_file_wrapper" not in metafunc.fixturenames: return file_names = [ pytest.param(example_path, id=f"{example_path}") for example_path in Path("tests/examples").glob("**/*_machine.py") ] metafunc.parametrize("file_name", file_names) @pytest.fixture() def example_file_wrapper(file_name): def execute_file_wrapper(): module = import_module_by_path(file_name.with_suffix("")) return getattr(module, "main", None) return execute_file_wrapper async def test_example(example_file_wrapper): """Import the example file so the module is executed""" main = example_file_wrapper() if main is None: return if iscoroutinefunction(main): await main() else: main() ================================================ FILE: tests/test_fellowship_quest.py ================================================ """Fellowship Quest: error.execution with conditions, listeners, and flow control. Demonstrates how a single StateChart definition can produce different outcomes depending on the character (listener) capabilities and the type of peril (exception). Per SCXML spec: - error.execution transitions follow the same rules as any other transition - conditions are evaluated in document order; the first match wins - the error object is available to conditions and handlers via the ``error`` kwarg - executable content (``on`` callbacks) on error transitions is executed normally - errors during error.execution processing are ignored to prevent infinite loops """ import pytest from statemachine import State from statemachine import StateChart # --------------------------------------------------------------------------- # Peril types (exception hierarchy) # --------------------------------------------------------------------------- class Peril(Exception): """Base class for all Middle-earth perils.""" class RingTemptation(Peril): """The One Ring tries to corrupt its bearer.""" class OrcAmbush(Peril): """An orc war party attacks the fellowship.""" class DarkSorcery(Peril): """Sauron's dark magic or a Nazgûl's sorcery.""" class TreacherousTerrain(Peril): """Natural hazards: avalanches, marshes, crumbling paths.""" class BalrogFury(Peril): """An ancient Balrog of Morgoth. Even wizards may fall.""" # --------------------------------------------------------------------------- # Characters (listeners) # --------------------------------------------------------------------------- class Character: """Base class for fellowship members. Subclasses override capability flags. Condition methods are discovered by the StateChart via the listener mechanism, so the method names must match the ``cond`` strings on the error_execution transitions. """ name: str = "Unknown" has_magic: bool = False has_ring_resistance: bool = False has_combat_prowess: bool = False has_endurance: bool = False def can_counter_with_magic(self, error=None, **kwargs): """Wizards can deflect dark sorcery — but not a Balrog.""" return self.has_magic and isinstance(error, DarkSorcery) def can_resist_temptation(self, error=None, **kwargs): """Ring-bearers and the wise can resist the Ring's call.""" return self.has_ring_resistance and isinstance(error, RingTemptation) def can_endure(self, error=None, **kwargs): """Warriors and the resilient survive physical perils.""" return (self.has_combat_prowess and isinstance(error, OrcAmbush)) or ( self.has_endurance and isinstance(error, TreacherousTerrain) ) def __repr__(self): return self.name class Gandalf(Character): name = "Gandalf" has_magic = True has_ring_resistance = True has_combat_prowess = True has_endurance = True class Aragorn(Character): name = "Aragorn" has_combat_prowess = True has_endurance = True class Frodo(Character): name = "Frodo" has_ring_resistance = True has_endurance = True # mithril coat class Legolas(Character): name = "Legolas" has_combat_prowess = True # elven agility has_endurance = True class Boromir(Character): name = "Boromir" has_combat_prowess = True has_endurance = True class Pippin(Character): name = "Pippin" class Samwise(Character): name = "Samwise" has_ring_resistance = True # briefly bore the Ring without corruption has_endurance = True # "I can't carry it for you, but I can carry you!" # --------------------------------------------------------------------------- # The StateChart # --------------------------------------------------------------------------- class FellowshipQuest(StateChart): """A quest through Middle-earth where perils are handled differently depending on the character's capabilities. Conditions on error_execution transitions (evaluated in document order): 1. can_counter_with_magic — wizard deflects sorcery, stays adventuring 2. can_resist_temptation — ring resistance deflects corruption, stays adventuring 3. can_endure — physical resilience survives the blow, but wounded 4. is_ring_corruption — if the peril is ring corruption, route to corrupted 5. (no condition) — fallback: the character falls From wounded state, any further peril is fatal (no conditions). """ adventuring = State("adventuring", initial=True) wounded = State("wounded") corrupted = State("corrupted", final=True) fallen = State("fallen", final=True) healed = State("healed", final=True) face_peril = adventuring.to(adventuring, on="encounter_danger") face_peril_wounded = wounded.to(wounded, on="encounter_danger") # error_execution transitions — document order determines priority. # Character capability conditions are resolved from the listener. error_execution = ( adventuring.to(adventuring, cond="can_counter_with_magic") | adventuring.to(adventuring, cond="can_resist_temptation") | adventuring.to(wounded, cond="can_endure", on="take_hit") | adventuring.to(corrupted, cond="is_ring_corruption") | adventuring.to(fallen) | wounded.to(fallen) ) recover = wounded.to(healed) wound_description: "str | None" = None def encounter_danger(self, peril, **kwargs): raise peril def is_ring_corruption(self, error=None, **kwargs): """Universal condition (on the SM itself, not character-dependent).""" return isinstance(error, RingTemptation) def take_hit(self, error=None, **kwargs): self.wound_description = str(error) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- # State name aliases for readable parametrize IDs ADVENTURING = "adventuring" WOUNDED = "wounded" CORRUPTED = "corrupted" FALLEN = "fallen" def _state_by_name(sm, name): return getattr(sm, name) # --------------------------------------------------------------------------- # Tests — single-peril outcome matrix # --------------------------------------------------------------------------- @pytest.mark.timeout(5) @pytest.mark.parametrize( ("character", "peril", "expected"), [ # --- Gandalf: magic + ring resistance + combat + endurance --- pytest.param( Gandalf(), DarkSorcery("Nazgûl screams"), ADVENTURING, id="gandalf-deflects-sorcery" ), pytest.param( Gandalf(), RingTemptation("The Ring calls to power"), ADVENTURING, id="gandalf-resists-ring", ), pytest.param(Gandalf(), OrcAmbush("Goblins in Moria"), WOUNDED, id="gandalf-endures-orcs"), pytest.param( Gandalf(), TreacherousTerrain("Caradhras blizzard"), WOUNDED, id="gandalf-endures-terrain", ), pytest.param(Gandalf(), BalrogFury("Flame of Udûn"), FALLEN, id="gandalf-falls-to-balrog"), # --- Aragorn: combat + endurance --- pytest.param( Aragorn(), DarkSorcery("Mouth of Sauron's curse"), FALLEN, id="aragorn-falls-to-sorcery", ), pytest.param( Aragorn(), RingTemptation("The Ring offers kingship"), CORRUPTED, id="aragorn-corrupted-by-ring", ), pytest.param(Aragorn(), OrcAmbush("Uruk-hai charge"), WOUNDED, id="aragorn-endures-orcs"), pytest.param( Aragorn(), TreacherousTerrain("Caradhras avalanche"), WOUNDED, id="aragorn-endures-terrain", ), # --- Frodo: ring resistance + endurance (mithril) --- pytest.param( Frodo(), DarkSorcery("Witch-king's blade"), FALLEN, id="frodo-falls-to-sorcery" ), pytest.param( Frodo(), RingTemptation("The Ring whispers"), ADVENTURING, id="frodo-resists-ring" ), pytest.param(Frodo(), OrcAmbush("Cirith Ungol orcs"), FALLEN, id="frodo-falls-to-orcs"), pytest.param( Frodo(), TreacherousTerrain("Cave troll stab (mithril saves)"), WOUNDED, id="frodo-endures-terrain-mithril", ), # --- Legolas: combat + endurance --- pytest.param(Legolas(), DarkSorcery("Dark spell"), FALLEN, id="legolas-falls-to-sorcery"), pytest.param( Legolas(), RingTemptation("The Ring promises immortal forest"), CORRUPTED, id="legolas-corrupted-by-ring", ), pytest.param(Legolas(), OrcAmbush("Orc arrows rain"), WOUNDED, id="legolas-endures-orcs"), # --- Boromir: combat + endurance, no ring resistance --- pytest.param( Boromir(), RingTemptation("Give me the Ring!"), CORRUPTED, id="boromir-corrupted-by-ring", ), pytest.param(Boromir(), OrcAmbush("Lurtz attacks"), WOUNDED, id="boromir-endures-orcs"), # --- Samwise: ring resistance + endurance --- pytest.param( Samwise(), RingTemptation("Ring tempts with gardens"), ADVENTURING, id="samwise-resists-ring", ), pytest.param( Samwise(), TreacherousTerrain("Stairs of Cirith Ungol"), WOUNDED, id="samwise-endures-terrain", ), pytest.param( Samwise(), DarkSorcery("Shelob's darkness"), FALLEN, id="samwise-falls-to-sorcery" ), pytest.param(Samwise(), OrcAmbush("Orc patrol"), FALLEN, id="samwise-falls-to-orcs"), # --- Pippin: no special capabilities --- pytest.param( Pippin(), RingTemptation("The Ring shows second breakfast"), CORRUPTED, id="pippin-corrupted-by-ring", ), pytest.param( Pippin(), DarkSorcery("Palantír vision"), FALLEN, id="pippin-falls-to-sorcery" ), pytest.param(Pippin(), OrcAmbush("Troll swings"), FALLEN, id="pippin-falls-to-orcs"), pytest.param( Pippin(), TreacherousTerrain("Dead Marshes"), FALLEN, id="pippin-falls-to-terrain" ), ], ) def test_single_peril_outcome(character, peril, expected): """Each character × peril combination produces the expected outcome.""" sm = FellowshipQuest(listeners=[character]) sm.send("face_peril", peril=peril) assert sm.configuration == {_state_by_name(sm, expected)} # --------------------------------------------------------------------------- # Tests — on callback receives error context # --------------------------------------------------------------------------- @pytest.mark.timeout(5) @pytest.mark.parametrize( ("character", "peril", "expect_wound"), [ pytest.param( Aragorn(), OrcAmbush("Poisoned orc blade"), "Poisoned orc blade", id="wound-from-orcs" ), pytest.param( Legolas(), TreacherousTerrain("Caradhras ice"), "Caradhras ice", id="wound-from-terrain", ), pytest.param(Gandalf(), DarkSorcery("Nazgûl"), None, id="no-wound-when-deflected"), pytest.param( Boromir(), RingTemptation("The Ring calls"), None, id="no-wound-when-corrupted" ), ], ) def test_wound_description(character, peril, expect_wound): """The take_hit callback stores the wound description only when can_endure matches.""" sm = FellowshipQuest(listeners=[character]) sm.send("face_peril", peril=peril) assert sm.wound_description == expect_wound # --------------------------------------------------------------------------- # Tests — multi-peril sagas # --------------------------------------------------------------------------- @pytest.mark.timeout(5) @pytest.mark.parametrize( ("character", "perils_and_states"), [ pytest.param( Gandalf(), [ (DarkSorcery("Saruman's blast"), ADVENTURING), (RingTemptation("The Ring calls"), ADVENTURING), (DarkSorcery("Witch-king's curse"), ADVENTURING), (OrcAmbush("Moria goblins"), WOUNDED), ], id="gandalf-saga-deflects-three-then-wounded", ), pytest.param( Frodo(), [ (RingTemptation("Ring at Weathertop"), ADVENTURING), (RingTemptation("Ring at Amon Hen"), ADVENTURING), (TreacherousTerrain("Emyn Muil rocks"), WOUNDED), ], id="frodo-saga-resists-ring-twice-then-wounded", ), pytest.param( Samwise(), [ (RingTemptation("Ring offers a garden"), ADVENTURING), (TreacherousTerrain("Stairs of Cirith Ungol"), WOUNDED), ], id="samwise-saga-resists-ring-then-wounded", ), ], ) def test_multi_peril_saga(character, perils_and_states): """Characters face a sequence of perils — each step checked.""" sm = FellowshipQuest(listeners=[character]) for peril, expected in perils_and_states: sm.send("face_peril", peril=peril) assert sm.configuration == {_state_by_name(sm, expected)} # --------------------------------------------------------------------------- # Tests — wounded then second peril (always fatal) # --------------------------------------------------------------------------- @pytest.mark.timeout(5) @pytest.mark.parametrize( ("character", "first_peril", "second_peril"), [ pytest.param( Aragorn(), OrcAmbush("First wave"), OrcAmbush("Second wave"), id="aragorn-wounded-then-falls", ), pytest.param( Boromir(), OrcAmbush("Lurtz's arrows"), RingTemptation("The Ring in his final moments"), id="boromir-wounded-then-corrupted-by-ring-but-falls", ), pytest.param( Legolas(), TreacherousTerrain("Ice bridge cracks"), DarkSorcery("Shadow spell"), id="legolas-wounded-then-falls", ), ], ) def test_wounded_then_second_peril_is_fatal(character, first_peril, second_peril): """A wounded character facing any second peril always falls — no conditions on the wounded→fallen transition.""" sm = FellowshipQuest(listeners=[character]) sm.send("face_peril", peril=first_peril) assert sm.configuration == {sm.wounded} sm.send("face_peril_wounded", peril=second_peril) assert sm.configuration == {sm.fallen} # --------------------------------------------------------------------------- # Tests — recovery after wound # --------------------------------------------------------------------------- @pytest.mark.timeout(5) @pytest.mark.parametrize( ("character", "peril"), [ pytest.param(Aragorn(), TreacherousTerrain("Cliff fall"), id="aragorn-recovers"), pytest.param(Gandalf(), OrcAmbush("Goblin arrow"), id="gandalf-recovers"), pytest.param(Frodo(), TreacherousTerrain("Shelob's lair"), id="frodo-recovers"), ], ) def test_recovery_after_wound(character, peril): """A wounded character can recover and reach a positive ending.""" sm = FellowshipQuest(listeners=[character]) sm.send("face_peril", peril=peril) assert sm.configuration == {sm.wounded} sm.send("recover") assert sm.configuration == {sm.healed} ================================================ FILE: tests/test_invoke.py ================================================ """Tests for the invoke callback group.""" import threading import time from statemachine.invoke import IInvoke from statemachine.invoke import InvokeContext from statemachine.invoke import invoke_group from statemachine import Event from statemachine import State from statemachine import StateChart class TestInvokeSimpleCallable: """Simple callable invoke — function runs in background, done.invoke fires.""" async def test_simple_callable_invoke(self, sm_runner): results = [] class SM(StateChart): loading = State(initial=True, invoke=lambda: 42) ready = State(final=True) done_invoke_loading = loading.to(ready) def on_enter_ready(self, data=None, **kwargs): results.append(data) sm = await sm_runner.start(SM) await sm_runner.sleep(0.15) await sm_runner.processing_loop(sm) assert "ready" in sm.configuration_values assert results == [42] async def test_invoke_return_value_in_done_event(self, sm_runner): results = [] class SM(StateChart): loading = State(initial=True, invoke=lambda: {"key": "value"}) ready = State(final=True) done_invoke_loading = loading.to(ready) def on_enter_ready(self, data=None, **kwargs): results.append(data) sm = await sm_runner.start(SM) await sm_runner.sleep(0.15) await sm_runner.processing_loop(sm) assert "ready" in sm.configuration_values assert results == [{"key": "value"}] class TestInvokeNamingConvention: """Naming convention — on_invoke_() method is discovered and invoked.""" async def test_naming_convention(self, sm_runner): invoked = [] class SM(StateChart): loading = State(initial=True) ready = State(final=True) done_invoke_loading = loading.to(ready) def on_invoke_loading(self, **kwargs): invoked.append(True) return "done" sm = await sm_runner.start(SM) await sm_runner.sleep(0.15) await sm_runner.processing_loop(sm) assert invoked == [True] assert "ready" in sm.configuration_values class TestInvokeDecorator: """Decorator — @state.invoke handler.""" async def test_decorator_invoke(self, sm_runner): invoked = [] class SM(StateChart): loading = State(initial=True) ready = State(final=True) done_invoke_loading = loading.to(ready) @loading.invoke def do_work(self, **kwargs): invoked.append(True) return "result" sm = await sm_runner.start(SM) await sm_runner.sleep(0.15) await sm_runner.processing_loop(sm) assert invoked == [True] assert "ready" in sm.configuration_values class TestInvokeIInvokeProtocol: """IInvoke protocol — class with run(ctx) method.""" async def test_iinvoke_class(self, sm_runner): """Pass an IInvoke class — engine instantiates per SM instance.""" results = [] class MyInvoker: def run(self, ctx: InvokeContext): results.append(ctx.state_id) return "invoker_result" def on_cancel(self): pass # no-op: only verifying the protocol is satisfied assert isinstance(MyInvoker(), IInvoke) class SM(StateChart): loading = State(initial=True, invoke=MyInvoker) ready = State(final=True) done_invoke_loading = loading.to(ready) def on_enter_ready(self, data=None, **kwargs): results.append(data) sm = await sm_runner.start(SM) await sm_runner.sleep(0.15) await sm_runner.processing_loop(sm) assert "loading" in results assert "invoker_result" in results assert "ready" in sm.configuration_values async def test_each_sm_instance_gets_own_handler(self, sm_runner): """Each StateChart instance must get a fresh IInvoke instance.""" handler_ids = [] class TrackingInvoker: def run(self, ctx: InvokeContext): handler_ids.append(id(self)) return None class SM(StateChart): loading = State(initial=True, invoke=TrackingInvoker) ready = State(final=True) done_invoke_loading = loading.to(ready) sm1 = await sm_runner.start(SM) await sm_runner.sleep(0.15) await sm_runner.processing_loop(sm1) sm2 = await sm_runner.start(SM) await sm_runner.sleep(0.15) await sm_runner.processing_loop(sm2) assert len(handler_ids) == 2 assert handler_ids[0] != handler_ids[1], "Each SM must get its own handler instance" class TestInvokeCancelOnExit: """Cancel on exit — ctx.cancelled is set when state is exited.""" async def test_cancel_on_exit_sync(self): """Test cancel in sync mode only — uses threading.Event.wait().""" from tests.conftest import SMRunner sm_runner = SMRunner(is_async=False) cancel_observed = [] class SM(StateChart): loading = State(initial=True) cancelled_state = State(final=True) cancel = loading.to(cancelled_state) def on_invoke_loading(self, ctx=None, **kwargs): if ctx is None: return ctx.cancelled.wait(timeout=5.0) cancel_observed.append(ctx.cancelled.is_set()) sm = await sm_runner.start(SM) await sm_runner.sleep(0.05) await sm_runner.send(sm, "cancel") await sm_runner.sleep(0.1) assert cancel_observed == [True] assert "cancelled_state" in sm.configuration_values async def test_cancel_on_exit_with_on_cancel(self, sm_runner): """Test that on_cancel() is called when state is exited.""" cancel_called = [] class CancelTracker: def run(self, ctx): while not ctx.cancelled.is_set(): ctx.cancelled.wait(0.01) def on_cancel(self): cancel_called.append(True) class SM(StateChart): loading = State(initial=True, invoke=CancelTracker) cancelled_state = State(final=True) cancel = loading.to(cancelled_state) sm = await sm_runner.start(SM) # Give the invoke handler time to start in its background thread await sm_runner.sleep(0.15) await sm_runner.send(sm, "cancel") await sm_runner.sleep(0.15) assert cancel_called == [True] assert "cancelled_state" in sm.configuration_values class TestInvokeErrorHandling: """Error in invoker → error.execution event.""" async def test_error_in_invoke(self, sm_runner): errors = [] class SM(StateChart): loading = State(initial=True, invoke=lambda: 1 / 0) error_state = State(final=True) error_execution = loading.to(error_state) def on_enter_error_state(self, **kwargs): errors.append(True) sm = await sm_runner.start(SM) await sm_runner.sleep(0.15) await sm_runner.processing_loop(sm) assert errors == [True] assert "error_state" in sm.configuration_values class TestInvokeMultiple: """Multiple invokes per state — all run concurrently.""" async def test_multiple_invokes(self, sm_runner): results = [] lock = threading.Lock() def task_a(): with lock: results.append("a") return "a" def task_b(): with lock: results.append("b") return "b" class SM(StateChart): loading = State(initial=True, invoke=invoke_group(task_a, task_b)) ready = State(final=True) done_invoke_loading = loading.to(ready) sm = await sm_runner.start(SM) await sm_runner.sleep(0.2) await sm_runner.processing_loop(sm) assert sorted(results) == ["a", "b"] class TestInvokeStateChartChild: """StateChart as invoker — child machine runs, completion fires done event.""" async def test_statechart_invoker(self, sm_runner): class ChildMachine(StateChart): start = State(initial=True) end = State(final=True) go = start.to(end) def on_enter_start(self, **kwargs): self.send("go") class SM(StateChart): loading = State(initial=True, invoke=ChildMachine) ready = State(final=True) done_invoke_loading = loading.to(ready) sm = await sm_runner.start(SM) await sm_runner.sleep(0.15) await sm_runner.processing_loop(sm) assert "ready" in sm.configuration_values class TestDoneInvokeTransition: """done_invoke_ transition — naming convention works.""" async def test_done_invoke_transition(self, sm_runner): class SM(StateChart): loading = State(initial=True, invoke=lambda: "hello") ready = State(final=True) done_invoke_loading = loading.to(ready) sm = await sm_runner.start(SM) await sm_runner.sleep(0.15) await sm_runner.processing_loop(sm) assert "ready" in sm.configuration_values class TestDoneInvokeEventFormat: """done.invoke event name must be done.invoke.. (no duplication).""" async def test_done_invoke_event_has_no_duplicate_state_id(self, sm_runner): received_events = [] class SM(StateChart): loading = State(initial=True, invoke=lambda: "ok") ready = State(final=True) done_invoke_loading = loading.to(ready) def on_enter_ready(self, event=None, **kwargs): if event is not None: received_events.append(str(event)) sm = await sm_runner.start(SM) await sm_runner.sleep(0.15) await sm_runner.processing_loop(sm) assert len(received_events) == 1 event_name = received_events[0] # Must be "done.invoke.loading." — NOT "done.invoke.loading.loading." assert event_name.startswith("done.invoke.loading.") parts = event_name.split(".") # ["done", "invoke", "loading", ""] — exactly 4 parts assert len(parts) == 4, f"Expected 4 parts, got {parts}" class TestInvokeGroup: """invoke_group() — runs multiple callables concurrently, returns list of results.""" async def test_group_returns_ordered_results(self, sm_runner): """Results are returned in the same order as the input callables.""" results = [] def slow(): time.sleep(0.05) return "slow" def fast(): return "fast" class SM(StateChart): loading = State(initial=True, invoke=invoke_group(slow, fast)) ready = State(final=True) done_invoke_loading = loading.to(ready) def on_enter_ready(self, data=None, **kwargs): results.append(data) sm = await sm_runner.start(SM) await sm_runner.sleep(0.2) await sm_runner.processing_loop(sm) assert "ready" in sm.configuration_values assert results == [["slow", "fast"]] async def test_group_with_file_io(self, sm_runner, tmp_path): """Real I/O: read two files concurrently and get both results.""" file_a = tmp_path / "a.txt" file_b = tmp_path / "b.txt" file_a.write_text("hello") file_b.write_text("world") results = [] class SM(StateChart): loading = State( initial=True, invoke=invoke_group( lambda: file_a.read_text(), lambda: file_b.read_text(), ), ) ready = State(final=True) done_invoke_loading = loading.to(ready) def on_enter_ready(self, data=None, **kwargs): results.append(data) sm = await sm_runner.start(SM) await sm_runner.sleep(0.2) await sm_runner.processing_loop(sm) assert "ready" in sm.configuration_values assert results == [["hello", "world"]] async def test_group_error_cancels_remaining(self, sm_runner): """If one callable raises, error.execution is sent.""" errors = [] def ok(): time.sleep(0.1) return "ok" def fail(): raise ValueError("boom") class SM(StateChart): loading = State(initial=True, invoke=invoke_group(ok, fail)) error_state = State(final=True) error_execution = loading.to(error_state) def on_enter_error_state(self, **kwargs): errors.append(True) sm = await sm_runner.start(SM) await sm_runner.sleep(0.3) await sm_runner.processing_loop(sm) assert "error_state" in sm.configuration_values assert errors == [True] async def test_group_cancel_on_exit(self, sm_runner): """Cancellation propagates: exiting state stops the group.""" cancel_flag = threading.Event() def slow_task(): # Use interruptible wait so thread can exit promptly on cancellation. cancel_flag.wait(timeout=5.0) return "should not complete" class SM(StateChart): loading = State(initial=True, invoke=invoke_group(slow_task)) stopped = State(final=True) cancel = loading.to(stopped) sm = await sm_runner.start(SM) await sm_runner.sleep(0.05) await sm_runner.send(sm, "cancel") cancel_flag.set() # Unblock the slow_task thread await sm_runner.sleep(0.1) assert "stopped" in sm.configuration_values async def test_group_single_callable(self, sm_runner): """Edge case: group with a single callable still returns a list.""" results = [] class SM(StateChart): loading = State(initial=True, invoke=invoke_group(lambda: 42)) ready = State(final=True) done_invoke_loading = loading.to(ready) def on_enter_ready(self, data=None, **kwargs): results.append(data) sm = await sm_runner.start(SM) await sm_runner.sleep(0.2) await sm_runner.processing_loop(sm) assert "ready" in sm.configuration_values assert results == [[42]] async def test_each_sm_instance_gets_own_group(self, sm_runner): """Each SM instance must get its own InvokeGroup — no shared state.""" all_results = [] counter = {"value": 0} lock = threading.Lock() def counting_task(): with lock: counter["value"] += 1 return counter["value"] class SM(StateChart): loading = State(initial=True, invoke=invoke_group(counting_task)) ready = State(final=True) done_invoke_loading = loading.to(ready) def on_enter_ready(self, data=None, **kwargs): all_results.append(data) sm1 = await sm_runner.start(SM) await sm_runner.sleep(0.2) await sm_runner.processing_loop(sm1) sm2 = await sm_runner.start(SM) await sm_runner.sleep(0.2) await sm_runner.processing_loop(sm2) assert len(all_results) == 2 assert all_results[0] == [1] assert all_results[1] == [2] class TestInvokeEventKwargs: """Event kwargs from send() are forwarded to invoke handlers.""" async def test_plain_callable_receives_event_kwargs(self, sm_runner): """Plain callable invoke handler receives event kwargs via SignatureAdapter.""" received = [] class SM(StateChart): idle = State(initial=True) loading = State() ready = State(final=True) start = idle.to(loading) done_invoke_loading = loading.to(ready) def on_invoke_loading(self, file_name=None, **kwargs): received.append(file_name) return f"loaded:{file_name}" def on_enter_ready(self, data=None, **kwargs): received.append(data) sm = await sm_runner.start(SM) await sm_runner.send(sm, "start", file_name="config.json") await sm_runner.sleep(0.15) await sm_runner.processing_loop(sm) assert "ready" in sm.configuration_values assert received == ["config.json", "loaded:config.json"] async def test_iinvoke_handler_receives_event_kwargs_via_ctx(self, sm_runner): """IInvoke handler receives event kwargs via ctx.kwargs.""" received = [] class FileLoader: def run(self, ctx: InvokeContext): received.append(ctx.kwargs.get("file_name")) return f"loaded:{ctx.kwargs['file_name']}" class SM(StateChart): idle = State(initial=True) loading = State(invoke=FileLoader) ready = State(final=True) start = idle.to(loading) done_invoke_loading = loading.to(ready) def on_enter_ready(self, data=None, **kwargs): received.append(data) sm = await sm_runner.start(SM) await sm_runner.send(sm, "start", file_name="data.csv") await sm_runner.sleep(0.15) await sm_runner.processing_loop(sm) assert "ready" in sm.configuration_values assert received == ["data.csv", "loaded:data.csv"] async def test_initial_state_invoke_has_empty_kwargs(self, sm_runner): """Invoke on initial state gets empty kwargs (no triggering event).""" class SM(StateChart): loading = State(initial=True, invoke=lambda: 42) ready = State(final=True) done_invoke_loading = loading.to(ready) sm = await sm_runner.start(SM) await sm_runner.sleep(0.15) await sm_runner.processing_loop(sm) assert "ready" in sm.configuration_values class TestInvokeNotTriggeredOnNonInvokeState: """States without invoke handlers should not be affected.""" async def test_no_invoke_on_plain_state(self, sm_runner): class SM(StateChart): idle = State(initial=True) active = State() done = State(final=True) go = idle.to(active) finish = active.to(done) sm = await sm_runner.start(SM) await sm_runner.send(sm, "go") assert "active" in sm.configuration_values await sm_runner.send(sm, "finish") assert "done" in sm.configuration_values class TestInvokeManagerCancelAll: """InvokeManager.cancel_all() cancels every active invocation.""" async def test_cancel_all(self, sm_runner): class SlowHandler: def run(self, ctx): ctx.cancelled.wait(timeout=5.0) class SM(StateChart): loading = State(initial=True, invoke=SlowHandler) stopped = State(final=True) cancel = loading.to(stopped) sm = await sm_runner.start(SM) await sm_runner.sleep(0.15) sm._engine._invoke_manager.cancel_all() await sm_runner.sleep(0.15) # All invocations should be terminated for inv in sm._engine._invoke_manager._active.values(): assert inv.terminated class TestInvokeCancelAlreadyTerminated: """Cancelling an already-terminated invocation is a no-op.""" async def test_cancel_terminated_invocation(self, sm_runner): class SM(StateChart): loading = State(initial=True, invoke=lambda: 42) ready = State(final=True) done_invoke_loading = loading.to(ready) sm = await sm_runner.start(SM) await sm_runner.sleep(0.15) await sm_runner.processing_loop(sm) assert "ready" in sm.configuration_values # All invocations should be terminated by now manager = sm._engine._invoke_manager for inv in manager._active.values(): assert inv.terminated # Calling cancel on terminated invocations should be a safe no-op for inv_id in list(manager._active.keys()): manager._cancel(inv_id) class TestInvokeOnCancelException: """Exception in on_cancel() is caught and logged, not propagated.""" async def test_on_cancel_exception_is_suppressed(self, sm_runner): class BadCancelHandler: def run(self, ctx): ctx.cancelled.wait(timeout=5.0) def on_cancel(self): raise RuntimeError("on_cancel exploded") class SM(StateChart): loading = State(initial=True, invoke=BadCancelHandler) stopped = State(final=True) cancel = loading.to(stopped) sm = await sm_runner.start(SM) await sm_runner.sleep(0.15) # This should NOT raise even though on_cancel() raises await sm_runner.send(sm, "cancel") await sm_runner.sleep(0.15) assert "stopped" in sm.configuration_values class TestStateChartInvokerOnCancel: """StateChartInvoker.on_cancel() cleans up the child reference.""" def test_on_cancel_clears_child(self): from statemachine.invoke import StateChartInvoker class ChildMachine(StateChart): start = State(initial=True, final=True) invoker = StateChartInvoker(ChildMachine) ctx = InvokeContext( invokeid="test.123", state_id="test", send=lambda *a, **kw: None, machine=None, ) invoker.run(ctx) assert invoker._child is not None invoker.on_cancel() assert invoker._child is None class TestNormalizeInvokeCallbacks: """normalize_invoke_callbacks handles edge cases.""" def test_string_passes_through(self): from statemachine.invoke import normalize_invoke_callbacks result = normalize_invoke_callbacks("some_method_name") assert result == ["some_method_name"] def test_already_wrapped_passes_through(self): from statemachine.invoke import _InvokeCallableWrapper from statemachine.invoke import normalize_invoke_callbacks class MyHandler: def run(self, ctx): pass wrapper = _InvokeCallableWrapper(MyHandler) result = normalize_invoke_callbacks(wrapper) assert len(result) == 1 assert result[0] is wrapper def test_iinvoke_class_with_run_method(self): """IInvoke-compatible class gets wrapped.""" from statemachine.invoke import _InvokeCallableWrapper from statemachine.invoke import normalize_invoke_callbacks class CustomHandler: def run(self, ctx): return "result" # CustomHandler satisfies IInvoke protocol (has run method) assert isinstance(CustomHandler(), IInvoke) result = normalize_invoke_callbacks(CustomHandler) assert len(result) == 1 assert isinstance(result[0], _InvokeCallableWrapper) def test_plain_callable_passes_through(self): from statemachine.invoke import _InvokeCallableWrapper from statemachine.invoke import normalize_invoke_callbacks def my_func(): return 42 result = normalize_invoke_callbacks(my_func) assert len(result) == 1 assert result[0] is my_func assert not isinstance(result[0], _InvokeCallableWrapper) def test_non_invoke_class_passes_through(self): """A class without run() (not IInvoke, not StateChart) passes through unwrapped.""" from statemachine.invoke import _InvokeCallableWrapper from statemachine.invoke import normalize_invoke_callbacks class PlainClass: pass result = normalize_invoke_callbacks(PlainClass) assert len(result) == 1 assert result[0] is PlainClass assert not isinstance(result[0], _InvokeCallableWrapper) class TestResolveHandler: """InvokeManager._resolve_handler edge cases.""" def test_bare_iinvoke_instance(self): from statemachine.invoke import InvokeManager class MyHandler: def run(self, ctx): return "result" handler = MyHandler() assert isinstance(handler, IInvoke) resolved = InvokeManager._resolve_handler(handler) assert resolved is handler def test_bare_statechart_class(self): from statemachine.invoke import InvokeManager from statemachine.invoke import StateChartInvoker class ChildMachine(StateChart): start = State(initial=True, final=True) resolved = InvokeManager._resolve_handler(ChildMachine) assert isinstance(resolved, StateChartInvoker) def test_plain_callable_returns_none(self): from statemachine.invoke import InvokeManager def my_func(): return 42 assert InvokeManager._resolve_handler(my_func) is None class TestInvokeCallableWrapperOnCancel: """_InvokeCallableWrapper.on_cancel() edge cases.""" def test_on_cancel_non_class_instance_with_on_cancel(self): """Non-class handler (already instantiated) delegates on_cancel.""" from statemachine.invoke import _InvokeCallableWrapper cancel_called = [] class MyHandler: def run(self, ctx): return "result" def on_cancel(self): cancel_called.append(True) handler = MyHandler() wrapper = _InvokeCallableWrapper(handler) # _instance is None, _is_class is False → falls through to _invoke_handler wrapper.on_cancel() assert cancel_called == [True] def test_on_cancel_class_not_yet_instantiated(self): """Class handler not yet instantiated — on_cancel is a no-op.""" from statemachine.invoke import _InvokeCallableWrapper class MyHandler: def run(self, ctx): return "result" def on_cancel(self): raise RuntimeError("should not be called") wrapper = _InvokeCallableWrapper(MyHandler) # _instance is None, _is_class is True → early return wrapper.on_cancel() # should not raise def test_callable_wrapper_call_returns_handler(self): """__call__ returns the original handler (used by callback system for resolution).""" from statemachine.invoke import _InvokeCallableWrapper class MyHandler: def run(self, ctx): return "result" wrapper = _InvokeCallableWrapper(MyHandler) assert wrapper() is MyHandler class TestInvokeGroupOnCancelBeforeRun: """InvokeGroup.on_cancel() before run() is a safe no-op.""" def test_on_cancel_before_run(self): group = invoke_group(lambda: 1) # on_cancel before run — executor is None, no futures group.on_cancel() class TestDoneInvokeEventFactory: """done_invoke_ prefix works with both TransitionList and Event.""" async def test_done_invoke_with_event_object(self, sm_runner): """Event() object with done_invoke_ prefix should match done.invoke events.""" class SM(StateChart): loading = State(initial=True, invoke=lambda: "result") ready = State(final=True) done_invoke_loading = Event(loading.to(ready)) sm = await sm_runner.start(SM) await sm_runner.sleep(0.15) await sm_runner.processing_loop(sm) assert "ready" in sm.configuration_values class TestVisitNoCallbacks: """visit/async_visit with no registered callbacks is a no-op.""" def test_visit_missing_key(self): from statemachine.callbacks import CallbacksRegistry registry = CallbacksRegistry() # Should not raise — just returns registry.visit("nonexistent_key", lambda cb, **kw: None) async def test_async_visit_missing_key(self): from statemachine.callbacks import CallbacksRegistry registry = CallbacksRegistry() await registry.async_visit("nonexistent_key", lambda cb, **kw: None) class TestAsyncVisitAwaitable: """async_visit should await the visitor_fn result when it is awaitable.""" async def test_async_visitor_fn_is_awaited(self): from statemachine.callbacks import CallbackGroup from statemachine.callbacks import CallbacksExecutor from statemachine.callbacks import CallbackSpec visited = [] async def async_visitor(callback, **kwargs): visited.append(str(callback)) executor = CallbacksExecutor() spec = CallbackSpec("dummy", group=CallbackGroup.INVOKE, is_convention=True) executor.add("test_key", spec, lambda: lambda **kw: True) await executor.async_visit(async_visitor) assert visited == ["dummy"] class TestIInvokeProtocolRun: """IInvoke.run() protocol method can be called on a concrete implementation.""" def test_protocol_run_is_callable(self): """Verify that calling run() on a concrete IInvoke instance works.""" class ConcreteInvoker: def run(self, ctx): return "concrete_result" invoker: IInvoke = ConcreteInvoker() result = invoker.run(None) assert result == "concrete_result" class TestSpawnPendingAsyncEmpty: """spawn_pending_async with nothing pending is a no-op.""" async def test_spawn_pending_async_no_pending(self, sm_runner): class SM(StateChart): idle = State(initial=True) active = State(final=True) go = idle.to(active) sm = await sm_runner.start(SM) # Directly call spawn_pending_async with empty pending list await sm._engine._invoke_manager.spawn_pending_async() class TestInvokeAsyncCancelledDuringExecution: """Async handler completes or errors after state was already exited.""" async def test_success_after_cancel(self): """Handler returns successfully but ctx.cancelled is already set.""" from tests.conftest import SMRunner class SM(StateChart): loading = State(initial=True) stopped = State(final=True) cancel = loading.to(stopped) def on_invoke_loading(self, ctx=None, **kwargs): if ctx is None: return # Simulate: cancelled is set during execution but we still return ctx.cancelled.set() return "should_be_ignored" sm_runner = SMRunner(is_async=True) sm = await sm_runner.start(SM) await sm_runner.sleep(0.2) await sm_runner.processing_loop(sm) # The done.invoke event should NOT have been sent (cancelled) assert "loading" in sm.configuration_values async def test_error_after_cancel(self): """Handler raises but ctx.cancelled is already set — error is swallowed.""" from tests.conftest import SMRunner class SM(StateChart): loading = State(initial=True) error_state = State(final=True) error_execution = loading.to(error_state) def on_invoke_loading(self, ctx=None, **kwargs): if ctx is None: return # Simulate: cancelled during execution, then error ctx.cancelled.set() raise ValueError("should be ignored") sm_runner = SMRunner(is_async=True) sm = await sm_runner.start(SM) await sm_runner.sleep(0.2) await sm_runner.processing_loop(sm) # The error.execution event should NOT have been sent (cancelled) assert "loading" in sm.configuration_values class TestSyncInvokeErrorAfterCancel: """Sync handler errors after state was already exited.""" async def test_sync_error_after_cancel(self): """Sync handler raises but ctx.cancelled is set — error.execution not sent.""" from tests.conftest import SMRunner class SM(StateChart): loading = State(initial=True) error_state = State(final=True) error_execution = loading.to(error_state) def on_invoke_loading(self, ctx=None, **kwargs): if ctx is None: return ctx.cancelled.set() raise ValueError("should be ignored") sm_runner = SMRunner(is_async=False) sm = await sm_runner.start(SM) await sm_runner.sleep(0.2) await sm_runner.processing_loop(sm) assert "loading" in sm.configuration_values class TestInvokeManagerUnit: """Unit tests for InvokeManager methods not exercised by integration tests.""" def test_send_to_child_not_found(self): """send_to_child returns False when invokeid is not in _active.""" from unittest.mock import Mock from statemachine.invoke import InvokeManager engine = Mock() manager = InvokeManager(engine) assert manager.send_to_child("nonexistent", "event") is False def test_send_to_child_handler_without_on_event(self): """send_to_child returns False when handler has no on_event.""" from unittest.mock import Mock from statemachine.invoke import Invocation from statemachine.invoke import InvokeContext from statemachine.invoke import InvokeManager engine = Mock() manager = InvokeManager(engine) handler = Mock(spec=[]) # no on_event ctx = InvokeContext(invokeid="test_id", state_id="s1", send=Mock(), machine=Mock()) inv = Invocation(invokeid="test_id", state_id="s1", ctx=ctx, _handler=handler) manager._active["test_id"] = inv assert manager.send_to_child("test_id", "event") is False def test_handle_external_event_none_event(self): """handle_external_event returns early when event is None.""" from unittest.mock import Mock from statemachine.invoke import InvokeManager engine = Mock() manager = InvokeManager(engine) trigger_data = Mock(event=None) # Should not raise manager.handle_external_event(trigger_data) class TestStopChildMachine: """Tests for _stop_child_machine.""" def test_stop_child_machine_exception_swallowed(self): """_stop_child_machine swallows exceptions during stop.""" from unittest.mock import Mock from statemachine.invoke import _stop_child_machine child = Mock() child._engine.running = True child._engine._invoke_manager.cancel_all.side_effect = RuntimeError("boom") # Should not raise _stop_child_machine(child) class TestEngineDelCleanup: """Test BaseEngine.__del__ cancel_all exception handling.""" def test_del_swallows_cancel_all_exception(self): """__del__ swallows exceptions from cancel_all.""" class SM(StateChart): s1 = State(initial=True, final=True) sm = SM() engine = sm._engine engine._invoke_manager.cancel_all = lambda: (_ for _ in ()).throw(RuntimeError("boom")) # Should not raise engine.__del__() ================================================ FILE: tests/test_io.py ================================================ """Tests for statemachine.io module (dictionary-based state machine definitions).""" from statemachine.io import _parse_history from statemachine.io import create_machine_class_from_definition class TestParseHistory: def test_history_without_transitions(self): """History state with no 'on' or 'transitions' keys.""" states_instances, events_definitions = _parse_history({"h1": {"type": "shallow"}}) assert "h1" in states_instances assert states_instances["h1"].type.value == "shallow" assert events_definitions == {} def test_history_with_on_only(self): """History state with 'on' events but no 'transitions' key.""" states_instances, events_definitions = _parse_history( {"h1": {"type": "deep", "on": {"restore": [{"target": "s1"}]}}} ) assert "h1" in states_instances assert "h1" in events_definitions assert "restore" in events_definitions["h1"] class TestCreateMachineWithEventNameConcat: def test_transition_with_both_parent_and_own_event_name(self): """Transition inside 'on' dict that also has its own 'event' key concatenates names.""" sm_cls = create_machine_class_from_definition( "TestMachine", states={ "s1": { "initial": True, "on": { "parent_evt": [ {"target": "s2", "event": "sub_evt"}, ], }, }, "s2": {"final": True}, }, ) sm = sm_cls() # The concatenated event name "parent_evt sub_evt" gets split into two events event_ids = sorted(e.id for e in sm.events) assert "parent_evt" in event_ids assert "sub_evt" in event_ids ================================================ FILE: tests/test_listener.py ================================================ from statemachine.state import State from statemachine.statemachine import StateChart EXPECTED_LOG_ADD = """Frodo on: draft--(add_job)-->draft Frodo enter: draft from add_job Frodo on: draft--(produce)-->producing Frodo enter: producing from produce """ EXPECTED_LOG_CREATION = """Frodo enter: draft from __initial__ Frodo on: draft--(add_job)-->draft Frodo enter: draft from add_job Frodo on: draft--(produce)-->producing Frodo enter: producing from produce """ class TestObserver: def test_add_log_observer(self, campaign_machine, capsys): class LogObserver: def __init__(self, name): self.name = name def on_transition(self, event, state, target): print(f"{self.name} on: {state.id}--({event})-->{target.id}") def on_enter_state(self, target, event): print(f"{self.name} enter: {target.id} from {event}") sm = campaign_machine() sm.add_listener(LogObserver("Frodo")) sm.add_job() sm.produce() captured = capsys.readouterr() assert captured.out == EXPECTED_LOG_ADD def test_log_observer_on_creation(self, campaign_machine, capsys): class LogObserver: def __init__(self, name): self.name = name def on_transition(self, event, state, target): print(f"{self.name} on: {state.id}--({event})-->{target.id}") def on_enter_state(self, target, event): print(f"{self.name} enter: {target.id} from {event}") sm = campaign_machine(listeners=[LogObserver("Frodo")]) sm.add_job() sm.produce() captured = capsys.readouterr() assert captured.out == EXPECTED_LOG_CREATION def test_regression_456(): class TestListener: def __init__(self): pass class MyMachine(StateChart): first = State("FIRST", initial=True) second = State("SECOND") first_selected = second.to(first) second_selected = first.to(second) @first.exit def exit_first(self) -> None: print("exit SLEEPING") m = MyMachine() m.add_listener(TestListener()) m.send("second_selected") ================================================ FILE: tests/test_mermaid_renderer.py ================================================ from statemachine.contrib.diagram import MermaidGraphMachine from statemachine.contrib.diagram.model import ActionType from statemachine.contrib.diagram.model import DiagramAction from statemachine.contrib.diagram.model import DiagramGraph from statemachine.contrib.diagram.model import DiagramState from statemachine.contrib.diagram.model import DiagramTransition from statemachine.contrib.diagram.model import StateType from statemachine.contrib.diagram.renderers.mermaid import MermaidRenderer from statemachine.contrib.diagram.renderers.mermaid import MermaidRendererConfig from statemachine import State from statemachine import StateChart class TestMermaidRendererSimple: """Basic MermaidRenderer tests with simple states.""" def test_simple_states(self): graph = DiagramGraph( name="Simple", states=[ DiagramState(id="s1", name="S1", type=StateType.REGULAR, is_initial=True), DiagramState(id="s2", name="S2", type=StateType.REGULAR), ], transitions=[ DiagramTransition(source="s1", targets=["s2"], event="go"), ], ) result = MermaidRenderer().render(graph) assert "stateDiagram-v2" in result assert "direction LR" in result assert "[*] --> s1" in result assert "s1 --> s2 : go" in result def test_initial_and_final(self): graph = DiagramGraph( name="InitFinal", states=[ DiagramState(id="s1", name="S1", type=StateType.REGULAR, is_initial=True), DiagramState(id="s2", name="S2", type=StateType.FINAL), ], transitions=[ DiagramTransition(source="s1", targets=["s2"], event="finish"), ], ) result = MermaidRenderer().render(graph) assert "[*] --> s1" in result assert "s2 --> [*]" in result def test_custom_direction(self): config = MermaidRendererConfig(direction="TB") graph = DiagramGraph( name="TB", states=[DiagramState(id="a", name="A", type=StateType.REGULAR, is_initial=True)], ) result = MermaidRenderer(config=config).render(graph) assert "direction TB" in result def test_state_name_differs_from_id(self): graph = DiagramGraph( name="Named", states=[ DiagramState( id="my_state", name="My State", type=StateType.REGULAR, is_initial=True ), ], ) result = MermaidRenderer().render(graph) assert 'state "My State" as my_state' in result def test_state_name_equals_id_no_declaration(self): """When name == id, no explicit state declaration is emitted.""" graph = DiagramGraph( name="NoDecl", states=[ DiagramState(id="s1", name="s1", type=StateType.REGULAR, is_initial=True), ], ) result = MermaidRenderer().render(graph) assert 'state "s1"' not in result class TestMermaidRendererTransitions: """Transition rendering tests.""" def test_transition_with_guards(self): graph = DiagramGraph( name="Guards", states=[ DiagramState(id="s1", name="S1", type=StateType.REGULAR, is_initial=True), DiagramState(id="s2", name="S2", type=StateType.REGULAR), ], transitions=[ DiagramTransition(source="s1", targets=["s2"], event="go", guards=["is_ready"]), ], ) result = MermaidRenderer().render(graph) assert "s1 --> s2 : go [is_ready]" in result def test_eventless_transition(self): graph = DiagramGraph( name="Eventless", states=[ DiagramState(id="s1", name="S1", type=StateType.REGULAR, is_initial=True), DiagramState(id="s2", name="S2", type=StateType.REGULAR), ], transitions=[ DiagramTransition(source="s1", targets=["s2"], event=""), ], ) result = MermaidRenderer().render(graph) assert "s1 --> s2\n" in result def test_self_transition(self): graph = DiagramGraph( name="SelfLoop", states=[ DiagramState(id="s1", name="S1", type=StateType.REGULAR, is_initial=True), ], transitions=[ DiagramTransition(source="s1", targets=["s1"], event="tick"), ], ) result = MermaidRenderer().render(graph) assert "s1 --> s1 : tick" in result def test_targetless_transition(self): graph = DiagramGraph( name="Targetless", states=[ DiagramState(id="s1", name="S1", type=StateType.REGULAR, is_initial=True), ], transitions=[ DiagramTransition(source="s1", targets=[], event="tick"), ], ) result = MermaidRenderer().render(graph) assert "s1 --> s1 : tick" in result def test_multi_target_transition(self): graph = DiagramGraph( name="Multi", states=[ DiagramState(id="s1", name="S1", type=StateType.REGULAR, is_initial=True), DiagramState(id="s2", name="S2", type=StateType.REGULAR), DiagramState(id="s3", name="S3", type=StateType.REGULAR), ], transitions=[ DiagramTransition(source="s1", targets=["s2", "s3"], event="split"), ], ) result = MermaidRenderer().render(graph) assert "s1 --> s2 : split" in result assert "s1 --> s3 : split" in result def test_internal_transitions_skipped(self): graph = DiagramGraph( name="Internal", states=[ DiagramState(id="s1", name="S1", type=StateType.REGULAR, is_initial=True), ], transitions=[ DiagramTransition(source="s1", targets=["s1"], event="check", is_internal=True), ], ) result = MermaidRenderer().render(graph) assert "s1 --> s1" not in result def test_initial_transitions_skipped(self): graph = DiagramGraph( name="InitTrans", states=[ DiagramState(id="s1", name="S1", type=StateType.REGULAR, is_initial=True), DiagramState(id="s2", name="S2", type=StateType.REGULAR), ], transitions=[ DiagramTransition(source="s1", targets=["s2"], event="", is_initial=True), ], ) result = MermaidRenderer().render(graph) # Implicit initial transitions are NOT rendered as edges assert "s1 --> s2" not in result class TestMermaidRendererActiveState: """Active state highlighting tests.""" def test_active_state_class(self): graph = DiagramGraph( name="Active", states=[ DiagramState( id="s1", name="S1", type=StateType.REGULAR, is_initial=True, is_active=True ), DiagramState(id="s2", name="S2", type=StateType.REGULAR), ], transitions=[ DiagramTransition(source="s1", targets=["s2"], event="go"), ], ) result = MermaidRenderer().render(graph) assert "classDef active" in result assert "s1:::active" in result assert "s2:::active" not in result def test_no_active_state_no_classdef(self): graph = DiagramGraph( name="NoActive", states=[ DiagramState(id="s1", name="S1", type=StateType.REGULAR, is_initial=True), ], ) result = MermaidRenderer().render(graph) assert "classDef" not in result def test_active_fill_config(self): config = MermaidRendererConfig(active_fill="#FF0000", active_stroke="#000") graph = DiagramGraph( name="CustomActive", states=[ DiagramState( id="s1", name="S1", type=StateType.REGULAR, is_initial=True, is_active=True ), ], ) result = MermaidRenderer(config=config).render(graph) assert "fill:#FF0000" in result assert "stroke:#000" in result class TestMermaidRendererCompound: """Compound and parallel state tests.""" def test_compound_state(self): class SM(StateChart): class parent(State.Compound, name="Parent"): child1 = State(initial=True) child2 = State(final=True) go = child1.to(child2) start = State(initial=True) end = State(final=True) enter = start.to(parent) finish = parent.to(end) result = MermaidGraphMachine(SM).get_mermaid() assert 'state "Parent" as parent {' in result assert "[*] --> child1" in result assert "child1 --> child2 : Go" in result assert "child2 --> [*]" in result assert "start --> parent : Enter" in result assert "parent --> end : Finish" in result def test_compound_no_duplicate_transitions(self): """Transitions inside compound states must not also appear at top level.""" class SM(StateChart): class parent(State.Compound, name="Parent"): child1 = State(initial=True) child2 = State(final=True) go = child1.to(child2) start = State(initial=True) enter = start.to(parent) result = MermaidGraphMachine(SM).get_mermaid() # "child1 --> child2 : Go" should appear exactly once (inside compound) assert result.count("child1 --> child2 : Go") == 1 def test_parallel_state(self): class SM(StateChart): class p(State.Parallel, name="Parallel"): class r1(State.Compound, name="Region1"): a = State(initial=True) a_done = State(final=True) finish_a = a.to(a_done) class r2(State.Compound, name="Region2"): b = State(initial=True) b_done = State(final=True) finish_b = b.to(b_done) start = State(initial=True) begin = start.to(p) result = MermaidGraphMachine(SM).get_mermaid() assert 'state "Parallel" as p {' in result assert "--" in result # parallel separator def test_parallel_redirects_compound_endpoints(self): """Transitions to/from compound states inside parallel regions are redirected to the initial child (Mermaid workaround for mermaid-js/mermaid#4052).""" class SM(StateChart): class p(State.Parallel, name="Parallel"): class region1(State.Compound, name="Region1"): idle = State(initial=True) class inner(State.Compound, name="Inner"): working = State(initial=True) start = idle.to(inner) class region2(State.Compound, name="Region2"): x = State(initial=True) begin = State(initial=True) enter = begin.to(p) result = MermaidGraphMachine(SM).get_mermaid() # Inside parallel: compound endpoint redirected to initial child assert "idle --> working : Start" in result assert "idle --> inner" not in result def test_compound_outside_parallel_not_redirected(self): """Compound states outside parallel regions keep direct transitions.""" class SM(StateChart): class parent(State.Compound, name="Parent"): child = State(initial=True) start = State(initial=True) end = State(final=True) enter = start.to(parent) leave = parent.to(end) result = MermaidGraphMachine(SM).get_mermaid() assert "start --> parent : Enter" in result assert "parent --> end : Leave" in result def test_nested_compound(self): class SM(StateChart): class outer(State.Compound, name="Outer"): class inner(State.Compound, name="Inner"): deep = State(initial=True) deep_final = State(final=True) go_deep = deep.to(deep_final) start_inner = State(initial=True) to_inner = start_inner.to(inner) begin = State(initial=True) enter = begin.to(outer) result = MermaidGraphMachine(SM).get_mermaid() assert 'state "Outer" as outer {' in result assert 'state "Inner" as inner {' in result class TestMermaidRendererPseudoStates: """Pseudo-state rendering tests.""" def test_history_shallow(self): graph = DiagramGraph( name="History", states=[ DiagramState( id="comp", name="Comp", type=StateType.REGULAR, is_initial=True, children=[ DiagramState(id="h", name="H", type=StateType.HISTORY_SHALLOW), DiagramState(id="c1", name="C1", type=StateType.REGULAR, is_initial=True), ], ), ], compound_state_ids={"comp"}, ) result = MermaidRenderer().render(graph) assert 'state "H" as h' in result def test_history_deep(self): graph = DiagramGraph( name="DeepHistory", states=[ DiagramState( id="comp", name="Comp", type=StateType.REGULAR, is_initial=True, children=[ DiagramState(id="h", name="H*", type=StateType.HISTORY_DEEP), DiagramState(id="c1", name="C1", type=StateType.REGULAR, is_initial=True), ], ), ], compound_state_ids={"comp"}, ) result = MermaidRenderer().render(graph) assert 'state "H*" as h' in result def test_choice_state(self): graph = DiagramGraph( name="Choice", states=[ DiagramState(id="ch", name="ch", type=StateType.CHOICE, is_initial=True), ], ) result = MermaidRenderer().render(graph) assert "state ch <>" in result def test_fork_state(self): graph = DiagramGraph( name="Fork", states=[ DiagramState(id="fk", name="fk", type=StateType.FORK, is_initial=True), ], ) result = MermaidRenderer().render(graph) assert "state fk <>" in result def test_join_state(self): graph = DiagramGraph( name="Join", states=[ DiagramState(id="jn", name="jn", type=StateType.JOIN, is_initial=True), ], ) result = MermaidRenderer().render(graph) assert "state jn <>" in result class TestMermaidRendererActions: """State action rendering tests.""" def test_entry_exit_actions(self): graph = DiagramGraph( name="Actions", states=[ DiagramState( id="s1", name="S1", type=StateType.REGULAR, is_initial=True, actions=[ DiagramAction(type=ActionType.ENTRY, body="setup"), DiagramAction(type=ActionType.EXIT, body="cleanup"), ], ), ], ) result = MermaidRenderer().render(graph) assert "s1 : entry / setup" in result assert "s1 : exit / cleanup" in result def test_internal_action(self): graph = DiagramGraph( name="InternalAction", states=[ DiagramState( id="s1", name="S1", type=StateType.REGULAR, is_initial=True, actions=[ DiagramAction(type=ActionType.INTERNAL, body="tick / handle"), ], ), ], ) result = MermaidRenderer().render(graph) assert "s1 : tick / handle" in result def test_empty_internal_action_skipped(self): graph = DiagramGraph( name="EmptyInternal", states=[ DiagramState( id="s1", name="S1", type=StateType.REGULAR, is_initial=True, actions=[ DiagramAction(type=ActionType.INTERNAL, body=""), ], ), ], ) result = MermaidRenderer().render(graph) assert "s1 : " not in result class TestMermaidGraphMachine: """Tests for the MermaidGraphMachine facade.""" def test_facade_returns_string(self): from tests.examples.traffic_light_machine import TrafficLightMachine result = MermaidGraphMachine(TrafficLightMachine).get_mermaid() assert isinstance(result, str) assert "stateDiagram-v2" in result def test_facade_callable(self): from tests.examples.traffic_light_machine import TrafficLightMachine facade = MermaidGraphMachine(TrafficLightMachine) assert facade() == facade.get_mermaid() def test_facade_with_instance(self): from tests.examples.traffic_light_machine import TrafficLightMachine sm = TrafficLightMachine() result = MermaidGraphMachine(sm).get_mermaid() assert "green:::active" in result def test_facade_custom_config(self): from tests.examples.traffic_light_machine import TrafficLightMachine class Custom(MermaidGraphMachine): direction = "TB" active_fill = "#FF0000" sm = TrafficLightMachine() result = Custom(sm).get_mermaid() assert "direction TB" in result assert "fill:#FF0000" in result class TestMermaidRendererEdgeCases: """Edge case tests for coverage.""" def test_compound_state_name_equals_id(self): """Compound state where name == id uses unquoted declaration.""" graph = DiagramGraph( name="NameId", states=[ DiagramState( id="comp", name="comp", type=StateType.REGULAR, is_initial=True, children=[ DiagramState(id="c1", name="C1", type=StateType.REGULAR, is_initial=True), ], ), ], compound_state_ids={"comp"}, ) result = MermaidRenderer().render(graph) assert "state comp {" in result assert '"comp"' not in result def test_active_compound_state(self): """Compound state that is active gets classDef.""" graph = DiagramGraph( name="ActiveComp", states=[ DiagramState( id="comp", name="Comp", type=StateType.REGULAR, is_initial=True, is_active=True, children=[ DiagramState(id="c1", name="C1", type=StateType.REGULAR, is_initial=True), ], ), ], compound_state_ids={"comp"}, ) result = MermaidRenderer().render(graph) assert "comp:::active" in result def test_cross_scope_transition_rendered_at_parent(self): """Transition crossing compound boundaries is rendered at the parent scope.""" graph = DiagramGraph( name="CrossScope", states=[ DiagramState( id="comp", name="Comp", type=StateType.REGULAR, is_initial=True, children=[ DiagramState(id="c1", name="C1", type=StateType.REGULAR, is_initial=True), ], ), DiagramState(id="outside", name="Outside", type=StateType.REGULAR), ], transitions=[ DiagramTransition(source="c1", targets=["outside"], event="leave"), ], compound_state_ids={"comp"}, ) result = MermaidRenderer().render(graph) # c1 is inside comp, outside is at top level — the transition # crosses the compound boundary and is rendered at the top scope. assert "c1 --> outside : leave" in result # It should NOT appear inside the compound block lines = result.split("\n") for line in lines: if "c1 --> outside" in line: # Should be at indent level 1 (top scope), not deeper assert line.startswith(" c1"), f"Expected top-level indent, got: {line!r}" def test_cross_scope_to_history_state(self): """Transition from outside a compound to a history state inside it is rendered.""" graph = DiagramGraph( name="HistoryCross", states=[ DiagramState( id="process", name="Process", type=StateType.REGULAR, children=[ DiagramState( id="step1", name="Step1", type=StateType.REGULAR, is_initial=True ), DiagramState(id="step2", name="Step2", type=StateType.REGULAR), DiagramState(id="h", name="H", type=StateType.HISTORY_SHALLOW), ], ), DiagramState(id="paused", name="Paused", type=StateType.REGULAR, is_initial=True), ], transitions=[ DiagramTransition(source="step1", targets=["step2"], event="advance"), DiagramTransition(source="process", targets=["paused"], event="pause"), DiagramTransition(source="paused", targets=["h"], event="resume"), DiagramTransition(source="paused", targets=["process"], event="begin"), ], compound_state_ids={"process"}, ) result = MermaidRenderer().render(graph) # The resume transition crosses the compound boundary assert "paused --> h : resume" in result # advance stays inside the compound assert "step1 --> step2 : advance" in result # pause and begin are at top level (both endpoints are top-level) assert "process --> paused : pause" in result assert "paused --> process : begin" in result def test_no_initial_state(self): """Graph with no initial state omits [*] arrow.""" graph = DiagramGraph( name="NoInitial", states=[ DiagramState(id="s1", name="S1", type=StateType.REGULAR), ], ) result = MermaidRenderer().render(graph) assert "[*]" not in result def test_duplicate_transition_rendered_once(self): """Duplicate transitions in the IR are rendered only once.""" graph = DiagramGraph( name="Dedup", states=[ DiagramState(id="s1", name="S1", type=StateType.REGULAR, is_initial=True), DiagramState(id="s2", name="S2", type=StateType.REGULAR), ], transitions=[ DiagramTransition(source="s1", targets=["s2"], event="go"), DiagramTransition(source="s1", targets=["s2"], event="go"), ], ) result = MermaidRenderer().render(graph) assert result.count("s1 --> s2 : go") == 1 def test_compound_no_initial_child(self): """Compound state with no initial child omits internal [*] arrow.""" graph = DiagramGraph( name="NoInitChild", states=[ DiagramState( id="comp", name="Comp", type=StateType.REGULAR, is_initial=True, children=[ DiagramState(id="c1", name="C1", type=StateType.REGULAR), ], ), ], compound_state_ids={"comp"}, ) result = MermaidRenderer().render(graph) # No [*] --> c1 inside the compound lines = result.strip().split("\n") inner_initial = [ln for ln in lines if "[*] --> c1" in ln] assert len(inner_initial) == 0 class TestMermaidRendererIntegration: """Integration tests with real state machines.""" def test_traffic_light(self): from tests.examples.traffic_light_machine import TrafficLightMachine result = MermaidGraphMachine(TrafficLightMachine).get_mermaid() assert "green --> yellow : Cycle" in result assert "yellow --> red : Cycle" in result assert "red --> green : Cycle" in result def test_traffic_light_with_events(self): from tests.examples.traffic_light_machine import TrafficLightMachine sm = TrafficLightMachine() sm.send("cycle") result = MermaidGraphMachine(sm).get_mermaid() assert "yellow:::active" in result ================================================ FILE: tests/test_mixins.py ================================================ import pytest from statemachine.mixins import MachineMixin from tests.models import MyModel class MyMixedModel(MyModel, MachineMixin): state_machine_name = "tests.machines.workflow.campaign_machine.CampaignMachine" def test_mixin_should_instantiate_a_machine(campaign_machine): model = MyMixedModel(state="draft") assert isinstance(model.statemachine, campaign_machine) assert model.state == "draft" assert model.statemachine.draft.is_active def test_mixin_should_raise_exception_if_machine_class_does_not_exist(): class MyModelWithoutMachineName(MachineMixin): pass with pytest.raises(ValueError, match="None is not a valid state machine name"): MyModelWithoutMachineName() def test_mixin_should_skip_init_for_django_historical_models(): """Regression test for #551: MachineMixin fails in Django data migrations. Django's ``apps.get_model()`` returns historical models with ``__module__ = '__fake__'`` that don't carry user-defined class attributes like ``state_machine_name``. """ # Simulate a Django historical model: __module__ is '__fake__' and # state_machine_name is not set (falls back to None from MachineMixin). HistoricalModel = type("HistoricalModel", (MachineMixin,), {"__module__": "__fake__"}) instance = HistoricalModel() assert not hasattr(instance, "statemachine") ================================================ FILE: tests/test_mock_compatibility.py ================================================ from statemachine import State from statemachine import StateChart def test_minimal(mocker): class Observer: def on_enter_state(self, event, model, source, target, state): ... obs = Observer() on_enter_state = mocker.spy(obs, "on_enter_state") class Machine(StateChart): a = State("Init", initial=True) b = State("Fin") cycle = a.to(b) | b.to(a) state = Machine().add_listener(obs) assert state.a.is_active state.cycle() assert state.b.is_active on_enter_state.assert_called_once() ================================================ FILE: tests/test_multiple_destinations.py ================================================ import pytest from statemachine import State from statemachine import StateChart from statemachine import exceptions class Request: def __init__(self, state="requested"): self.state = None self._is_ok = False def is_ok(self): return self._is_ok def test_transition_should_choose_final_state_on_multiple_possibilities( approval_machine, current_time ): # given model = Request() machine = approval_machine(model) model._is_ok = False # when assert machine.validate() == model # then assert model.rejected_at == current_time assert machine.rejected.is_active # given model._is_ok = True # when assert machine.retry() == model # then assert model.rejected_at is None assert machine.requested.is_active # when assert machine.validate() == model # then assert model.accepted_at == current_time assert machine.accepted.is_active def test_transition_to_first_that_executes_if_multiple_targets(): class ApprovalMachine(StateChart): "A workflow" requested = State(initial=True) accepted = State(final=True) rejected = State(final=True) validate = requested.to(accepted, rejected) machine = ApprovalMachine() machine.validate() assert machine.accepted.is_active def test_do_not_transition_if_multiple_targets_with_guard(): def never_will_pass(event_data): return False class ApprovalMachine(StateChart): "A workflow" allow_event_without_transition = False catch_errors_as_events = False requested = State(initial=True) accepted = State(final=True) rejected = State(final=True) validate = ( requested.to(accepted, cond=never_will_pass) | requested.to(rejected, cond="also_never_will_pass") | requested.to(requested, cond="this_also_never_will_pass") ) @property def also_never_will_pass(self): return False def this_also_never_will_pass(self, event_data): return False machine = ApprovalMachine() with pytest.raises(exceptions.TransitionNotAllowed): machine.validate() assert machine.requested.is_active def test_check_invalid_reference_to_conditions(): class ApprovalMachine(StateChart): "A workflow" catch_errors_as_events = False requested = State(initial=True) accepted = State(final=True) rejected = State(final=True) validate = requested.to(accepted, cond="not_found_condition") | requested.to(rejected) with pytest.raises(exceptions.InvalidDefinition): ApprovalMachine() def test_should_change_to_returned_state_on_multiple_target_with_combined_transitions(): class ApprovalMachine(StateChart): "A workflow" allow_event_without_transition = False catch_errors_as_events = False requested = State(initial=True) accepted = State() rejected = State() completed = State(final=True) validate = ( requested.to(accepted, cond="is_ok") | requested.to(rejected) | accepted.to(completed) ) retry = rejected.to(requested) def on_validate(self, previous_configuration): if self.accepted in previous_configuration and self.model.is_ok(): return "congrats!" # given model = Request() machine = ApprovalMachine(model) model._is_ok = False # when assert machine.validate() is None # then assert machine.rejected.is_active # given assert machine.retry() is None assert machine.requested.is_active model._is_ok = True # when assert machine.validate() is None # then assert machine.accepted.is_active # when assert machine.validate() == "congrats!" # then assert machine.completed.is_active assert machine.is_terminated with pytest.raises(exceptions.TransitionNotAllowed, match="Can't Validate when in Completed."): assert machine.validate() def test_transition_on_execute_should_be_called_with_run_syntax(approval_machine, current_time): # given model = Request() machine = approval_machine(model) model._is_ok = True # when assert machine.send("validate") == model # then assert model.accepted_at == current_time assert machine.accepted.is_active def test_multiple_values_returned_with_multiple_targets(): class ApprovalMachine(StateChart): "A workflow" requested = State(initial=True) accepted = State(final=True) denied = State(final=True) @requested.to(accepted, denied) def validate(self): return 1, 2 machine = ApprovalMachine() assert machine.validate() == ( 1, 2, ) @pytest.mark.parametrize( ("payment_failed", "expected_state"), [ (False, "paid"), (True, "failed"), ], ) def test_multiple_targets_using_or_starting_from_same_origin(payment_failed, expected_state): class InvoiceStateMachine(StateChart): catch_errors_as_events = False unpaid = State(initial=True) paid = State(final=True) failed = State() pay = unpaid.to(paid, unless="payment_success") | failed.to(paid) | unpaid.to(failed) def payment_success(self, event_data): return payment_failed invoice_fsm = InvoiceStateMachine() invoice_fsm.pay() assert invoice_fsm.current_state_value == expected_state def test_order_control(OrderControl): control = OrderControl() assert control.add_to_order(3) == 3 assert control.add_to_order(7) == 10 control.receive_payment(4) with pytest.raises(exceptions.TransitionNotAllowed): control.process_order() control.receive_payment(6) control.process_order() control.ship_order() assert control.order_total == 10 assert control.payments == [4, 6] assert control.completed.is_active ================================================ FILE: tests/test_profiling.py ================================================ import weakref import pytest from statemachine import HistoryState from statemachine import State from statemachine import StateChart # --------------------------------------------------------------------------- # Machines under test # --------------------------------------------------------------------------- # 1. Flat machine with model, guards, and listener callbacks (v1-style) class OrderControl(StateChart): allow_event_without_transition = False catch_errors_as_events = False waiting_for_payment = State(initial=True) processing = State() shipping = State() completed = State(final=True) add_to_order = waiting_for_payment.to(waiting_for_payment) receive_payment = waiting_for_payment.to( processing, cond="payments_enough" ) | waiting_for_payment.to(waiting_for_payment, unless="payments_enough") process_order = processing.to(shipping, cond="payment_received") ship_order = shipping.to(completed) class Order: def __init__(self): self.order_total = 0 self.payments = [] self.payment_received = False self.state_machine = OrderControl(model=weakref.proxy(self)) def payments_enough(self, amount): return sum(self.payments) + amount >= self.order_total def before_add_to_order(self, amount): self.order_total += amount return self.order_total def on_receive_payment(self, amount): self.payments.append(amount) return self.payments def after_receive_payment(self): self.payment_received = True # 2. Compound (nested) states class CompoundSC(StateChart): class active(State.Compound, name="Active"): idle = State(initial=True) working = State() begin = idle.to(working) off = State(initial=True) done = State(final=True) turn_on = off.to(active) turn_off = active.to(done) # 3. Parallel regions class ParallelSC(StateChart): class both(State.Parallel, name="Both"): class left(State.Compound, name="Left"): l1 = State(initial=True) l2 = State() go_l = l1.to(l2) back_l = l2.to(l1) class right(State.Compound, name="Right"): r1 = State(initial=True) r2 = State() go_r = r1.to(r2) back_r = r2.to(r1) start = State(initial=True) enter = start.to(both) # 4. Guards with boolean expressions class GuardedSC(StateChart): s1 = State(initial=True) s2 = State() s3 = State(final=True) def check_a(self): return True def check_b(self): return False go = s1.to(s2, cond="check_a") | s1.to(s3, cond="check_b") back = s2.to(s1) # 5. History states (shallow) class HistoryShallowSC(StateChart): class process(State.Compound, name="Process"): step1 = State(initial=True) step2 = State() advance = step1.to(step2) h = HistoryState() paused = State(initial=True) pause = process.to(paused) resume = paused.to(process.h) begin = paused.to(process) # 6. Deep history with nested compound states class DeepHistorySC(StateChart): class outer(State.Compound, name="Outer"): class inner(State.Compound, name="Inner"): a = State(initial=True) b = State() go = a.to(b) back = b.to(a) start = State(initial=True) enter_inner = start.to(inner) h = HistoryState(type="deep") away = State(initial=True) dive = away.to(outer) leave = outer.to(away) restore = away.to(outer.h) # 7. Many-transition stress machine (wide, not deep) class ManyTransitionsSC(StateChart): s1 = State(initial=True) s2 = State() s3 = State() s4 = State() s5 = State() go_12 = s1.to(s2) go_23 = s2.to(s3) go_34 = s3.to(s4) go_45 = s4.to(s5) go_51 = s5.to(s1) reset = s2.to(s1) | s3.to(s1) | s4.to(s1) | s5.to(s1) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def create_order(): order = Order() assert order.state_machine.waiting_for_payment.is_active def add_to_order(sm, amount): sm.add_to_order(amount) # --------------------------------------------------------------------------- # Benchmark: instance creation # --------------------------------------------------------------------------- @pytest.mark.slow() class TestSetupPerformance: """Benchmark the cost of creating and activating state machine instances.""" def test_flat_machine(self, benchmark): benchmark.pedantic(create_order, rounds=10, iterations=1000) def test_compound_machine(self, benchmark): benchmark.pedantic(lambda: CompoundSC(), rounds=10, iterations=1000) def test_parallel_machine(self, benchmark): benchmark.pedantic(lambda: ParallelSC(), rounds=10, iterations=1000) def test_guarded_machine(self, benchmark): benchmark.pedantic(lambda: GuardedSC(), rounds=10, iterations=1000) def test_history_machine(self, benchmark): benchmark.pedantic(lambda: HistoryShallowSC(), rounds=10, iterations=1000) def test_deep_history_machine(self, benchmark): benchmark.pedantic(lambda: DeepHistorySC(), rounds=10, iterations=1000) # --------------------------------------------------------------------------- # Benchmark: event throughput # --------------------------------------------------------------------------- @pytest.mark.slow() class TestEventPerformance: """Benchmark event processing (self-transitions and state changes).""" def test_flat_self_transition(self, benchmark): """Self-transition on a flat machine with model/listener.""" order = Order() sm = order.state_machine benchmark.pedantic(add_to_order, args=(sm, 1), rounds=10, iterations=1000) def test_compound_enter_exit(self, benchmark): """Enter and exit a compound state repeatedly.""" def cycle(): sm = CompoundSC() sm.turn_on() sm.begin() sm.turn_off() benchmark.pedantic(cycle, rounds=10, iterations=500) def test_parallel_region_events(self, benchmark): """Send events within parallel regions.""" sm = ParallelSC() sm.enter() def cycle(): sm.go_l() sm.go_r() sm.back_l() sm.back_r() benchmark.pedantic(cycle, rounds=10, iterations=500) def test_guarded_transitions(self, benchmark): """Guard evaluation + transition selection.""" sm = GuardedSC() def cycle(): sm.go() sm.back() benchmark.pedantic(cycle, rounds=10, iterations=1000) def test_history_pause_resume(self, benchmark): """Shallow history: pause and resume compound state.""" sm = HistoryShallowSC() sm.begin() sm.advance() def cycle(): sm.pause() sm.resume() benchmark.pedantic(cycle, rounds=10, iterations=500) def test_deep_history_cycle(self, benchmark): """Deep history: leave and restore nested compound state.""" sm = DeepHistorySC() sm.dive() sm.enter_inner() sm.go() def cycle(): sm.leave() sm.restore() benchmark.pedantic(cycle, rounds=10, iterations=500) def test_many_transitions_full_cycle(self, benchmark): """Traverse a 5-state ring (s1→s2→s3→s4→s5→s1).""" sm = ManyTransitionsSC() def cycle(): sm.go_12() sm.go_23() sm.go_34() sm.go_45() sm.go_51() benchmark.pedantic(cycle, rounds=10, iterations=500) def test_many_transitions_reset(self, benchmark): """Composite event (|) selecting among multiple source states.""" sm = ManyTransitionsSC() def cycle(): sm.go_12() sm.go_23() sm.go_34() sm.reset() benchmark.pedantic(cycle, rounds=10, iterations=500) ================================================ FILE: tests/test_registry.py ================================================ from unittest import mock import pytest @pytest.fixture() def django_autodiscover_modules(): auto_discover_modules = mock.MagicMock() with mock.patch("statemachine.registry.autodiscover_modules", new=auto_discover_modules): yield auto_discover_modules def test_should_register_a_state_machine(caplog, django_autodiscover_modules): from statemachine import State from statemachine import StateMachine from statemachine import registry class CampaignMachine(StateMachine): "A workflow machine" draft = State(initial=True) producing = State() add_job = draft.to(draft) | producing.to(producing) produce = draft.to(producing) assert registry.get_machine_cls("tests.test_registry.CampaignMachine") == CampaignMachine assert "CampaignMachine" not in registry._REGISTRY def test_load_modules_should_call_autodiscover_modules(django_autodiscover_modules): from statemachine.registry import load_modules # given modules = ["a", "c", "statemachine", "statemachines"] # when load_modules(modules) # then django_autodiscover_modules.assert_has_calls(mock.call(m) for m in modules) ================================================ FILE: tests/test_rtc.py ================================================ import inspect from unittest import mock import pytest from statemachine import State from statemachine import StateChart @pytest.fixture() def chained_after_sm_class(): # noqa: C901 class ChainedSM(StateChart): a = State(initial=True) b = State() c = State(final=True) t1 = a.to(b, after="t1") | b.to(c) def __init__(self, *args, **kwargs): self.spy = mock.Mock(side_effect=lambda x, **kwargs: x) super().__init__(*args, **kwargs) def before_t1(self, source: State, value: int = 0): return self.spy("before_t1", source=source.id, value=value) def on_t1(self, source: State, value: int = 0): return self.spy("on_t1", source=source.id, value=value) def after_t1(self, source: State, value: int = 0): return self.spy("after_t1", source=source.id, value=value) def on_enter_state(self, state: State, source: State, value: int = 0): return self.spy( "on_enter_state", state=state.id, source=getattr(source, "id", None), value=value, ) def on_exit_state(self, state: State, source: State, value: int = 0): return self.spy("on_exit_state", state=state.id, source=source.id, value=value) return ChainedSM @pytest.fixture() def chained_on_sm_class(): # noqa: C901 class ChainedSM(StateChart): s1 = State(initial=True) s2 = State() s3 = State() s4 = State(final=True) t1 = s1.to(s2) t2a = s2.to(s2) t2b = s2.to(s3) t3 = s3.to(s4) def __init__(self): self.spy = mock.Mock() super().__init__() def on_t1(self): return [self.t2a(), self.t2b(), self.send("t3")] def on_enter_state(self, event: str, state: State, source: State): self.spy( "on_enter_state", event=event, state=state.id, source=getattr(source, "id", ""), ) def on_exit_state(self, event: str, state: State, target: State): self.spy("on_exit_state", event=event, state=state.id, target=target.id) def on_transition(self, event: str, source: State, target: State): self.spy("on_transition", event=event, source=source.id, target=target.id) return event def after_transition(self, event: str, source: State, target: State): self.spy("after_transition", event=event, source=source.id, target=target.id) return ChainedSM class TestChainedTransition: @pytest.mark.parametrize( ("expected_calls"), [ [ mock.call("on_enter_state", state="a", source="", value=0), mock.call("before_t1", source="a", value=42), mock.call("on_exit_state", state="a", source="a", value=42), mock.call("on_t1", source="a", value=42), mock.call("on_enter_state", state="b", source="a", value=42), mock.call("after_t1", source="a", value=42), mock.call("before_t1", source="b", value=42), mock.call("on_exit_state", state="b", source="b", value=42), mock.call("on_t1", source="b", value=42), mock.call("on_enter_state", state="c", source="b", value=42), mock.call("after_t1", source="b", value=42), ], ], ) def test_should_allow_chaining_transitions_using_actions( self, chained_after_sm_class, expected_calls ): sm = chained_after_sm_class() sm.t1(value=42) assert sm.c.is_active assert sm.spy.call_args_list == expected_calls @pytest.mark.parametrize( ("expected"), [ [ mock.call("on_enter_state", event="__initial__", state="s1", source=""), mock.call("on_exit_state", event="t1", state="s1", target="s2"), mock.call("on_transition", event="t1", source="s1", target="s2"), mock.call("on_enter_state", event="t1", state="s2", source="s1"), mock.call("after_transition", event="t1", source="s1", target="s2"), mock.call("on_exit_state", event="t2a", state="s2", target="s2"), mock.call("on_transition", event="t2a", source="s2", target="s2"), mock.call("on_enter_state", event="t2a", state="s2", source="s2"), mock.call("after_transition", event="t2a", source="s2", target="s2"), mock.call("on_exit_state", event="t2b", state="s2", target="s3"), mock.call("on_transition", event="t2b", source="s2", target="s3"), mock.call("on_enter_state", event="t2b", state="s3", source="s2"), mock.call("after_transition", event="t2b", source="s2", target="s3"), mock.call("on_exit_state", event="t3", state="s3", target="s4"), mock.call("on_transition", event="t3", source="s3", target="s4"), mock.call("on_enter_state", event="t3", state="s4", source="s3"), mock.call("after_transition", event="t3", source="s3", target="s4"), ], ], ) def test_should_preserve_event_order(self, chained_on_sm_class, expected): sm = chained_on_sm_class() if inspect.isclass(expected) and issubclass(expected, Exception): with pytest.raises(expected): sm.send("t1") else: assert sm.send("t1") == ["t1", [None, None, None]] assert sm.spy.call_args_list == expected class TestAsyncEngineRTC: @pytest.mark.parametrize( ("expected"), [ [ mock.call("on_enter_state", event="__initial__", state="s1", source=""), mock.call("on_exit_state", event="t1", state="s1", target="s2"), mock.call("on_transition", event="t1", source="s1", target="s2"), mock.call("on_enter_state", event="t1", state="s2", source="s1"), mock.call("after_transition", event="t1", source="s1", target="s2"), mock.call("on_exit_state", event="t2a", state="s2", target="s2"), mock.call("on_transition", event="t2a", source="s2", target="s2"), mock.call("on_enter_state", event="t2a", state="s2", source="s2"), mock.call("after_transition", event="t2a", source="s2", target="s2"), mock.call("on_exit_state", event="t2b", state="s2", target="s3"), mock.call("on_transition", event="t2b", source="s2", target="s3"), mock.call("on_enter_state", event="t2b", state="s3", source="s2"), mock.call("after_transition", event="t2b", source="s2", target="s3"), mock.call("on_exit_state", event="t3", state="s3", target="s4"), mock.call("on_transition", event="t3", source="s3", target="s4"), mock.call("on_enter_state", event="t3", state="s4", source="s3"), mock.call("after_transition", event="t3", source="s3", target="s4"), ], ], ) def test_should_preserve_event_order(self, expected): # noqa: C901 class ChainedSM(StateChart): s1 = State(initial=True) s2 = State() s3 = State() s4 = State(final=True) t1 = s1.to(s2) t2a = s2.to(s2) t2b = s2.to(s3) t3 = s3.to(s4) def __init__(self): self.spy = mock.Mock() super().__init__() async def on_t1(self): return [await self.t2a(), await self.t2b(), await self.send("t3")] async def on_enter_state(self, event: str, state: State, source: State): self.spy( "on_enter_state", event=event, state=state.id, source=getattr(source, "id", ""), ) async def on_exit_state(self, event: str, state: State, target: State): self.spy("on_exit_state", event=event, state=state.id, target=target.id) async def on_transition(self, event: str, source: State, target: State): self.spy("on_transition", event=event, source=source.id, target=target.id) return event async def after_transition(self, event: str, source: State, target: State): self.spy("after_transition", event=event, source=source.id, target=target.id) sm = ChainedSM() assert sm.send("t1") == ["t1", [None, None, None]] assert sm.spy.call_args_list == expected ================================================ FILE: tests/test_scxml_units.py ================================================ """Unit tests for SCXML parser, actions, and schema modules.""" import logging import xml.etree.ElementTree as ET from unittest.mock import Mock import pytest from statemachine.io.scxml.actions import EventDataWrapper from statemachine.io.scxml.actions import Log from statemachine.io.scxml.actions import ParseTime from statemachine.io.scxml.actions import create_action_callable from statemachine.io.scxml.actions import create_datamodel_action_callable from statemachine.io.scxml.invoke import SCXMLInvoker from statemachine.io.scxml.parser import parse_element from statemachine.io.scxml.parser import parse_scxml from statemachine.io.scxml.parser import strip_namespaces from statemachine.io.scxml.schema import CancelAction from statemachine.io.scxml.schema import DataModel from statemachine.io.scxml.schema import IfBranch from statemachine.io.scxml.schema import InvokeDefinition from statemachine.io.scxml.schema import LogAction from statemachine.io.scxml.schema import Param # --- ParseTime --- class TestParseTimeErrors: def test_invalid_milliseconds_value(self): """ParseTime raises ValueError for non-numeric milliseconds.""" with pytest.raises(ValueError, match="Invalid time value"): ParseTime.time_in_ms("abcms") def test_invalid_seconds_value(self): """ParseTime raises ValueError for non-numeric seconds.""" with pytest.raises(ValueError, match="Invalid time value"): ParseTime.time_in_ms("abcs") def test_invalid_unit(self): """ParseTime raises ValueError for values without recognized unit.""" with pytest.raises(ValueError, match="Invalid time unit"): ParseTime.time_in_ms("abc") # --- Parser --- class TestStripNamespaces: def test_removes_namespace_from_attributes(self): """strip_namespaces removes namespace prefixes from attribute names.""" xml = '' tree = ET.fromstring(xml) strip_namespaces(tree) child = tree.find("child") assert "attr" in child.attrib assert child.attrib["attr"] == "value" class TestParseScxml: def test_no_scxml_element_raises(self): """parse_scxml raises ValueError if no scxml element is found.""" xml = "" with pytest.raises(ValueError, match="No scxml element found"): parse_scxml(xml) class TestParseState: def test_state_without_id_gets_auto_generated(self): """State element without id attribute gets an auto-generated id.""" xml = '' definition = parse_scxml(xml) state_ids = list(definition.states.keys()) assert len(state_ids) == 1 assert state_ids[0].startswith("__auto_") class TestParseHistory: def test_history_without_id_raises(self): """History element without id attribute raises ValueError.""" xml = ( '' '' "" ) with pytest.raises(ValueError, match="History must have an 'id' attribute"): parse_scxml(xml) class TestParseElement: def test_unknown_tag_raises(self): """parse_element raises ValueError for an unrecognized tag.""" element = ET.fromstring("") with pytest.raises(ValueError, match="Unknown tag: unknown_tag"): parse_element(element) class TestParseSendParam: def test_param_without_expr_or_location_raises(self): """Send param without expr or location raises ValueError.""" xml = ( '' '' "" '' "" "" "" ) with pytest.raises(ValueError, match="Must specify"): parse_scxml(xml) # --- Actions --- class TestCreateActionCallable: def test_unknown_action_type_raises(self): """create_action_callable raises ValueError for unknown action types.""" from statemachine.io.scxml.schema import Action with pytest.raises(ValueError, match="Unknown action type"): create_action_callable(Action()) class TestLogAction: def test_log_without_label(self, capsys): """Log action without label prints just the value.""" action = LogAction(label=None, expr="42") log = Log(action) log() # "42" is a literal that evaluates without machine context captured = capsys.readouterr() assert "42" in captured.out class TestCancelActionCallable: def test_cancel_without_sendid_raises(self): """CancelAction without sendid or sendidexpr raises ValueError.""" from statemachine.io.scxml.actions import create_cancel_action_callable action = CancelAction(sendid=None, sendidexpr=None) cancel = create_cancel_action_callable(action) with pytest.raises(ValueError, match="must have either 'sendid' or 'sendidexpr'"): cancel(machine=None) class TestCreateDatamodelCallable: def test_empty_datamodel_returns_none(self): """create_datamodel_action_callable returns None for empty DataModel.""" model = DataModel(data=[], scripts=[]) result = create_datamodel_action_callable(model) assert result is None # --- Schema --- class TestIfBranch: def test_str_with_none_cond(self): """IfBranch.__str__ returns '' for None condition.""" branch = IfBranch(cond=None) assert str(branch) == "" def test_str_with_cond(self): """IfBranch.__str__ returns the condition string.""" branch = IfBranch(cond="x > 0") assert str(branch) == "x > 0" # --- SCXML integration tests for action edge cases --- class TestSCXMLIfConditionError: """SCXML with a condition that raises an error.""" def test_if_condition_error_sends_error_execution(self): """When an condition evaluation fails, error.execution is sent.""" from statemachine.io.scxml.processor import SCXMLProcessor scxml = """ """ processor = SCXMLProcessor() processor.parse_scxml("test_if_error", scxml) sm = processor.start() assert sm.configuration == {sm.states_map["error"]} class TestSCXMLForeachArrayError: """SCXML with an array expression that fails to evaluate.""" def test_foreach_bad_array_raises(self): """ with invalid array expression raises ValueError.""" from statemachine.io.scxml.processor import SCXMLProcessor scxml = """ """ processor = SCXMLProcessor() processor.parse_scxml("test_foreach_error", scxml) sm = processor.start() # The foreach array eval raises, which gets caught by catch_errors_as_events assert sm.configuration == {sm.states_map["error"]} class TestSCXMLParallelFinalState: """Test done.state detection when all regions of a parallel state complete.""" def test_parallel_state_done_when_all_regions_final(self): """done.state fires when all regions of a parallel state are in final states.""" from statemachine.io.scxml.processor import SCXMLProcessor scxml = """ """ processor = SCXMLProcessor() processor.parse_scxml("test_parallel_final", scxml) sm = processor.start() # Both regions auto-transition to final states, done.state.p1 fires assert sm.states_map["done"] in sm.configuration class TestEventDataWrapperMultipleArgs: """EventDataWrapper.data returns tuple when trigger_data has multiple args.""" def test_data_returns_tuple_for_multiple_args(self): """EventDataWrapper.data returns the args tuple when more than one positional arg.""" from unittest.mock import Mock from statemachine.io.scxml.actions import EventDataWrapper trigger_data = Mock() trigger_data.kwargs = {} trigger_data.args = (1, 2, 3) trigger_data.event = Mock(internal=True) trigger_data.event.__str__ = lambda self: "test" trigger_data.send_id = None event_data = Mock() event_data.trigger_data = trigger_data wrapper = EventDataWrapper(event_data) assert wrapper.data == (1, 2, 3) class TestIfActionRaisesWithoutErrorOnExecution: """SCXML condition error raises when catch_errors_as_events is False.""" def test_if_condition_error_propagates_without_catch_errors_as_events(self): """ with failing condition raises when machine.catch_errors_as_events is False.""" from statemachine.io.scxml.actions import create_if_action_callable from statemachine.io.scxml.schema import IfAction from statemachine.io.scxml.schema import IfBranch action = IfAction(branches=[IfBranch(cond="undefined_var")]) if_callable = create_if_action_callable(action) machine = Mock() machine.catch_errors_as_events = False machine.model.__dict__ = {} with pytest.raises(NameError, match="undefined_var"): if_callable(machine=machine) class TestSCXMLSendWithParamNoExpr: """SCXML with a param that has location but no expr.""" def test_send_param_with_location_only(self): """ param with location only evaluates the location.""" from statemachine.io.scxml.processor import SCXMLProcessor scxml = """ """ processor = SCXMLProcessor() processor.parse_scxml("test_send_param", scxml) sm = processor.start() assert sm.configuration == {sm.states_map["s2"]} class TestSCXMLHistoryWithoutTransitions: """SCXML history state without default transitions.""" def test_history_without_transitions(self): """History state without transitions is processed correctly.""" from statemachine.io.scxml.processor import SCXMLProcessor scxml = """ """ processor = SCXMLProcessor() processor.parse_scxml("test_history_no_trans", scxml) sm = processor.start() assert sm.states_map["a"] in sm.configuration # --- SCXMLInvoker --- def _make_invoker(definition=None, base_dir=None, register_child=None): """Helper to create an SCXMLInvoker with sensible defaults.""" if definition is None: definition = InvokeDefinition() if base_dir is None: base_dir = "" if register_child is None: register_child = Mock(return_value=Mock) return SCXMLInvoker( definition=definition, base_dir=base_dir, register_child=register_child, ) class TestSCXMLInvoker: def test_invalid_invoke_type_raises(self): """run() raises ValueError for unsupported invoke type.""" defn = InvokeDefinition( type="http://unsupported/type", content="", ) invoker = _make_invoker(definition=defn) ctx = Mock() model = Mock(spec=[]) ctx.machine = Mock(model=model) with pytest.raises(ValueError, match="Unsupported invoke type"): invoker.run(ctx) def test_no_content_resolved_raises(self): """run() raises ValueError when no src/content/srcexpr is provided.""" defn = InvokeDefinition() # no content, src, or srcexpr invoker = _make_invoker(definition=defn) ctx = Mock() model = Mock(spec=[]) ctx.machine = Mock(model=model) with pytest.raises(ValueError, match="No content resolved"): invoker.run(ctx) def test_resolve_content_inline_xml(self): """_resolve_content returns inline XML content directly.""" xml_content = '' defn = InvokeDefinition(content=xml_content) invoker = _make_invoker(definition=defn) result = invoker._resolve_content(Mock()) assert result == xml_content def test_resolve_content_from_file(self, tmp_path): """_resolve_content reads content from src file path.""" scxml_file = tmp_path / "child.scxml" scxml_file.write_text("") defn = InvokeDefinition(src="child.scxml") invoker = _make_invoker(definition=defn, base_dir=str(tmp_path)) result = invoker._resolve_content(Mock()) assert result == "" def test_evaluate_params_namelist_and_params(self): """_evaluate_params resolves both namelist variables and param elements.""" defn = InvokeDefinition( namelist="var1 var2", params=[Param(name="p1", expr="42")], ) invoker = _make_invoker(definition=defn) model = type("Model", (), {"var1": "a", "var2": "b"})() machine = Mock(model=model) result = invoker._evaluate_params(machine) assert result == {"var1": "a", "var2": "b", "p1": 42} def test_on_cancel_clears_child(self): """on_cancel() sets _child to None.""" invoker = _make_invoker() invoker._child = Mock() invoker.on_cancel() assert invoker._child is None def test_on_event_skips_terminated_child(self): """on_event() does not error when child is terminated.""" invoker = _make_invoker() child = Mock() child.is_terminated = True invoker._child = child # Should not raise or call send invoker.on_event("some.event") child.send.assert_not_called() def test_on_finalize_without_block_is_noop(self): """on_finalize() does nothing when no finalize block is defined.""" invoker = _make_invoker() assert invoker._finalize_block is None # Should not raise trigger_data = Mock() invoker.on_finalize(trigger_data) def test_send_to_parent_warns_without_session(self, caplog): """_send_to_parent logs a warning when machine has no _invoke_session.""" from statemachine.io.scxml.actions import _send_to_parent from statemachine.io.scxml.parser import SendAction action = SendAction(event="done", target="#_parent") machine = Mock(spec=[]) # spec=[] ensures no _invoke_session attribute machine.name = "test_machine" with caplog.at_level(logging.WARNING, logger="statemachine.io.scxml.actions"): _send_to_parent(action, machine=machine) assert "no _invoke_session" in caplog.text # --- _send_to_invoke --- class TestSendToInvoke: """Unit tests for _send_to_invoke (routes ).""" def _make_machine_with_invoke_manager(self, send_to_child_return=True): """Create a mock machine with an InvokeManager that has send_to_child.""" machine = Mock() machine.model = Mock() machine.model.__dict__ = {} machine._engine._invoke_manager.send_to_child.return_value = send_to_child_return return machine def test_routes_event_to_child(self): """_send_to_invoke forwards the event to InvokeManager.send_to_child.""" from statemachine.io.scxml.actions import _send_to_invoke from statemachine.io.scxml.parser import SendAction machine = self._make_machine_with_invoke_manager() action = SendAction(event="childEvent", target="#_child1") _send_to_invoke(action, "child1", machine=machine) machine._engine._invoke_manager.send_to_child.assert_called_once_with( "child1", "childEvent" ) machine.send.assert_not_called() def test_sends_error_communication_when_child_not_found(self): """_send_to_invoke sends error.communication when invokeid is not found.""" from statemachine.io.scxml.actions import _send_to_invoke from statemachine.io.scxml.parser import SendAction machine = self._make_machine_with_invoke_manager(send_to_child_return=False) action = SendAction(event="childEvent", target="#_unknown") _send_to_invoke(action, "unknown", machine=machine) machine._put_nonblocking.assert_called_once() trigger_data = machine._put_nonblocking.call_args[0][0] assert str(trigger_data.event) == "error.communication" def test_evaluates_eventexpr(self): """_send_to_invoke evaluates eventexpr when event is None.""" from statemachine.io.scxml.actions import _send_to_invoke from statemachine.io.scxml.parser import SendAction machine = self._make_machine_with_invoke_manager() action = SendAction(event=None, eventexpr="'dynamic_event'", target="#_child1") _send_to_invoke(action, "child1", machine=machine) machine._engine._invoke_manager.send_to_child.assert_called_once_with( "child1", "dynamic_event" ) def test_forwards_params(self): """_send_to_invoke forwards evaluated params to send_to_child.""" from statemachine.io.scxml.actions import _send_to_invoke from statemachine.io.scxml.parser import SendAction machine = self._make_machine_with_invoke_manager() action = SendAction( event="childEvent", target="#_child1", params=[Param(name="x", expr="42"), Param(name="y", expr="'hello'")], ) _send_to_invoke(action, "child1", machine=machine) machine._engine._invoke_manager.send_to_child.assert_called_once_with( "child1", "childEvent", x=42, y="hello" ) def test_forwards_namelist_variables(self): """_send_to_invoke resolves namelist variables from model and forwards them.""" from statemachine.io.scxml.actions import _send_to_invoke from statemachine.io.scxml.parser import SendAction machine = self._make_machine_with_invoke_manager() model = type("Model", (), {})() model.var1 = "alpha" model.var2 = "beta" machine.model = model action = SendAction(event="childEvent", target="#_child1", namelist="var1 var2") _send_to_invoke(action, "child1", machine=machine) machine._engine._invoke_manager.send_to_child.assert_called_once_with( "child1", "childEvent", var1="alpha", var2="beta" ) def test_namelist_missing_variable_raises(self): """_send_to_invoke raises NameError when namelist variable is not on model.""" from statemachine.io.scxml.actions import _send_to_invoke from statemachine.io.scxml.parser import SendAction machine = self._make_machine_with_invoke_manager() machine.model = Mock(spec=[]) # no attributes action = SendAction(event="childEvent", target="#_child1", namelist="missing_var") with pytest.raises(NameError, match="missing_var"): _send_to_invoke(action, "child1", machine=machine) def test_send_action_callable_routes_invoke_target(self): """create_send_action_callable routes #_ targets to _send_to_invoke.""" from statemachine.io.scxml.actions import create_send_action_callable from statemachine.io.scxml.parser import SendAction machine = self._make_machine_with_invoke_manager() action = SendAction(event="hello", target="#_myinvoke") send_callable = create_send_action_callable(action) send_callable(machine=machine) machine._engine._invoke_manager.send_to_child.assert_called_once_with("myinvoke", "hello") def test_send_action_callable_scxml_session_target(self): """create_send_action_callable sends error.communication for #_scxml_ targets.""" from statemachine.io.scxml.actions import create_send_action_callable from statemachine.io.scxml.parser import SendAction machine = self._make_machine_with_invoke_manager() action = SendAction(event="hello", target="#_scxml_session123") send_callable = create_send_action_callable(action) send_callable(machine=machine) machine._put_nonblocking.assert_called_once() trigger_data = machine._put_nonblocking.call_args[0][0] assert str(trigger_data.event) == "error.communication" machine._engine._invoke_manager.send_to_child.assert_not_called() # --- EventDataWrapper coverage --- class TestEventDataWrapperEdgeCases: def test_no_event_data_no_trigger_data_raises(self): """EventDataWrapper raises ValueError when neither is provided.""" with pytest.raises(ValueError, match="Either event_data or trigger_data"): EventDataWrapper() def test_getattr_with_event_data_delegates(self): """__getattr__ delegates to event_data when present.""" event_data = Mock() event_data.trigger_data = Mock( kwargs={}, send_id=None, event=Mock(internal=True, __str__=lambda s: "test") ) event_data.some_custom_attr = "custom_value" wrapper = EventDataWrapper(event_data) assert wrapper.some_custom_attr == "custom_value" def test_getattr_without_event_data_raises(self): """__getattr__ raises AttributeError when event_data is None.""" trigger_data = Mock(kwargs={}, send_id=None, event=Mock(internal=True)) trigger_data.event.__str__ = lambda s: "test" wrapper = EventDataWrapper(trigger_data=trigger_data) with pytest.raises(AttributeError, match="no attribute 'missing_attr'"): wrapper.missing_attr # noqa: B018 def test_name_via_trigger_data(self): """name property returns event string from trigger_data when no event_data.""" trigger_data = Mock(kwargs={}, send_id=None, event=Mock(internal=True)) trigger_data.event.__str__ = lambda s: "my.event" wrapper = EventDataWrapper(trigger_data=trigger_data) assert wrapper.name == "my.event" # --- _send_to_parent coverage --- class TestSendToParentParams: def test_send_to_parent_with_namelist_and_params(self): """_send_to_parent resolves namelist and params before sending.""" from statemachine.io.scxml.actions import _send_to_parent from statemachine.io.scxml.parser import SendAction model = type("Model", (), {})() model.myvar = "hello" machine = Mock(model=model) machine.model.__dict__ = {"myvar": "hello"} session = Mock() machine._invoke_session = session action = SendAction( event="childDone", target="#_parent", namelist="myvar", params=[Param(name="extra", expr="42")], ) _send_to_parent(action, machine=machine) session.send_to_parent.assert_called_once_with("childDone", myvar="hello", extra=42) def test_send_to_parent_namelist_missing_raises(self): """_send_to_parent raises NameError when namelist variable is missing.""" from statemachine.io.scxml.actions import _send_to_parent from statemachine.io.scxml.parser import SendAction machine = Mock() machine.model = Mock(spec=[]) # no attributes machine._invoke_session = Mock() action = SendAction(event="ev", target="#_parent", namelist="missing_var") with pytest.raises(NameError, match="missing_var"): _send_to_parent(action, machine=machine) def test_send_to_parent_param_without_expr_skipped(self): """_send_to_parent skips params where expr is None.""" from statemachine.io.scxml.actions import _send_to_parent from statemachine.io.scxml.parser import SendAction machine = Mock() machine.model = Mock() machine.model.__dict__ = {} session = Mock() machine._invoke_session = session action = SendAction( event="ev", target="#_parent", params=[ Param(name="has_expr", expr="1"), Param(name="no_expr", expr=None), ], ) _send_to_parent(action, machine=machine) session.send_to_parent.assert_called_once_with("ev", has_expr=1) # --- _send_to_invoke param skip coverage --- class TestSendToInvokeParamSkip: def test_param_without_expr_is_skipped(self): """_send_to_invoke skips params where expr is None.""" from statemachine.io.scxml.actions import _send_to_invoke from statemachine.io.scxml.parser import SendAction machine = Mock() machine.model = Mock() machine.model.__dict__ = {} machine._engine._invoke_manager.send_to_child.return_value = True action = SendAction( event="ev", target="#_child", params=[ Param(name="with_expr", expr="1"), Param(name="no_expr", expr=None), ], ) _send_to_invoke(action, "child", machine=machine) machine._engine._invoke_manager.send_to_child.assert_called_once_with( "child", "ev", with_expr=1 ) # --- invoke_init coverage --- class TestInvokeInitCallback: def test_invoke_init_idempotent(self): """invoke_init only runs once, even if called multiple times.""" from statemachine.io.scxml.actions import create_invoke_init_callable callback = create_invoke_init_callable() machine = Mock() callback(machine=machine) assert machine._invoke_params is not None or True # first call sets attrs # Reset to detect second call machine._invoke_params = "first" callback(machine=machine) # Should NOT have been overwritten assert machine._invoke_params == "first" # --- SCXMLInvoker edge cases --- class TestSCXMLInvokerEdgeCases: def test_on_event_exception_in_child_send(self): """on_event swallows exceptions from child.send().""" invoker = _make_invoker() child = Mock() child.is_terminated = False child.send.side_effect = RuntimeError("child error") invoker._child = child # Should not raise invoker.on_event("some.event") child.send.assert_called_once_with("some.event") def test_resolve_content_expr_non_string(self): """_resolve_content converts non-string eval result to string.""" defn = InvokeDefinition(content="42") # evaluates to int invoker = _make_invoker(definition=defn) machine = Mock() machine.model.__dict__ = {} result = invoker._resolve_content(machine) assert result == "42" def test_evaluate_params_with_location(self): """_evaluate_params resolves param with location instead of expr.""" defn = InvokeDefinition( params=[Param(name="p1", expr=None, location="myvar")], ) invoker = _make_invoker(definition=defn) model = type("Model", (), {})() model.myvar = "resolved" machine = Mock(model=model) machine.model.__dict__ = {"myvar": "resolved"} result = invoker._evaluate_params(machine) assert result == {"p1": "resolved"} # --- Parser edge cases --- class TestParserAssignChildXml: def test_assign_with_child_xml_content(self): """ with child XML content is parsed as child_xml.""" scxml = """ """ # Should parse without error — the child XML is stored in child_xml definition = parse_scxml(scxml) # Verify it parsed states correctly assert "s1" in definition.states def test_assign_with_text_content(self): """ with text content (no expr attr) uses text as expr.""" scxml = """ 42 """ definition = parse_scxml(scxml) assert "s1" in definition.states class TestParserInvokeContent: def test_invoke_with_text_content(self): """ with text body is parsed.""" scxml = """ some text content """ definition = parse_scxml(scxml) assert "s1" in definition.states invoke_def = definition.states["s1"].invocations[0] assert "some text content" in invoke_def.content def test_invoke_with_content_expr(self): """ is parsed as dynamic content.""" scxml = """ """ definition = parse_scxml(scxml) invoke_def = definition.states["s1"].invocations[0] assert invoke_def.content == "'dynamic'" def test_invoke_with_inline_scxml_no_namespace(self): """ with inline (no namespace) is parsed.""" scxml = """ """ definition = parse_scxml(scxml) invoke_def = definition.states["s1"].invocations[0] assert " are silently ignored.""" scxml = """ """ definition = parse_scxml(scxml) invoke_def = definition.states["s1"].invocations[0] assert len(invoke_def.params) == 1 def test_invoke_with_empty_content(self): """ with empty results in content=None.""" scxml = """ """ definition = parse_scxml(scxml) invoke_def = definition.states["s1"].invocations[0] assert invoke_def.content is None def test_invoke_with_finalize_block(self): """ with block is parsed.""" scxml = """ child content """ definition = parse_scxml(scxml) invoke_def = definition.states["s1"].invocations[0] assert invoke_def.finalize is not None assert len(invoke_def.finalize.actions) == 1 class TestParserAssignEdgeCases: def test_assign_without_children_or_text(self): """ with neither children nor text results in expr=None.""" scxml = """ """ definition = parse_scxml(scxml) assert "s1" in definition.states class TestSCXMLInvokerResolveContentAbsolutePath: def test_resolve_content_absolute_path(self, tmp_path): """_resolve_content with absolute src path doesn't prepend base_dir.""" scxml_file = tmp_path / "child.scxml" scxml_file.write_text("") defn = InvokeDefinition(src=str(scxml_file)) invoker = _make_invoker(definition=defn, base_dir="/some/other/dir") result = invoker._resolve_content(Mock()) assert result == "" class TestSCXMLInvokerEvaluateParamsNoExprNoLocation: def test_param_without_expr_or_location_skipped(self): """_evaluate_params skips params with neither expr nor location.""" defn = InvokeDefinition( params=[Param(name="p1", expr=None, location=None)], ) invoker = _make_invoker(definition=defn) machine = Mock(model=type("M", (), {})()) machine.model.__dict__ = {} result = invoker._evaluate_params(machine) assert result == {} class TestInvokeInitMachineNone: def test_invoke_init_without_machine_is_noop(self): """invoke_init does nothing when machine is not in kwargs.""" from statemachine.io.scxml.actions import create_invoke_init_callable callback = create_invoke_init_callable() # Call without machine kwarg — should not raise callback() class TestInvokeCallableWrapperRunInstance: def test_run_with_instance_not_class(self): """_InvokeCallableWrapper.run() works with an instance (not a class).""" from statemachine.invoke import _InvokeCallableWrapper class Handler: def run(self, ctx): return "result" handler_instance = Handler() wrapper = _InvokeCallableWrapper(handler_instance) assert not wrapper._is_class ctx = Mock() result = wrapper.run(ctx) assert result == "result" assert wrapper._instance is handler_instance class TestOrderedSetStr: def test_str_representation(self): """OrderedSet.__str__ returns a set-like string.""" from statemachine.orderedset import OrderedSet os = OrderedSet([1, 2, 3]) assert str(os) == "{1, 2, 3}" ================================================ FILE: tests/test_signature.py ================================================ import inspect from functools import partial import pytest from statemachine.dispatcher import callable_method from statemachine.signature import SignatureAdapter def single_positional_param(a): return a def single_default_keyword_param(a=42): return a def args_param(*args): return args def kwargs_param(**kwargs): return kwargs def args_and_kwargs_param(*args, **kwargs): return args, kwargs def positional_optional_catchall(a, b="ham", *args): return a, b, args def ignored_param(a, b, *, c, d=10): return a, b, c, d def positional_and_kw_arguments(source, target, event): return source, target, event def default_kw_arguments(source: str = "A", target: str = "B", event: str = "go"): return source, target, event class MyObject: def __init__(self, value=42): self.value = value def method_no_argument(self): return self.value class TestSignatureAdapter: @pytest.mark.parametrize( ("func", "args", "kwargs", "expected"), [ (single_positional_param, [10], {}, 10), (single_positional_param, [], {"a": 10}, 10), pytest.param(single_positional_param, [], {}, TypeError), (single_default_keyword_param, [10], {}, 10), (single_default_keyword_param, [], {"a": 10}, 10), pytest.param(single_default_keyword_param, [], {}, 42), (MyObject().method_no_argument, [], {}, 42), (MyObject().method_no_argument, ["ignored"], {"x": True}, 42), (MyObject.method_no_argument, [MyObject()], {"x": True}, 42), pytest.param(MyObject.method_no_argument, [], {}, TypeError), (args_param, [], {}, ()), (args_param, [42], {}, (42,)), (args_param, [1, 1, 2, 3, 5, 8, 13], {}, (1, 1, 2, 3, 5, 8, 13)), ( args_param, [1, 1, 2, 3, 5, 8, 13], {"x": True, "other": 42}, (1, 1, 2, 3, 5, 8, 13), ), (kwargs_param, [], {}, {}), (kwargs_param, [1], {}, {}), (kwargs_param, [1, 3, 5, 8, "x", True], {}, {}), (kwargs_param, [], {"x": True}, {"x": True}), (kwargs_param, [], {"x": True, "n": 42}, {"x": True, "n": 42}), ( kwargs_param, [10, "x", False], {"x": True, "n": 42}, {"x": True, "n": 42}, ), (args_and_kwargs_param, [], {}, ((), {})), (args_and_kwargs_param, [1], {}, ((1,), {})), ( args_and_kwargs_param, [1, 3, 5, False, "n"], {"x": True, "n": 42}, ((1, 3, 5, False, "n"), {"x": True, "n": 42}), ), (positional_optional_catchall, [], {}, TypeError), (positional_optional_catchall, [42], {}, (42, "ham", ())), pytest.param( positional_optional_catchall, [True], {"b": "spam"}, (True, "spam", ()), ), pytest.param( positional_optional_catchall, ["a", "b"], {"b": "spam"}, ("a", "spam", ()), ), ( positional_optional_catchall, ["a", "b", "c"], {"b": "spam"}, ("a", "spam", ("c",)), ), ( positional_optional_catchall, ["a", "b", "c", False, 10], {"other": 42}, ("a", "b", ("c", False, 10)), ), (ignored_param, [], {}, TypeError), (ignored_param, [1, 2, 3], {}, TypeError), pytest.param( ignored_param, [1, 2], {"c": 42}, (1, 2, 42, 10), ), pytest.param( ignored_param, [1, 2], {"c": 42, "d": 21}, (1, 2, 42, 21), ), pytest.param(positional_and_kw_arguments, [], {}, TypeError), (positional_and_kw_arguments, [1, 2, 3], {}, (1, 2, 3)), (positional_and_kw_arguments, [1, 2], {"event": "foo"}, (1, 2, "foo")), ( positional_and_kw_arguments, [], {"source": "A", "target": "B", "event": "foo"}, ("A", "B", "foo"), ), pytest.param(default_kw_arguments, [], {}, ("A", "B", "go")), (default_kw_arguments, [1, 2, 3], {}, (1, 2, 3)), (default_kw_arguments, [1, 2], {"event": "wait"}, (1, 2, "wait")), ], ) def test_wrap_fn_single_positional_parameter(self, func, args, kwargs, expected): wrapped_func = callable_method(func) assert wrapped_func.__name__ == func.__name__ if inspect.isclass(expected) and issubclass(expected, Exception): with pytest.raises(expected): wrapped_func(*args, **kwargs) else: assert wrapped_func(*args, **kwargs) == expected def test_support_for_partial(self): part = partial(positional_and_kw_arguments, event="activated") wrapped_func = callable_method(part) assert wrapped_func("A", "B") == ("A", "B", "activated") assert wrapped_func.__name__ == positional_and_kw_arguments.__name__ def named_and_kwargs(source, **kwargs): return source, kwargs class TestCachedBindExpected: """Tests that exercise the cache fast-path by calling the same wrapped function twice with the same argument shape.""" def setup_method(self): SignatureAdapter.from_callable.clear_cache() def test_named_param_not_leaked_into_kwargs(self): """Named params should not appear in the **kwargs dict on cache hit.""" wrapped = callable_method(named_and_kwargs) # 1st call: cache miss -> _full_bind result1 = wrapped(source="A", target="B", event="go") assert result1 == ("A", {"target": "B", "event": "go"}) # 2nd call: cache hit -> _fast_bind result2 = wrapped(source="X", target="Y", event="stop") assert result2 == ("X", {"target": "Y", "event": "stop"}) def test_kwargs_only_receives_unmatched_keys_with_positional(self): """When mixing positional and keyword args with **kwargs.""" wrapped = callable_method(named_and_kwargs) result1 = wrapped("A", target="B") assert result1 == ("A", {"target": "B"}) result2 = wrapped("X", target="Y") assert result2 == ("X", {"target": "Y"}) def test_var_positional_collected_as_tuple(self): """VAR_POSITIONAL (*args) must be collected into a tuple on cache hit.""" def fn(*args, **kwargs): return args, kwargs wrapped = callable_method(fn) result1 = wrapped(1, 2, 3, key="val") assert result1 == ((1, 2, 3), {"key": "val"}) result2 = wrapped(4, 5, key="other") assert result2 == ((4, 5), {"key": "other"}) def test_keyword_only_after_var_positional(self): """KEYWORD_ONLY params after *args must be extracted from kwargs on cache hit.""" def fn(*args, event, **kwargs): return args, event, kwargs wrapped = callable_method(fn) result1 = wrapped(100, event="ev1", source="s0") assert result1 == ((100,), "ev1", {"source": "s0"}) result2 = wrapped(200, event="ev2", source="s1") assert result2 == ((200,), "ev2", {"source": "s1"}) def test_positional_or_keyword_prefers_kwargs_over_positional(self): """When a POSITIONAL_OR_KEYWORD param is in both args and kwargs, kwargs wins.""" def fn(event, source, target): return event, source, target wrapped = callable_method(fn) # 1st call: positional arg provided but 'event' also in kwargs -> kwargs wins result1 = wrapped("discarded_content", event="ev1", source="s0", target="t0") assert result1 == ("ev1", "s0", "t0") # 2nd call: cache hit, same behavior expected result2 = wrapped("other_content", event="ev2", source="s1", target="t1") assert result2 == ("ev2", "s1", "t1") def test_empty_var_positional(self): """Empty *args is handled correctly on cache hit.""" def fn(*args, **kwargs): return args, kwargs wrapped = callable_method(fn) # 1st call with args result1 = wrapped(1, key="val") assert result1 == ((1,), {"key": "val"}) # 2nd call: only kwargs, no positional args — different cache key (len=0) result2 = wrapped(key="val2") assert result2 == ((), {"key": "val2"}) # 3rd call: hits cache for len=0 result3 = wrapped(key="val3") assert result3 == ((), {"key": "val3"}) def test_named_params_before_var_positional(self): """Named params before *args are filled correctly on cache hit.""" def fn(a, b, *args, **kwargs): return a, b, args, kwargs wrapped = callable_method(fn) result1 = wrapped(1, 2, 3, 4, key="val") assert result1 == (1, 2, (3, 4), {"key": "val"}) result2 = wrapped(10, 20, 30, key="val2") assert result2 == (10, 20, (30,), {"key": "val2"}) def test_kwargs_wins_with_var_positional_present(self): """POSITIONAL_OR_KEYWORD before *args prefers kwargs when name matches.""" def fn(event, *args, **kwargs): return event, args, kwargs wrapped = callable_method(fn) # 1st call: 'event' in both positional and kwargs — kwargs wins result1 = wrapped("discarded", "extra", event="ev1", key="a") assert result1 == ("ev1", ("extra",), {"key": "a"}) # 2nd call: cache hit, same behavior result2 = wrapped("other", "more", event="ev2", key="b") assert result2 == ("ev2", ("more",), {"key": "b"}) ================================================ FILE: tests/test_signature_positional_only.py ================================================ import inspect import pytest from statemachine.dispatcher import callable_method class TestSignatureAdapter: @pytest.mark.parametrize( ("args", "kwargs", "expected"), [ ([], {}, TypeError), ([1, 2, 3], {}, TypeError), ([1, 2], {"kw_only_param": 42}, (1, 2, 42)), ([1], {"pos_or_kw_param": 21, "kw_only_param": 42}, (1, 21, 42)), ( [], {"pos_only": 10, "pos_or_kw_param": 21, "kw_only_param": 42}, TypeError, ), ], ) def test_positional_only(self, args, kwargs, expected): def func(pos_only, /, pos_or_kw_param, *, kw_only_param): # https://peps.python.org/pep-0570/ return pos_only, pos_or_kw_param, kw_only_param wrapped_func = callable_method(func) if inspect.isclass(expected) and issubclass(expected, Exception): with pytest.raises(expected): wrapped_func(*args, **kwargs) else: assert wrapped_func(*args, **kwargs) == expected ================================================ FILE: tests/test_spec_parser.py ================================================ import asyncio from typing import TYPE_CHECKING import pytest from statemachine.spec_parser import Functions from statemachine.spec_parser import operator_mapping from statemachine.spec_parser import parse_boolean_expr if TYPE_CHECKING: from collections.abc import Callable def variable_hook( var_name: str, spy: "Callable[[str], None] | None" = None, ) -> "Callable": values = { "frodo_has_ring": True, "sauron_alive": False, "gandalf_present": True, "sam_is_loyal": True, "orc_army_ready": False, "frodo_age": 50, "height": 1.75, "name": "Frodo", "aragorn_age": 87, "legolas_age": 2931, "gimli_age": 139, "ring_power": 100, "sword_power": 80, "bow_power": 75, "axe_power": 85, } def decorated(*args, **kwargs): if spy is not None: spy(var_name) return values.get(var_name, False) decorated.__name__ = var_name return decorated @pytest.mark.parametrize( ("expression", "expected", "hooks_called"), [ ("frodo_has_ring", True, ["frodo_has_ring"]), ("frodo_has_ring or sauron_alive", True, ["frodo_has_ring"]), ( "frodo_has_ring and gandalf_present", True, ["frodo_has_ring", "gandalf_present"], ), ("sauron_alive", False, ["sauron_alive"]), ("not sauron_alive", True, ["sauron_alive"]), ( "frodo_has_ring and (gandalf_present or sauron_alive)", True, ["frodo_has_ring", "gandalf_present"], ), ( "not sauron_alive and orc_army_ready", False, ["sauron_alive", "orc_army_ready"], ), ( "not (not sauron_alive and orc_army_ready)", True, ["sauron_alive", "orc_army_ready"], ), ( "(frodo_has_ring and sam_is_loyal) or (not sauron_alive and orc_army_ready)", True, ["frodo_has_ring", "sam_is_loyal"], ), ( "(frodo_has_ring ^ sam_is_loyal) v (!sauron_alive ^ orc_army_ready)", True, ["frodo_has_ring", "sam_is_loyal"], ), ("not (not frodo_has_ring)", True, ["frodo_has_ring"]), ("!(!frodo_has_ring)", True, ["frodo_has_ring"]), ( "frodo_has_ring and orc_army_ready", False, ["frodo_has_ring", "orc_army_ready"], ), ( "frodo_has_ring ^ orc_army_ready", False, ["frodo_has_ring", "orc_army_ready"], ), ( "frodo_has_ring and not orc_army_ready", True, ["frodo_has_ring", "orc_army_ready"], ), ( "frodo_has_ring ^ !orc_army_ready", True, ["frodo_has_ring", "orc_army_ready"], ), ( "frodo_has_ring and (sam_is_loyal or (gandalf_present and not sauron_alive))", True, ["frodo_has_ring", "sam_is_loyal"], ), ( "frodo_has_ring ^ (sam_is_loyal v (gandalf_present ^ !sauron_alive))", True, ["frodo_has_ring", "sam_is_loyal"], ), ("sauron_alive or orc_army_ready", False, ["sauron_alive", "orc_army_ready"]), ("sauron_alive v orc_army_ready", False, ["sauron_alive", "orc_army_ready"]), ( "(frodo_has_ring and gandalf_present) or orc_army_ready", True, ["frodo_has_ring", "gandalf_present"], ), ( "orc_army_ready or (frodo_has_ring and gandalf_present)", True, ["orc_army_ready", "frodo_has_ring", "gandalf_present"], ), ( "orc_army_ready and (frodo_has_ring and gandalf_present)", False, ["orc_army_ready"], ), ( "!orc_army_ready and (frodo_has_ring and gandalf_present)", True, ["orc_army_ready", "frodo_has_ring", "gandalf_present"], ), ( "!orc_army_ready and !(frodo_has_ring and gandalf_present)", False, ["orc_army_ready", "frodo_has_ring", "gandalf_present"], ), ("frodo_has_ring or True", True, ["frodo_has_ring"]), ("sauron_alive or True", True, ["sauron_alive"]), ("frodo_age >= 50", True, ["frodo_age"]), ("50 <= frodo_age", True, ["frodo_age"]), ("frodo_age <= 50", True, ["frodo_age"]), ("frodo_age == 50", True, ["frodo_age"]), ("frodo_age > 50", False, ["frodo_age"]), ("frodo_age < 50", False, ["frodo_age"]), ("frodo_age != 50", False, ["frodo_age"]), ("frodo_age != 49", True, ["frodo_age"]), ("49 < frodo_age < 51", True, ["frodo_age", "frodo_age"]), ("49 < frodo_age > 50", False, ["frodo_age", "frodo_age"]), ( "aragorn_age < legolas_age < gimli_age", False, ["aragorn_age", "legolas_age", "legolas_age", "gimli_age"], ), # 87 < 2931 and 2931 < 139 ( "gimli_age > aragorn_age < legolas_age", True, ["gimli_age", "aragorn_age", "aragorn_age", "legolas_age"], ), # 139 > 87 and 87 < 2931 ( "sword_power < ring_power > bow_power", True, ["sword_power", "ring_power", "ring_power", "bow_power"], ), # 80 < 100 and 100 > 75 ( "axe_power > sword_power == bow_power", False, ["axe_power", "sword_power", "sword_power", "bow_power"], ), # 85 > 80 and 80 == 75 ("name == 'Frodo'", True, ["name"]), ("name != 'Sam'", True, ["name"]), ("height == 1.75", True, ["height"]), ("height > 1 and height < 2", True, ["height", "height"]), ], ) def test_expressions(expression, expected, hooks_called): calls: list[str] = [] def hook(name): return variable_hook(name, spy=calls.append) parsed_expr = parse_boolean_expr(expression, hook, operator_mapping) assert parsed_expr() is expected, expression assert calls == hooks_called def test_negating_compound_false_expression(): expr = "not (not sauron_alive and orc_army_ready)" parsed_expr = parse_boolean_expr(expr, variable_hook, operator_mapping) assert parsed_expr() is True assert parsed_expr.__name__ == "not((not(sauron_alive) and orc_army_ready))" def test_expression_name_uniqueness(): expr = "frodo_has_ring or not orc_army_ready" parsed_expr = parse_boolean_expr(expr, variable_hook, operator_mapping) assert ( parsed_expr.__name__ == "(frodo_has_ring or not(orc_army_ready))" ) # name reflects expression structure def test_classical_operators_name(): expr = "frodo_has_ring ^ !orc_army_ready" parsed_expr = parse_boolean_expr(expr, variable_hook, operator_mapping) assert parsed_expr() is True # both parts are True assert ( parsed_expr.__name__ == "(frodo_has_ring and not(orc_army_ready))" ) # name reflects expression structure def test_empty_expression(): expr = "" with pytest.raises(SyntaxError): parse_boolean_expr(expr, variable_hook, operator_mapping) def test_whitespace_expression(): expr = " " with pytest.raises(SyntaxError): parse_boolean_expr(expr, variable_hook, operator_mapping) def test_missing_operator_expression(): expr = "frodo_has_ring orc_army_ready" with pytest.raises(SyntaxError): parse_boolean_expr(expr, variable_hook, operator_mapping) def test_dict_usage_expression(): expr = "frodo_has_ring or {}" with pytest.raises(ValueError, match="Unsupported expression structure"): parse_boolean_expr(expr, variable_hook, operator_mapping) def test_unsupported_operator(): # Define an unsupported operator like MUL expr = "frodo_has_ring * gandalf_present" with pytest.raises(ValueError, match="Unsupported expression structure"): parse_boolean_expr(expr, variable_hook, operator_mapping) def test_simple_variable_returns_the_original_callback(): def original_callback(*args, **kwargs): return True mapping = {"original": original_callback} def variable_hook(var_name): return mapping.get(var_name, None) expr = "original" parsed_expr = parse_boolean_expr(expr, variable_hook, operator_mapping) assert parsed_expr is original_callback def async_variable_hook(var_name): """Variable hook that returns async callables, for testing issue #535.""" values = { "cond_true": True, "cond_false": False, "val_10": 10, "val_20": 20, } value = values.get(var_name, False) async def decorated(*args, **kwargs): await asyncio.sleep(0) return value decorated.__name__ = var_name return decorated @pytest.mark.parametrize( ("expression", "expected"), [ ("not cond_false", True), ("not cond_true", False), ("cond_true and cond_true", True), ("cond_true and cond_false", False), ("cond_false and cond_true", False), ("cond_false or cond_true", True), ("cond_true or cond_false", True), ("cond_false or cond_false", False), ("not cond_false and cond_true", True), ("not (cond_true and cond_false)", True), ("not (cond_false or cond_false)", True), ("cond_true and not cond_false", True), ("val_10 == 10", True), ("val_10 != 20", True), ("val_10 < val_20", True), ("val_20 > val_10", True), ("val_10 >= 10", True), ("val_10 <= val_20", True), ], ) def test_async_expressions(expression, expected): """Issue #535: condition expressions with async predicates must await results.""" parsed_expr = parse_boolean_expr(expression, async_variable_hook, operator_mapping) result = parsed_expr() assert asyncio.iscoroutine(result), f"Expected coroutine for async expression: {expression}" assert asyncio.run(result) is expected, expression def mixed_variable_hook(var_name): """Variable hook where some vars are sync and some are async.""" sync_values = {"sync_true": True, "sync_false": False, "sync_10": 10} async_values = {"async_true": True, "async_false": False, "async_20": 20} if var_name in async_values: value = async_values[var_name] async def async_decorated(*args, **kwargs): await asyncio.sleep(0) return value async_decorated.__name__ = var_name return async_decorated def sync_decorated(*args, **kwargs): return sync_values.get(var_name, False) sync_decorated.__name__ = var_name return sync_decorated @pytest.mark.parametrize( ("expression", "expected"), [ # async left, sync right ("async_true and sync_true", True), ("async_false or sync_true", True), # sync left, async right ("sync_true and async_true", True), ("sync_false or async_true", True), ("sync_true and async_false", False), ("sync_false or async_false", False), ], ) def test_mixed_sync_async_expressions(expression, expected): """Expressions mixing sync and async predicates must handle both correctly.""" parsed_expr = parse_boolean_expr(expression, mixed_variable_hook, operator_mapping) result = parsed_expr() if asyncio.iscoroutine(result): assert asyncio.run(result) is expected, expression else: assert result is expected, expression def test_functions_get_unknown_raises(): """Functions.get raises ValueError for unknown functions.""" with pytest.raises(ValueError, match="Unsupported function"): Functions.get("nonexistent_function") ================================================ FILE: tests/test_state.py ================================================ import pytest from statemachine.orderedset import OrderedSet from statemachine import State from statemachine import StateChart @pytest.fixture() def sm_class(): class SM(StateChart): pending = State(initial=True) waiting_approval = State() approved = State(final=True) start = pending.to(waiting_approval) approve = waiting_approval.to(approved) return SM class TestState: def test_name_derived_from_id(self, sm_class): assert sm_class.pending.name == "Pending" assert sm_class.waiting_approval.name == "Waiting approval" assert sm_class.approved.name == "Approved" def test_state_from_instance_is_hashable(self, sm_class): sm = sm_class() states_set = {sm.pending, sm.waiting_approval, sm.approved, sm.approved} assert states_set == {sm.pending, sm.waiting_approval, sm.approved} def test_state_knows_if_its_initial(self, sm_class): sm = sm_class() assert sm.pending.initial assert not sm.waiting_approval.initial assert not sm.approved.initial def test_state_knows_if_its_final(self, sm_class): sm = sm_class() assert not sm.pending.final assert not sm.waiting_approval.final assert sm.approved.final def test_ordered_set_clear(): """OrderedSet.clear empties the set.""" s = OrderedSet([1, 2, 3]) s.clear() assert len(s) == 0 def test_ordered_set_getitem(): """OrderedSet supports index access.""" s = OrderedSet([10, 20, 30]) assert s[0] == 10 assert s[2] == 30 def test_ordered_set_getitem_out_of_range(): """OrderedSet raises IndexError for out-of-range index.""" s = OrderedSet([10, 20]) with pytest.raises(IndexError, match="index 5 out of range"): s[5] def test_ordered_set_union(): """OrderedSet.union returns new set with elements from both.""" s1 = OrderedSet([1, 2]) result = s1.union([3, 4], [5, 6]) assert list(result) == [1, 2, 3, 4, 5, 6] ================================================ FILE: tests/test_state_callbacks.py ================================================ from unittest import mock import pytest @pytest.fixture() def event_mock(): return mock.MagicMock() @pytest.fixture() def traffic_light_machine(event_mock): # noqa: C901 from statemachine import State from statemachine import StateChart class TrafficLightMachineStateEvents(StateChart): "A traffic light machine" green = State(initial=True) yellow = State() red = State() cycle = green.to(yellow) | yellow.to(red) | red.to(green) def on_enter_state(self, event_data): event_mock.on_enter_state(event_data.transition.target) def on_exit_state(self, event_data): event_mock.on_exit_state(event_data.state) def on_enter_green(self): event_mock.on_enter_green(self) def on_exit_green(self): event_mock.on_exit_green(self) def on_enter_yellow(self): event_mock.on_enter_yellow(self) def on_exit_yellow(self): event_mock.on_exit_yellow(self) def on_enter_red(self): event_mock.on_enter_red(self) def on_exit_red(self): event_mock.on_exit_red(self) return TrafficLightMachineStateEvents class TestStateCallbacks: def test_should_call_on_enter_generic_state(self, event_mock, traffic_light_machine): machine = traffic_light_machine() machine.cycle() assert event_mock.on_enter_state.call_args_list == [ mock.call(machine.green), mock.call(machine.yellow), ] def test_should_call_on_exit_generic_state(self, event_mock, traffic_light_machine): machine = traffic_light_machine() machine.cycle() event_mock.on_exit_state.assert_called_once_with(machine.green) def test_should_call_on_enter_of_specific_state(self, event_mock, traffic_light_machine): machine = traffic_light_machine() machine.cycle() event_mock.on_enter_yellow.assert_called_once_with(machine) def test_should_call_on_exit_of_specific_state(self, event_mock, traffic_light_machine): machine = traffic_light_machine() machine.cycle() event_mock.on_exit_green.assert_called_once_with(machine) def test_should_be_on_the_previous_state_when_exiting(self, event_mock, traffic_light_machine): machine = traffic_light_machine() def assert_is_green_from_state(s): assert s.value == "green" def assert_is_green(m): assert m.green.is_active event_mock.on_exit_state.side_effect = assert_is_green_from_state event_mock.on_exit_green.side_effect = assert_is_green machine.cycle() def test_should_be_on_the_next_state_when_entering(self, event_mock, traffic_light_machine): machine = traffic_light_machine() def assert_is_yellow_from_state(s): assert s.value == "yellow" def assert_is_yellow(m): assert m.yellow.is_active event_mock.on_enter_state.side_effect = assert_is_yellow_from_state event_mock.on_enter_yellow.side_effect = assert_is_yellow machine.cycle() ================================================ FILE: tests/test_statechart_compound.py ================================================ """Compound state behavior using Python class syntax. Tests exercise entering/exiting compound states, nested compounds, cross-compound transitions, done.state events from final children, callback ordering, and discovery of methods defined inside State.Compound class bodies. Theme: Fellowship journey through Middle-earth. """ import pytest from statemachine import State from statemachine import StateChart from tests.machines.compound.middle_earth_journey import MiddleEarthJourney from tests.machines.compound.middle_earth_journey_two_compounds import ( MiddleEarthJourneyTwoCompounds, ) from tests.machines.compound.middle_earth_journey_with_finals import MiddleEarthJourneyWithFinals from tests.machines.compound.moria_expedition import MoriaExpedition from tests.machines.compound.moria_expedition_with_escape import MoriaExpeditionWithEscape from tests.machines.compound.quest_for_erebor import QuestForErebor from tests.machines.compound.shire_to_rivendell import ShireToRivendell @pytest.mark.timeout(5) class TestCompoundStates: async def test_enter_compound_activates_initial_child(self, sm_runner): """Entering a compound activates both parent and the initial child.""" sm = await sm_runner.start(ShireToRivendell) assert {"shire", "bag_end"} == set(sm.configuration_values) async def test_transition_within_compound(self, sm_runner): """Inner state changes while parent stays active.""" sm = await sm_runner.start(ShireToRivendell) await sm_runner.send(sm, "visit_pub") assert "shire" in sm.configuration_values assert "green_dragon" in sm.configuration_values assert "bag_end" not in sm.configuration_values async def test_exit_compound_removes_all_descendants(self, sm_runner): """Leaving a compound removes the parent and all children.""" sm = await sm_runner.start(ShireToRivendell) await sm_runner.send(sm, "depart") assert {"road"} == set(sm.configuration_values) async def test_nested_compound_two_levels(self, sm_runner): """Three-level nesting: outer > middle > leaf.""" sm = await sm_runner.start(MoriaExpedition) assert {"moria", "upper_halls", "entrance"} == set(sm.configuration_values) async def test_transition_from_inner_to_outer(self, sm_runner): """A deep child can transition to an outer state.""" sm = await sm_runner.start(MoriaExpeditionWithEscape) await sm_runner.send(sm, "escape") assert {"daylight"} == set(sm.configuration_values) async def test_cross_compound_transition(self, sm_runner): """Transition from one compound to another removes old children.""" sm = await sm_runner.start(MiddleEarthJourney) assert "rivendell" in sm.configuration_values assert "council" in sm.configuration_values await sm_runner.send(sm, "march_to_moria") assert "moria" in sm.configuration_values assert "gates" in sm.configuration_values assert "rivendell" not in sm.configuration_values assert "council" not in sm.configuration_values async def test_enter_compound_lands_on_initial(self, sm_runner): """Entering a compound from outside lands on the initial child.""" sm = await sm_runner.start(MiddleEarthJourneyTwoCompounds) await sm_runner.send(sm, "march_to_moria") assert "gates" in sm.configuration_values assert "moria" in sm.configuration_values async def test_final_child_fires_done_state(self, sm_runner): """Reaching a final child triggers done.state.{parent_id}.""" sm = await sm_runner.start(QuestForErebor) assert "approach" in sm.configuration_values await sm_runner.send(sm, "enter_mountain") assert {"victory"} == set(sm.configuration_values) async def test_multiple_compound_sequential_traversal(self, sm_runner): """Traverse all three compounds sequentially.""" sm = await sm_runner.start(MiddleEarthJourneyWithFinals) await sm_runner.send(sm, "march_to_moria") assert "moria" in sm.configuration_values await sm_runner.send(sm, "march_to_lorien") assert "lothlorien" in sm.configuration_values assert "mirror" in sm.configuration_values assert "moria" not in sm.configuration_values async def test_entry_exit_action_ordering(self, sm_runner): """on_exit fires before on_enter (verified via log).""" log = [] class ActionOrderTracker(StateChart): class realm(State.Compound): day = State(initial=True) night = State() sunset = day.to(night) outside = State(final=True) leave = realm.to(outside) def on_exit_day(self): log.append("exit_day") def on_exit_realm(self): log.append("exit_realm") def on_enter_outside(self): log.append("enter_outside") sm = await sm_runner.start(ActionOrderTracker) await sm_runner.send(sm, "leave") assert log == ["exit_day", "exit_realm", "enter_outside"] async def test_callbacks_inside_compound_class(self, sm_runner): """Methods defined inside the State.Compound class body are discovered.""" log = [] class CallbackDiscovery(StateChart): class realm(State.Compound): peaceful = State(initial=True) troubled = State() darken = peaceful.to(troubled) def on_enter_troubled(self): log.append("entered troubled times") end = State(final=True) conclude = realm.to(end) sm = await sm_runner.start(CallbackDiscovery) await sm_runner.send(sm, "darken") assert log == ["entered troubled times"] async def test_done_state_inside_compound(self, sm_runner): """done_state_* bare transition inside a compound body registers done.state.* event.""" class InnerDoneState(StateChart): class outer(State.Compound): class inner(State.Compound): start = State(initial=True) end = State(final=True) finish = start.to(end) after_inner = State(final=True) done_state_inner = inner.to(after_inner) victory = State(final=True) done_state_outer = outer.to(victory) sm = await sm_runner.start(InnerDoneState) assert "start" in sm.configuration_values await sm_runner.send(sm, "finish") assert {"victory"} == set(sm.configuration_values) async def test_done_invoke_inside_compound(self, sm_runner): """done_invoke_* bare transition inside a compound registers done.invoke.* event.""" class InvokeInCompound(StateChart): class wrapper(State.Compound): loading = State(initial=True, invoke=lambda: 42) loaded = State(final=True) done_invoke_loading = loading.to(loaded) done = State(final=True) done_state_wrapper = wrapper.to(done) sm = await sm_runner.start(InvokeInCompound) await sm_runner.sleep(0.15) await sm_runner.processing_loop(sm) assert {"done"} == set(sm.configuration_values) async def test_error_execution_inside_compound(self, sm_runner): """error_execution inside a compound body registers error.execution event.""" def raise_error(): raise RuntimeError("boom") class ErrorInCompound(StateChart): class active(State.Compound): ok = State(initial=True) failing = State() trigger = ok.to(failing, on=raise_error) errored = State() error_execution = failing.to(errored) done = State(final=True) finish = active.to(done) sm = await sm_runner.start(ErrorInCompound) await sm_runner.send(sm, "trigger") assert "errored" in sm.configuration_values def test_compound_state_name_attribute(self): """The name= kwarg in class syntax sets the state name.""" class NamedCompound(StateChart): class shire(State.Compound, name="The Shire"): home = State(initial=True, final=True) sm = NamedCompound() assert sm.shire.name == "The Shire" ================================================ FILE: tests/test_statechart_delayed.py ================================================ """Delayed event sends and cancellations. Tests exercise queuing events with a delay (fires after elapsed time), cancelling delayed events before they fire, zero-delay immediate firing, and the Event(delay=...) definition syntax. Theme: Beacons of Gondor — signal fires propagate with timing. """ import asyncio import pytest from statemachine.event import BoundEvent from statemachine import Event from statemachine import State from statemachine import StateChart @pytest.mark.timeout(10) class TestDelayedEvents: async def test_delayed_event_fires_after_delay(self, sm_runner): """Queuing a delayed event does not fire immediately; processing after delay does.""" class BeaconsOfGondor(StateChart): dark = State(initial=True) first_lit = State() all_lit = State(final=True) light_first = dark.to(first_lit) light_all = first_lit.to(all_lit) sm = await sm_runner.start(BeaconsOfGondor) await sm_runner.send(sm, "light_first") assert "first_lit" in sm.configuration_values # Queue the event with delay without triggering the processing loop event = BoundEvent(id="light_all", name="Light all", delay=50, _sm=sm) event.put() # Not yet processed assert "first_lit" in sm.configuration_values await asyncio.sleep(0.1) await sm_runner.processing_loop(sm) assert "all_lit" in sm.configuration_values async def test_cancel_delayed_event(self, sm_runner): """Cancelled delayed events do not fire.""" class BeaconsOfGondor(StateChart): dark = State(initial=True) lit = State(final=True) light = dark.to(lit) sm = await sm_runner.start(BeaconsOfGondor) # Queue delayed event event = BoundEvent(id="light", name="Light", delay=500, _sm=sm) event.put(send_id="beacon_signal") sm.cancel_event("beacon_signal") await asyncio.sleep(0.1) await sm_runner.processing_loop(sm) assert "dark" in sm.configuration_values async def test_zero_delay_fires_immediately(self, sm_runner): """delay=0 fires immediately.""" class BeaconsOfGondor(StateChart): dark = State(initial=True) lit = State(final=True) light = dark.to(lit) sm = await sm_runner.start(BeaconsOfGondor) await sm_runner.send(sm, "light", delay=0) assert "lit" in sm.configuration_values async def test_delayed_event_on_event_definition(self, sm_runner): """Event(transitions, delay=100) syntax queues with a delay.""" class BeaconsOfGondor(StateChart): dark = State(initial=True) lit = State(final=True) light = Event(dark.to(lit), delay=50) sm = await sm_runner.start(BeaconsOfGondor) # Queue via BoundEvent.put() to avoid blocking in processing_loop event = BoundEvent(id="light", name="Light", delay=50, _sm=sm) event.put() # Not yet processed assert "dark" in sm.configuration_values await asyncio.sleep(0.1) await sm_runner.processing_loop(sm) assert "lit" in sm.configuration_values ================================================ FILE: tests/test_statechart_donedata.py ================================================ """Donedata on final states passes data to done.state handlers. Tests exercise callable donedata returning dicts, done.state transitions triggered with data, nested compound donedata propagation, InvalidDefinition for donedata on non-final states, and listener capture of done event kwargs. Theme: Quest completion — returning data about how the quest ended. """ import pytest from statemachine.exceptions import InvalidDefinition from statemachine import Event from statemachine import State from statemachine import StateChart from tests.machines.donedata.destroy_the_ring import DestroyTheRing from tests.machines.donedata.destroy_the_ring_simple import DestroyTheRingSimple from tests.machines.donedata.nested_quest_donedata import NestedQuestDoneData from tests.machines.donedata.quest_for_erebor_done_convention import QuestForEreborDoneConvention from tests.machines.donedata.quest_for_erebor_explicit_id import QuestForEreborExplicitId from tests.machines.donedata.quest_for_erebor_multi_word import QuestForEreborMultiWord from tests.machines.donedata.quest_for_erebor_with_event import QuestForEreborWithEvent @pytest.mark.timeout(5) class TestDoneData: async def test_donedata_callable_returns_dict(self, sm_runner): """Handler receives donedata as kwargs.""" sm = await sm_runner.start(DestroyTheRing) await sm_runner.send(sm, "finish") assert sm.received["ring_destroyed"] is True assert sm.received["hero"] == "frodo" async def test_donedata_fires_done_state_with_data(self, sm_runner): """done.state event fires and triggers a transition.""" sm = await sm_runner.start(DestroyTheRingSimple) await sm_runner.send(sm, "finish") assert {"celebration"} == set(sm.configuration_values) async def test_donedata_in_nested_compound(self, sm_runner): """Inner done.state propagates up through nesting.""" sm = await sm_runner.start(NestedQuestDoneData) await sm_runner.send(sm, "go") # inner finishes -> done.state.inner -> after_inner (final) # -> done.state.outer -> final assert {"final"} == set(sm.configuration_values) def test_donedata_only_on_final_state(self): """InvalidDefinition if donedata is on a non-final state.""" with pytest.raises(InvalidDefinition, match="donedata.*final"): class BadDoneData(StateChart): s1 = State(initial=True, donedata="oops") s2 = State(final=True) go = s1.to(s2) async def test_donedata_with_listener(self, sm_runner): """Listener captures done event kwargs.""" captured = {} class QuestListener: def on_enter_celebration(self, ring_destroyed=None, **kwargs): captured["ring_destroyed"] = ring_destroyed class DestroyTheRingWithListener(StateChart): class quest(State.Compound): traveling = State(initial=True) completed = State(final=True, donedata="get_result") finish = traveling.to(completed) def get_result(self): return {"ring_destroyed": True} celebration = State(final=True) done_state_quest = Event(quest.to(celebration)) listener = QuestListener() sm = await sm_runner.start(DestroyTheRingWithListener, listeners=[listener]) await sm_runner.send(sm, "finish") assert {"celebration"} == set(sm.configuration_values) @pytest.mark.timeout(5) class TestDoneStateConvention: async def test_done_state_convention_with_transition_list(self, sm_runner): """Bare TransitionList with done_state_ name auto-registers done.state.X.""" sm = await sm_runner.start(QuestForEreborDoneConvention) await sm_runner.send(sm, "finish") assert {"celebration"} == set(sm.configuration_values) async def test_done_state_convention_with_event_no_explicit_id(self, sm_runner): """Event() wrapper without explicit id= applies the convention.""" sm = await sm_runner.start(QuestForEreborWithEvent) await sm_runner.send(sm, "finish") assert {"celebration"} == set(sm.configuration_values) async def test_done_state_convention_preserves_explicit_id(self, sm_runner): """Explicit id= takes precedence over the convention.""" sm = await sm_runner.start(QuestForEreborExplicitId) await sm_runner.send(sm, "finish") assert {"celebration"} == set(sm.configuration_values) async def test_done_state_convention_with_multi_word_state(self, sm_runner): """done_state_lonely_mountain maps to done.state.lonely_mountain.""" sm = await sm_runner.start(QuestForEreborMultiWord) await sm_runner.send(sm, "enter_mountain") assert {"victory"} == set(sm.configuration_values) ================================================ FILE: tests/test_statechart_error.py ================================================ """Error handling in compound and parallel contexts. Tests exercise error.execution firing when on_enter raises in a compound child, error handling in parallel regions, and error.execution transitions that leave a compound state entirely. """ import pytest from statemachine import Event from statemachine import State from statemachine import StateChart @pytest.mark.timeout(5) class TestErrorExecutionStatechart: async def test_error_in_compound_child_onentry(self, sm_runner): """Error in on_enter of compound child fires error.execution.""" class CompoundError(StateChart): class realm(State.Compound): safe = State(initial=True) danger = State() enter_danger = safe.to(danger) def on_enter_danger(self): raise RuntimeError("Balrog awakens!") error_state = State(final=True) error_execution = Event(realm.to(error_state), id="error.execution") sm = await sm_runner.start(CompoundError) await sm_runner.send(sm, "enter_danger") assert {"error_state"} == set(sm.configuration_values) async def test_error_in_parallel_region_isolation(self, sm_runner): """Error in one parallel region; error.execution handles the exit.""" class ParallelError(StateChart): class fronts(State.Parallel): class battle_a(State.Compound): fighting = State(initial=True) victory = State() win = fighting.to(victory) def on_enter_victory(self): raise RuntimeError("Ambush!") class battle_b(State.Compound): holding = State(initial=True) won = State(final=True) triumph = holding.to(won) error_state = State(final=True) error_execution = Event(fronts.to(error_state), id="error.execution") sm = await sm_runner.start(ParallelError) await sm_runner.send(sm, "win") assert {"error_state"} == set(sm.configuration_values) async def test_error_recovery_exits_compound(self, sm_runner): """error.execution transition leaves compound state entirely.""" class CompoundRecovery(StateChart): class dungeon(State.Compound): room_a = State(initial=True) room_b = State() explore = room_a.to(room_b) def on_enter_room_b(self): raise RuntimeError("Trap!") safe = State(final=True) error_execution = Event(dungeon.to(safe), id="error.execution") sm = await sm_runner.start(CompoundRecovery) await sm_runner.send(sm, "explore") assert {"safe"} == set(sm.configuration_values) assert "dungeon" not in sm.configuration_values ================================================ FILE: tests/test_statechart_eventless.py ================================================ """Eventless (automatic) transitions with guards. Tests exercise eventless transitions that fire when conditions are met, stay inactive when conditions are false, cascade through chains in a single macrostep, work with gradual threshold conditions, and combine with In() guards. Theme: The One Ring's corruption and Beacons of Gondor. """ import pytest from tests.machines.eventless.auto_advance import AutoAdvance from tests.machines.eventless.beacon_chain import BeaconChain from tests.machines.eventless.beacon_chain_lighting import BeaconChainLighting from tests.machines.eventless.coordinated_advance import CoordinatedAdvance from tests.machines.eventless.ring_corruption import RingCorruption from tests.machines.eventless.ring_corruption_with_bear_ring import RingCorruptionWithBearRing from tests.machines.eventless.ring_corruption_with_tick import RingCorruptionWithTick @pytest.mark.timeout(5) class TestEventlessTransitions: async def test_eventless_fires_when_condition_met(self, sm_runner): """Eventless transition fires when guard is True.""" sm = await sm_runner.start(RingCorruption) assert "resisting" in sm.configuration_values sm.ring_power = 6 # Need to trigger processing loop — send a no-op event await sm_runner.send(sm, "tick") assert "corrupted" in sm.configuration_values async def test_eventless_does_not_fire_when_condition_false(self, sm_runner): """Eventless transition stays when guard is False.""" sm = await sm_runner.start(RingCorruptionWithTick) sm.ring_power = 2 await sm_runner.send(sm, "tick") assert "resisting" in sm.configuration_values async def test_eventless_chain_cascades(self, sm_runner): """All beacons light in a single macrostep via unconditional eventless chain.""" sm = await sm_runner.start(BeaconChainLighting) # The chain should cascade through all states in a single macrostep assert {"all_lit"} == set(sm.configuration_values) async def test_eventless_gradual_condition(self, sm_runner): """Multiple events needed before the condition threshold is met.""" sm = await sm_runner.start(RingCorruptionWithBearRing) await sm_runner.send(sm, "bear_ring") # power = 2 assert "resisting" in sm.configuration_values await sm_runner.send(sm, "bear_ring") # power = 4 assert "resisting" in sm.configuration_values await sm_runner.send(sm, "bear_ring") # power = 6 -> threshold exceeded assert "corrupted" in sm.configuration_values async def test_eventless_in_compound_state(self, sm_runner): """Eventless transition between compound children.""" sm = await sm_runner.start(AutoAdvance) # Eventless chain cascades through all children assert {"done"} == set(sm.configuration_values) async def test_eventless_with_in_condition(self, sm_runner): """Eventless transition guarded by In('state_id').""" sm = await sm_runner.start(CoordinatedAdvance) assert "waiting" in sm.configuration_values await sm_runner.send(sm, "move_forward") # Vanguard advances, then rearguard's eventless fires vals = set(sm.configuration_values) assert "advanced" in vals assert "moved_up" in vals async def test_eventless_chain_with_final_triggers_done(self, sm_runner): """Eventless chain reaches final state -> done.state fires.""" sm = await sm_runner.start(BeaconChain) assert {"signal_received"} == set(sm.configuration_values) ================================================ FILE: tests/test_statechart_history.py ================================================ """History state behavior with shallow and deep history. Tests exercise shallow history (remembers last direct child), deep history (remembers exact leaf in nested compounds), default transitions on first visit, multiple exit/reentry cycles, and the history_values dict. Theme: Gollum's dual personality — remembers which was active. """ import pytest from tests.machines.history.deep_memory_of_moria import DeepMemoryOfMoria from tests.machines.history.gollum_personality import GollumPersonality from tests.machines.history.gollum_personality_default_gollum import GollumPersonalityDefaultGollum from tests.machines.history.gollum_personality_with_default import GollumPersonalityWithDefault from tests.machines.history.shallow_moria import ShallowMoria @pytest.mark.timeout(5) class TestHistoryStates: async def test_shallow_history_remembers_last_child(self, sm_runner): """Exit compound, re-enter via history -> restores last active child.""" sm = await sm_runner.start(GollumPersonality) await sm_runner.send(sm, "dark_side") assert "gollum" in sm.configuration_values await sm_runner.send(sm, "leave") assert {"outside"} == set(sm.configuration_values) await sm_runner.send(sm, "return_via_history") assert "gollum" in sm.configuration_values assert "personality" in sm.configuration_values async def test_shallow_history_default_on_first_visit(self, sm_runner): """No prior visit -> history uses default transition target.""" sm = await sm_runner.start(GollumPersonalityWithDefault) assert {"outside"} == set(sm.configuration_values) await sm_runner.send(sm, "enter_via_history") assert "smeagol" in sm.configuration_values async def test_deep_history_remembers_full_descendant(self, sm_runner): """Deep history restores the exact leaf in a nested compound.""" sm = await sm_runner.start(DeepMemoryOfMoria) await sm_runner.send(sm, "explore") assert "chamber" in sm.configuration_values await sm_runner.send(sm, "escape") assert {"outside"} == set(sm.configuration_values) await sm_runner.send(sm, "return_deep") assert "chamber" in sm.configuration_values assert "halls" in sm.configuration_values assert "moria" in sm.configuration_values async def test_multiple_exits_and_reentries(self, sm_runner): """History updates each time we exit the compound.""" sm = await sm_runner.start(GollumPersonality) await sm_runner.send(sm, "leave") await sm_runner.send(sm, "return_via_history") assert "smeagol" in sm.configuration_values await sm_runner.send(sm, "dark_side") await sm_runner.send(sm, "leave") await sm_runner.send(sm, "return_via_history") assert "gollum" in sm.configuration_values await sm_runner.send(sm, "light_side") await sm_runner.send(sm, "leave") await sm_runner.send(sm, "return_via_history") assert "smeagol" in sm.configuration_values async def test_history_after_state_change(self, sm_runner): """Change state within compound, exit, re-enter -> new state restored.""" sm = await sm_runner.start(GollumPersonality) await sm_runner.send(sm, "dark_side") await sm_runner.send(sm, "leave") await sm_runner.send(sm, "return_via_history") assert "gollum" in sm.configuration_values async def test_shallow_only_remembers_immediate_child(self, sm_runner): """Shallow history in nested compound restores direct child, not grandchild.""" sm = await sm_runner.start(ShallowMoria) await sm_runner.send(sm, "explore") assert "chamber" in sm.configuration_values await sm_runner.send(sm, "escape") await sm_runner.send(sm, "return_shallow") # Shallow history restores 'halls' as the direct child, # but re-enters halls at its initial state (entrance), not chamber assert "halls" in sm.configuration_values assert "entrance" in sm.configuration_values async def test_history_values_dict_populated(self, sm_runner): """sm.history_values[history_id] has saved states after exit.""" sm = await sm_runner.start(GollumPersonality) await sm_runner.send(sm, "dark_side") await sm_runner.send(sm, "leave") assert "h" in sm.history_values saved = sm.history_values["h"] assert len(saved) == 1 assert saved[0].id == "gollum" async def test_history_with_default_transition(self, sm_runner): """HistoryState with explicit default .to() transition.""" sm = await sm_runner.start(GollumPersonalityDefaultGollum) await sm_runner.send(sm, "enter_via_history") assert "gollum" in sm.configuration_values ================================================ FILE: tests/test_statechart_in_condition.py ================================================ """In('state_id') condition for cross-state checks. Tests exercise In() conditions that enable/block transitions based on whether a given state is active, cross-region In() in parallel states, In() with compound descendants, combined event + In() guards, and eventless + In() guards. Theme: Fellowship coordination — actions depend on where members are. """ import pytest from tests.machines.in_condition.combined_guard import CombinedGuard from tests.machines.in_condition.descendant_check import DescendantCheck from tests.machines.in_condition.eventless_in import EventlessIn from tests.machines.in_condition.fellowship import Fellowship from tests.machines.in_condition.fellowship_coordination import FellowshipCoordination from tests.machines.in_condition.gate_of_moria import GateOfMoria @pytest.mark.timeout(5) class TestInCondition: async def test_in_condition_true_enables_transition(self, sm_runner): """In('state_id') when state is active -> transition fires.""" sm = await sm_runner.start(Fellowship) await sm_runner.send(sm, "journey") vals = set(sm.configuration_values) assert "mordor_f" in vals assert "mordor_s" in vals async def test_in_condition_false_blocks_transition(self, sm_runner): """In('state_id') when state is not active -> transition blocked.""" sm = await sm_runner.start(GateOfMoria) await sm_runner.send(sm, "enter_gate") assert "outside" in sm.configuration_values async def test_in_with_parallel_regions(self, sm_runner): """Cross-region In() evaluation in parallel states.""" sm = await sm_runner.start(FellowshipCoordination) vals = set(sm.configuration_values) assert "waiting" in vals assert "scouting" in vals await sm_runner.send(sm, "report") vals = set(sm.configuration_values) assert "reported" in vals assert "marching" in vals async def test_in_with_compound_descendant(self, sm_runner): """In('child') when child is an active descendant.""" sm = await sm_runner.start(DescendantCheck) await sm_runner.send(sm, "conquer") assert "realm" in sm.configuration_values await sm_runner.send(sm, "ascend") assert "castle" in sm.configuration_values await sm_runner.send(sm, "conquer") assert {"conquered"} == set(sm.configuration_values) async def test_in_combined_with_event(self, sm_runner): """Event + In() guard together.""" sm = await sm_runner.start(CombinedGuard) await sm_runner.send(sm, "charge") assert "idle" in sm.configuration_values await sm_runner.send(sm, "return_scout") await sm_runner.send(sm, "charge") assert "attacking" in sm.configuration_values async def test_in_with_eventless_transition(self, sm_runner): """Eventless + In() guard.""" sm = await sm_runner.start(EventlessIn) assert "waiting" in sm.configuration_values await sm_runner.send(sm, "get_ready") vals = set(sm.configuration_values) assert "ready" in vals assert "moving" in vals ================================================ FILE: tests/test_statechart_parallel.py ================================================ """Parallel state behavior with independent regions. Tests exercise entering parallel states (all regions activate), region isolation (events in one region don't affect others), exiting parallel states, done.state when all regions reach final, and mixed compound/parallel hierarchies. Theme: War of the Ring — multiple simultaneous fronts. """ import pytest from tests.machines.parallel.session import Session from tests.machines.parallel.session_with_done_state import SessionWithDoneState from tests.machines.parallel.two_towers import TwoTowers from tests.machines.parallel.war_of_the_ring import WarOfTheRing from tests.machines.parallel.war_with_exit import WarWithExit @pytest.mark.timeout(5) class TestParallelStates: async def test_parallel_activates_all_regions(self, sm_runner): """Entering a parallel state activates the initial child of every region.""" sm = await sm_runner.start(WarOfTheRing) vals = set(sm.configuration_values) assert "war" in vals assert "frodos_quest" in vals assert "shire" in vals assert "aragorns_path" in vals assert "ranger" in vals assert "gandalfs_defense" in vals assert "rohan" in vals async def test_independent_transitions_in_regions(self, sm_runner): """An event in one region does not affect others.""" sm = await sm_runner.start(WarOfTheRing) await sm_runner.send(sm, "journey") vals = set(sm.configuration_values) assert "mordor" in vals assert "ranger" in vals # unchanged assert "rohan" in vals # unchanged async def test_configuration_includes_all_active_states(self, sm_runner): """Configuration set includes all active states across regions.""" sm = await sm_runner.start(WarOfTheRing) config_ids = {s.id for s in sm.configuration} assert config_ids == { "war", "frodos_quest", "shire", "aragorns_path", "ranger", "gandalfs_defense", "rohan", } async def test_exit_parallel_exits_all_regions(self, sm_runner): """Transition out of a parallel clears everything.""" sm = await sm_runner.start(WarWithExit) assert "war" in sm.configuration_values await sm_runner.send(sm, "truce") assert {"peace"} == set(sm.configuration_values) async def test_event_in_one_region_no_effect_on_others(self, sm_runner): """Region isolation: events affect only the targeted region.""" sm = await sm_runner.start(WarOfTheRing) await sm_runner.send(sm, "coronation") vals = set(sm.configuration_values) assert "king" in vals assert "shire" in vals # Frodo's region unchanged assert "rohan" in vals # Gandalf's region unchanged async def test_parallel_with_compound_children(self, sm_runner): """Mixed hierarchy: parallel with compound regions verified.""" sm = await sm_runner.start(WarOfTheRing) assert "shire" in sm.configuration_values assert "ranger" in sm.configuration_values assert "rohan" in sm.configuration_values async def test_current_state_value_set_comparison(self, sm_runner): """configuration_values supports set comparison for parallel states.""" sm = await sm_runner.start(WarOfTheRing) vals = set(sm.configuration_values) expected = { "war", "frodos_quest", "shire", "aragorns_path", "ranger", "gandalfs_defense", "rohan", } assert vals == expected async def test_parallel_done_when_all_regions_final(self, sm_runner): """done.state fires when ALL regions reach a final state.""" sm = await sm_runner.start(TwoTowers) await sm_runner.send(sm, "win") # Only one region is final, battle continues assert "battle" in sm.configuration_values await sm_runner.send(sm, "flood") # Both regions are final -> done.state.battle fires assert {"aftermath"} == set(sm.configuration_values) async def test_parallel_not_done_when_one_region_final(self, sm_runner): """Parallel not done when only one region reaches final.""" sm = await sm_runner.start(TwoTowers) await sm_runner.send(sm, "win") assert "battle" in sm.configuration_values assert "victory" in sm.configuration_values assert "besieging" in sm.configuration_values async def test_transition_within_compound_inside_parallel(self, sm_runner): """Deep transition within a compound region of a parallel state.""" sm = await sm_runner.start(WarOfTheRing) await sm_runner.send(sm, "journey") await sm_runner.send(sm, "destroy_ring") vals = set(sm.configuration_values) assert "mount_doom" in vals assert "ranger" in vals # other regions unchanged async def test_top_level_parallel_terminates_when_all_children_final(self, sm_runner): """A root parallel terminates when all regions reach final states.""" sm = await sm_runner.start(Session) assert sm.is_terminated is False await sm_runner.send(sm, "close_ui") assert sm.is_terminated is False # one region still active await sm_runner.send(sm, "stop_backend") assert sm.is_terminated is True async def test_top_level_parallel_done_state_fires_before_termination(self, sm_runner): """done.state fires and transitions before root-final check terminates.""" sm = await sm_runner.start(SessionWithDoneState) await sm_runner.send(sm, "close_ui") await sm_runner.send(sm, "stop_backend") # done.state.session fires, transitions to finished, then terminates assert {"finished"} == set(sm.configuration_values) assert sm.is_terminated is True async def test_top_level_parallel_not_terminated_when_one_region_pending(self, sm_runner): """Machine keeps running when only one region reaches final.""" sm = await sm_runner.start(Session) await sm_runner.send(sm, "close_ui") assert sm.is_terminated is False assert "closed" in sm.configuration_values assert "running" in sm.configuration_values ================================================ FILE: tests/test_statemachine.py ================================================ import pytest from statemachine.orderedset import OrderedSet from statemachine import HistoryState from statemachine import State from statemachine import StateChart from statemachine import exceptions from tests.models import MyModel def test_machine_repr(campaign_machine): model = MyModel() machine = campaign_machine(model) assert ( repr(machine) == "CampaignMachine(model=MyModel({'state': 'draft'}), " "state_field='state', configuration=['draft'])" ) def test_machine_should_be_at_start_state(campaign_machine): model = MyModel() machine = campaign_machine(model) assert [s.value for s in campaign_machine.states] == [ "draft", "producing", "closed", ] assert [t.name for t in campaign_machine.events] == [ "Add job", "Produce", "Deliver", ] assert model.state == "draft" assert machine.draft.is_active def test_machine_should_only_allow_only_one_initial_state(): with pytest.raises(exceptions.InvalidDefinition): class CampaignMachine(StateChart): "A workflow machine" draft = State(initial=True) producing = State() closed = State( "Closed", initial=True ) # Should raise an Exception right after the class is defined add_job = draft.to(draft) | producing.to(producing) produce = draft.to(producing) deliver = producing.to(closed) def test_machine_should_activate_initial_state(mocker): spy = mocker.Mock() class CampaignMachine(StateChart): "A workflow machine" draft = State(initial=True) producing = State() closed = State(final=True) add_job = draft.to(draft) | producing.to(producing) produce = draft.to(producing) deliver = producing.to(closed) def on_enter_draft(self): spy("draft") return "draft" sm = CampaignMachine() spy.assert_called_once_with("draft") assert sm.draft.is_active assert sm.draft.is_active spy.reset_mock() # trying to activate the initial state again should does nothing assert sm.activate_initial_state() is None spy.assert_not_called() assert sm.draft.is_active assert sm.draft.is_active def test_machine_should_not_allow_transitions_from_final_state(): with pytest.raises(exceptions.InvalidDefinition): class CampaignMachine(StateChart): "A workflow machine" draft = State(initial=True) producing = State() closed = State(final=True) add_job = draft.to(draft) | producing.to(producing) | closed.to(draft) produce = draft.to(producing) deliver = producing.to(closed) def test_should_change_state(campaign_machine): model = MyModel() machine = campaign_machine(model) assert model.state == "draft" assert machine.draft.is_active machine.produce() assert model.state == "producing" assert machine.producing.is_active def test_should_run_a_transition_that_keeps_the_state(campaign_machine): model = MyModel() machine = campaign_machine(model) assert model.state == "draft" assert machine.draft.is_active machine.add_job() assert model.state == "draft" assert machine.draft.is_active machine.produce() assert model.state == "producing" assert machine.producing.is_active machine.add_job() assert model.state == "producing" assert machine.producing.is_active def test_should_change_state_with_multiple_machine_instances(campaign_machine): model1 = MyModel() model2 = MyModel() machine1 = campaign_machine(model1) machine2 = campaign_machine(model2) assert machine1.draft.is_active assert machine2.draft.is_active p1 = machine1.produce p2 = machine2.produce p2() assert machine1.draft.is_active assert machine2.producing.is_active p1() assert machine1.producing.is_active assert machine2.producing.is_active def test_machine_should_list_allowed_events_in_the_current_state(campaign_machine): model = MyModel() machine = campaign_machine(model) assert model.state == "draft" assert [t.name for t in machine.allowed_events] == ["Add job", "Produce"] machine.produce() assert model.state == "producing" assert [t.name for t in machine.allowed_events] == ["Add job", "Deliver"] deliver = machine.allowed_events[1] deliver() assert model.state == "closed" assert machine.allowed_events == [] def test_machine_should_run_a_transition_by_his_key(campaign_machine): model = MyModel() machine = campaign_machine(model) assert model.state == "draft" machine.send("add_job") assert model.state == "draft" assert machine.draft.is_active machine.send("produce") assert model.state == "producing" assert machine.producing.is_active def test_machine_should_use_and_model_attr_other_than_state(campaign_machine): model = MyModel(status="producing") machine = campaign_machine(model, state_field="status") assert getattr(model, "state", None) is None assert model.status == "producing" assert machine.producing.is_active machine.deliver() assert model.status == "closed" assert machine.closed.is_active def test_cant_assign_an_invalid_state_directly(campaign_machine): machine = campaign_machine() with pytest.raises(exceptions.InvalidStateValue): machine.current_state_value = "non existing state" def test_should_allow_validate_data_for_transition(campaign_machine_with_validator): model = MyModel() machine = campaign_machine_with_validator(model) with pytest.raises(LookupError): machine.produce() machine.produce(goods="something") assert model.state == "producing" def test_should_check_if_is_in_status(campaign_machine): model = MyModel() machine = campaign_machine(model) assert machine.draft.is_active assert not machine.producing.is_active assert not machine.closed.is_active machine.produce() assert not machine.draft.is_active assert machine.producing.is_active assert not machine.closed.is_active machine.deliver() assert not machine.draft.is_active assert not machine.producing.is_active assert machine.closed.is_active def test_defined_value_must_be_assigned_to_models(campaign_machine_with_values): model = MyModel() machine = campaign_machine_with_values(model) assert model.state == 1 machine.produce() assert model.state == 2 machine.deliver() assert model.state == 3 def test_state_machine_without_model(campaign_machine): machine = campaign_machine() assert machine.draft.is_active assert not machine.producing.is_active assert not machine.closed.is_active machine.produce() assert not machine.draft.is_active assert machine.producing.is_active assert not machine.closed.is_active @pytest.mark.parametrize( ("model", "machine_name", "start_value"), [ (None, "campaign_machine", "producing"), (None, "campaign_machine_with_values", 2), (MyModel(), "campaign_machine", "producing"), (MyModel(), "campaign_machine_with_values", 2), ], ) def test_state_machine_with_a_start_value(request, model, machine_name, start_value): machine_cls = request.getfixturevalue(machine_name) machine = machine_cls(model, start_value=start_value) assert not machine.draft.is_active assert machine.producing.is_active assert not model or model.state == start_value @pytest.mark.parametrize( ("model", "machine_name", "start_value"), [ (None, "campaign_machine", "tapioca"), (None, "campaign_machine_with_values", 99), (MyModel(), "campaign_machine", "tapioca"), (MyModel(), "campaign_machine_with_values", 99), ], ) def test_state_machine_with_a_invalid_start_value(request, model, machine_name, start_value): machine_cls = request.getfixturevalue(machine_name) with pytest.raises(exceptions.InvalidStateValue): machine_cls(model, start_value=start_value) def test_state_machine_with_a_invalid_model_state_value(request, campaign_machine): machine_cls = campaign_machine model = MyModel(state="tapioca") sm = machine_cls(model) with pytest.raises(KeyError): sm.configuration # noqa: B018 def test_should_not_create_instance_of_abstract_machine(): class EmptyMachine(StateChart): "An empty machine" pass with pytest.raises(exceptions.InvalidDefinition): EmptyMachine() def test_should_not_create_instance_of_machine_without_states(): s1 = State() class OnlyTransitionMachine(StateChart): t1 = s1.to.itself() with pytest.raises(exceptions.InvalidDefinition): OnlyTransitionMachine() def test_should_not_create_instance_of_machine_without_transitions(): with pytest.raises(exceptions.InvalidDefinition): class NoTransitionsMachine(StateChart): "A machine without transitions" initial = State(initial=True) def test_should_not_create_disconnected_machine(): expected = ( r"There are unreachable states. The statemachine graph should have a single component. " r"Disconnected states: \['blue'\]" ) with pytest.raises(exceptions.InvalidDefinition, match=expected): class BrokenTrafficLightMachine(StateChart): "A broken traffic light machine" green = State(initial=True) yellow = State() blue = State() # This state is unreachable cycle = green.to(yellow) | yellow.to(green) def test_should_not_create_big_disconnected_machine(): expected = ( r"There are unreachable states. The statemachine graph should have a single component. " r"Disconnected states: \[.*\]$" ) with pytest.raises(exceptions.InvalidDefinition, match=expected): class BrokenTrafficLightMachine(StateChart): "A broken traffic light machine" green = State(initial=True) yellow = State() magenta = State() # This state is unreachable red = State() cyan = State() blue = State() # This state is also unreachable cycle = green.to(yellow) diverge = green.to(cyan) | cyan.to(red) validate = yellow.to(green) def test_disconnected_validation_bypassed_by_flag(): """Setting validate_disconnected_states=False allows unreachable states.""" class DisconnectedButAllowed(StateChart): validate_disconnected_states = False green = State(initial=True) yellow = State() blue = State() # unreachable, but flag disables the check cycle = green.to(yellow) | yellow.to(green) blink = blue.to.itself() assert "green" in DisconnectedButAllowed.states_map def test_parallel_states_reachable_without_disabling_flag(): """Substates of parallel regions are reachable via hierarchy.""" class ParallelMachine(StateChart): class top(State.Parallel): class region1(State.Compound): a = State(initial=True) b = State(final=True) go = a.to(b) class region2(State.Compound): c = State(initial=True) d = State(final=True) go2 = c.to(d) assert "a" in ParallelMachine.states_map assert "c" in ParallelMachine.states_map def test_compound_substates_reachable_without_disabling_flag(): """Substates of a compound state are reachable via hierarchy.""" class CompoundMachine(StateChart): start = State(initial=True) class parent(State.Compound): child1 = State(initial=True) child2 = State(final=True) inner = child1.to(child2) enter = start.to(parent) assert "child1" in CompoundMachine.states_map assert "child2" in CompoundMachine.states_map def test_history_state_reachable_without_disabling_flag(): """History states and their parent compound are reachable via hierarchy.""" class HistoryMachine(StateChart): outside = State(initial=True) class compound(State.Compound): a = State(initial=True) b = State() h = HistoryState() go = a.to(b) enter_via_history = outside.to(compound.h) leave = compound.to(outside) assert "compound" in HistoryMachine.states_map assert "a" in HistoryMachine.states_map def test_state_value_is_correct(): STATE_NEW = 0 STATE_DRAFT = 1 class ValueTestModel(StateChart): new = State(STATE_NEW, value=STATE_NEW, initial=True) draft = State(STATE_DRAFT, value=STATE_DRAFT, final=True) write = new.to(draft) model = ValueTestModel() assert model.new.value == STATE_NEW assert model.draft.value == STATE_DRAFT def test_final_states(campaign_machine_with_final_state): model = MyModel() machine = campaign_machine_with_final_state(model) final_states = machine.final_states assert len(final_states) == 1 assert final_states[0].name == "Closed" def test_should_not_override_states_properties(campaign_machine): machine = campaign_machine() with pytest.raises(exceptions.StateMachineError) as e: machine.draft = "something else" assert "State overriding is not allowed. Trying to add 'something else' to draft" in str(e) class TestWarnings: def test_should_warn_if_model_already_has_attribute_and_binding_is_enabled( self, campaign_machine_with_final_state, capsys ): class Model: state = "draft" def produce(self): return f"producing from {self.__class__.__name__!r}" model = Model() sm = campaign_machine_with_final_state(model) with pytest.warns( UserWarning, match="Attribute 'produce' already exists on 10 sm = MyMachine() assert sm.enabled_events() == [] assert [e.id for e in sm.enabled_events(value=20)] == ["go"] def test_condition_exception_treated_as_enabled(self): """If a condition raises, the event is treated as enabled (permissive).""" class MyMachine(StateChart): s0 = State(initial=True) s1 = State(final=True) go = s0.to(s1, cond="bad_cond") def bad_cond(self): raise RuntimeError("boom") sm = MyMachine() assert [e.id for e in sm.enabled_events()] == ["go"] def test_mixed_enabled_and_disabled(self): class MyMachine(StateChart): s0 = State(initial=True) s1 = State(final=True) s2 = State(final=True) go = s0.to(s1, cond="cond_true") stop = s0.to(s2, cond="cond_false") def cond_true(self): return True def cond_false(self): return False sm = MyMachine() assert [e.id for e in sm.enabled_events()] == ["go"] def test_unless_condition(self): class MyMachine(StateChart): s0 = State(initial=True) s1 = State(final=True) go = s0.to(s1, unless="is_blocked") def is_blocked(self): return True sm = MyMachine() assert sm.enabled_events() == [] def test_unless_condition_passes(self): class MyMachine(StateChart): s0 = State(initial=True) s1 = State(final=True) go = s0.to(s1, unless="is_blocked") def is_blocked(self): return False sm = MyMachine() assert [e.id for e in sm.enabled_events()] == ["go"] class TestInvalidStateValueNonNone: """current_state raises InvalidStateValue when state value is non-None but invalid.""" def test_invalid_non_none_state_value(self): import warnings class SM(StateChart): idle = State(initial=True) active = State(final=True) go = idle.to(active) sm = SM() # Bypass setter validation by writing directly to the model attribute setattr(sm.model, sm.state_field, "nonexistent_state") with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) with pytest.raises(exceptions.InvalidStateValue): _ = sm.current_state class TestInitKwargsPropagation: """Constructor kwargs are forwarded to initial state entry callbacks.""" async def test_kwargs_available_in_on_enter_initial(self, sm_runner): class SM(StateChart): idle = State(initial=True) done = State(final=True) go = idle.to(done) def on_enter_idle(self, greeting=None, **kwargs): self.greeting = greeting sm = await sm_runner.start(SM, greeting="hello") assert sm.greeting == "hello" async def test_kwargs_flow_through_eventless_transitions(self, sm_runner): class Pipeline(StateChart): start = State(initial=True) processing = State() done = State(final=True) start.to(processing) processing.to(done) def on_enter_start(self, task_id=None, **kwargs): self.task_id = task_id sm = await sm_runner.start(Pipeline, task_id="abc-123") assert sm.task_id == "abc-123" assert "done" in sm.configuration_values async def test_no_kwargs_still_works(self, sm_runner): class SM(StateChart): idle = State(initial=True) done = State(final=True) go = idle.to(done) def on_enter_idle(self, **kwargs): self.entered = True sm = await sm_runner.start(SM) assert sm.entered is True async def test_multiple_kwargs(self, sm_runner): class SM(StateChart): idle = State(initial=True) done = State(final=True) go = idle.to(done) def on_enter_idle(self, host=None, port=None, **kwargs): self.host = host self.port = port sm = await sm_runner.start(SM, host="localhost", port=5432) assert sm.host == "localhost" assert sm.port == 5432 async def test_kwargs_in_invoke_handler(self, sm_runner): """Init kwargs flow to invoke handlers via dependency injection.""" class SM(StateChart): loading = State(initial=True) ready = State(final=True) done_invoke_loading = loading.to(ready) def on_invoke_loading(self, url=None, **kwargs): return f"fetched:{url}" def on_enter_ready(self, data=None, **kwargs): self.result = data sm = await sm_runner.start(SM, url="https://example.com") await sm_runner.sleep(0.2) await sm_runner.processing_loop(sm) assert "ready" in sm.configuration_values assert sm.result == "fetched:https://example.com" ================================================ FILE: tests/test_statemachine_bounded_transitions.py ================================================ from unittest import mock import pytest from statemachine import State from statemachine import StateChart from .models import MyModel @pytest.fixture() def event_mock(): return mock.MagicMock() @pytest.fixture() def state_machine(event_mock): class CampaignMachine(StateChart): draft = State(initial=True) producing = State() closed = State(final=True) add_job = draft.to(draft) | producing.to(producing) produce = draft.to(producing) deliver = producing.to(closed) def on_enter_producing(self, param1=None, param2=None): event_mock.on_enter_producing(param1=param1, param2=param2) def on_exit_draft(self, param1=None, param2=None): event_mock.on_exit_draft(param1=param1, param2=param2) def on_enter_closed(self): event_mock.on_enter_closed() def on_exit_producing(self): event_mock.on_exit_producing() return CampaignMachine def test_run_transition_pass_arguments_to_sub_transitions( state_machine, event_mock, ): model = MyModel(state="draft") machine = state_machine(model) machine.send("produce", param1="value1", param2="value2") assert model.state == "producing" event_mock.on_enter_producing.assert_called_with(param1="value1", param2="value2") event_mock.on_exit_draft.assert_called_with(param1="value1", param2="value2") machine.send("deliver", param3="value3") event_mock.on_enter_closed.assert_called_with() event_mock.on_exit_producing.assert_called_with() ================================================ FILE: tests/test_statemachine_compat.py ================================================ """Backward-compatibility tests for the StateMachine (v2) API. These tests verify that ``StateMachine`` (which inherits from ``StateChart`` with different defaults) continues to work as expected. Tests here exercise behaviour that differs from ``StateChart`` defaults: - ``allow_event_without_transition = False`` → ``TransitionNotAllowed`` - ``enable_self_transition_entries = False`` - ``atomic_configuration_update = True`` - ``catch_errors_as_events = False`` → exceptions propagate directly - ``current_state`` deprecated property """ import warnings import pytest from statemachine import State from statemachine import StateMachine from statemachine import exceptions # --------------------------------------------------------------------------- # Flag defaults # --------------------------------------------------------------------------- class TestStateMachineDefaults: """Verify the four class-level flag defaults on StateMachine.""" def test_allow_event_without_transition(self): assert StateMachine.allow_event_without_transition is False def test_enable_self_transition_entries(self): assert StateMachine.enable_self_transition_entries is False def test_atomic_configuration_update(self): assert StateMachine.atomic_configuration_update is True def test_catch_errors_as_events(self): assert StateMachine.catch_errors_as_events is False # --------------------------------------------------------------------------- # Smoke test # --------------------------------------------------------------------------- class TestStateMachineSmoke: """StateMachine as a subclass works for basic operations.""" def test_create_send_and_check_state(self): class TrafficLight(StateMachine): green = State(initial=True) yellow = State() red = State() cycle = green.to(yellow) | yellow.to(red) | red.to(green) sm = TrafficLight() assert sm.green.is_active sm.send("cycle") assert sm.yellow.is_active sm.send("cycle") assert sm.red.is_active def test_final_state_terminates(self): class Simple(StateMachine): s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) sm = Simple() sm.send("go") assert sm.is_terminated # --------------------------------------------------------------------------- # TransitionNotAllowed (allow_event_without_transition = False) # --------------------------------------------------------------------------- class TestTransitionNotAllowed: """StateMachine raises TransitionNotAllowed for invalid events.""" @pytest.fixture() def sm(self): class Workflow(StateMachine): draft = State(initial=True) published = State(final=True) publish = draft.to(published) return Workflow() def test_invalid_event_raises(self, sm): with pytest.raises(exceptions.TransitionNotAllowed): sm.send("nonexistent") def test_event_not_available_in_current_state(self, sm): sm.send("publish") with pytest.raises(exceptions.TransitionNotAllowed): sm.send("publish") def test_condition_blocks_transition(self): class Gated(StateMachine): s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2, cond="allowed") allowed: bool = False sm = Gated() with pytest.raises(sm.TransitionNotAllowed): sm.go() def test_multiple_destinations_all_blocked(self): def never(event_data): return False class Multi(StateMachine): requested = State(initial=True) accepted = State(final=True) rejected = State(final=True) validate = requested.to(accepted, cond=never) | requested.to( rejected, cond="also_never" ) @property def also_never(self): return False sm = Multi() with pytest.raises(exceptions.TransitionNotAllowed): sm.validate() assert sm.requested.is_active def test_from_any_with_cond_blocked(self): class Account(StateMachine): active = State(initial=True) closed = State(final=True) close = closed.from_.any(cond="can_close") can_close: bool = False sm = Account() with pytest.raises(sm.TransitionNotAllowed): sm.close() def test_condition_algebra_any_false(self): class CondAlgebra(StateMachine): start = State(initial=True) end = State(final=True) submit = start.to(end, cond="used_money or used_credit") used_money: bool = False used_credit: bool = False sm = CondAlgebra() with pytest.raises(sm.TransitionNotAllowed): sm.submit() # --------------------------------------------------------------------------- # TransitionNotAllowed — async # --------------------------------------------------------------------------- class TestTransitionNotAllowedAsync: """TransitionNotAllowed in async machines.""" @pytest.fixture() def async_sm_cls(self): class AsyncWorkflow(StateMachine): s1 = State(initial=True) s2 = State() s3 = State(final=True) go = s1.to(s2, cond="is_ready") finish = s2.to(s3) is_ready: bool = False async def on_go(self): ... return AsyncWorkflow async def test_async_transition_not_allowed(self, async_sm_cls): sm = async_sm_cls() await sm.activate_initial_state() with pytest.raises(sm.TransitionNotAllowed): await sm.send("go") def test_sync_context_transition_not_allowed(self, async_sm_cls): sm = async_sm_cls() with pytest.raises(sm.TransitionNotAllowed): sm.send("go") async def test_async_condition_blocks(self): class AsyncCond(StateMachine): s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2, cond="check") async def check(self): return False sm = AsyncCond() await sm.activate_initial_state() with pytest.raises(sm.TransitionNotAllowed): await sm.go() # --------------------------------------------------------------------------- # catch_errors_as_events = False (exceptions propagate directly) # --------------------------------------------------------------------------- class TestErrorOnExecutionFalse: """With catch_errors_as_events=False, exceptions propagate without being caught.""" def test_runtime_error_in_action_propagates(self): class SM(StateMachine): s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) def on_go(self): raise RuntimeError("boom") sm = SM() with pytest.raises(RuntimeError, match="boom"): sm.send("go") def test_runtime_error_in_after_propagates(self): class SM(StateMachine): s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) def after_go(self): raise RuntimeError("after boom") sm = SM() with pytest.raises(RuntimeError, match="after boom"): sm.send("go") @pytest.mark.timeout(5) async def test_async_runtime_error_in_after_propagates(self): class SM(StateMachine): s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) async def after_go(self, **kwargs): raise RuntimeError("async after boom") sm = SM() await sm.activate_initial_state() with pytest.raises(RuntimeError, match="async after boom"): await sm.send("go") # --------------------------------------------------------------------------- # enable_self_transition_entries = False # --------------------------------------------------------------------------- class TestSelfTransitionNoEntries: """With enable_self_transition_entries=False, internal self-transitions do NOT fire entry/exit. Note: ``enable_self_transition_entries`` only applies to *internal* self-transitions (``internal=True``). External self-transitions always fire entry/exit regardless. """ def test_internal_self_transition_does_not_fire_enter_exit(self): log = [] class SM(StateMachine): s1 = State(initial=True) loop = s1.to.itself(internal=True) def on_enter_s1(self): log.append("enter_s1") def on_exit_s1(self): log.append("exit_s1") sm = SM() log.clear() # clear initial enter sm.send("loop") assert "enter_s1" not in log assert "exit_s1" not in log def test_external_self_transition_fires_enter_exit(self): """External self-transitions always fire, regardless of the flag.""" log = [] class SM(StateMachine): s1 = State(initial=True) loop = s1.to.itself() def on_enter_s1(self): log.append("enter_s1") def on_exit_s1(self): log.append("exit_s1") sm = SM() log.clear() sm.send("loop") assert "enter_s1" in log assert "exit_s1" in log # --------------------------------------------------------------------------- # current_state deprecated property # --------------------------------------------------------------------------- class TestCurrentStateDeprecated: """The current_state property emits DeprecationWarning but still works.""" def test_current_state_returns_state(self): class SM(StateMachine): s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) sm = SM() with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) cs = sm.current_state assert cs == sm.s1 def test_current_state_emits_warning(self): class SM(StateMachine): s1 = State(initial=True) s2 = State(final=True) go = s1.to(s2) sm = SM() with pytest.warns(DeprecationWarning, match="current_state"): _ = sm.current_state # noqa: F841 ================================================ FILE: tests/test_statemachine_inheritance.py ================================================ import pytest from statemachine import exceptions @pytest.fixture() def BaseMachine(): from statemachine import State from statemachine import StateChart class BaseMachine(StateChart): state_1 = State(initial=True) state_2 = State() trans_1_2 = state_1.to(state_2) trans_2_2 = state_2.to.itself(internal=True) return BaseMachine @pytest.fixture() def InheritedClass(BaseMachine): class InheritedClass(BaseMachine): pass return InheritedClass @pytest.fixture() def ExtendedClass(BaseMachine): from statemachine import State class ExtendedClass(BaseMachine): state_3 = State() trans_2_3 = BaseMachine.state_2.to(state_3) trans_3_3 = state_3.to.itself(internal=True) return ExtendedClass @pytest.fixture() def OverridedClass(BaseMachine): from statemachine import State class OverridedClass(BaseMachine): state_2 = State() trans_1_2 = BaseMachine.state_1.to(state_2) trans_2_2 = state_2.to.itself(internal=True) return OverridedClass @pytest.fixture() def OverridedTransitionClass(BaseMachine): from statemachine import State class OverridedTransitionClass(BaseMachine): state_3 = State() trans_1_2 = BaseMachine.state_1.to(state_3) trans_3_3 = state_3.to.itself(internal=True) return OverridedTransitionClass def test_should_inherit_states_and_transitions(BaseMachine, InheritedClass): assert InheritedClass.states == [ BaseMachine.state_1, BaseMachine.state_2, ] expected = [e.name for e in BaseMachine.events] actual = [e.name for e in InheritedClass.events] assert actual == expected def test_should_extend_states_and_transitions(BaseMachine, ExtendedClass): assert ExtendedClass.states == [ BaseMachine.state_1, BaseMachine.state_2, ExtendedClass.state_3, ] base_events = [e.name for e in BaseMachine.events] expected = base_events + [ExtendedClass.trans_2_3.name, ExtendedClass.trans_3_3.name] actual = [e.name for e in ExtendedClass.events] assert actual == expected def test_should_execute_transitions(ExtendedClass): instance = ExtendedClass() instance.trans_1_2() instance.trans_2_3() assert instance.state_3.is_active @pytest.mark.xfail(reason="State overriding is not supported") def test_dont_support_overriden_states(OverridedClass): # There's no support for overriding states with pytest.raises(exceptions.InvalidDefinition): OverridedClass() @pytest.mark.xfail(reason="Transition overriding is not supported") def test_support_override_transitions(OverridedTransitionClass): instance = OverridedTransitionClass() instance.trans_1_2() assert instance.state_3.is_active ================================================ FILE: tests/test_threading.py ================================================ import threading import time from collections import Counter import pytest from statemachine.state import State from statemachine.statemachine import StateChart def test_machine_should_allow_multi_thread_event_changes(): """ Test for https://github.com/fgmacedo/python-statemachine/issues/443 """ class CampaignMachine(StateChart): "A workflow machine" draft = State(initial=True) producing = State() closed = State(final=True) add_job = draft.to(producing) | producing.to(closed) machine = CampaignMachine() def off_thread_change_state(): time.sleep(0.01) machine.add_job() thread = threading.Thread(target=off_thread_change_state) thread.start() thread.join() assert machine.current_state_value == "producing" def test_regression_443(): """ Test for https://github.com/fgmacedo/python-statemachine/issues/443 """ total_iterations = 4 send_at_iteration = 3 # 0-indexed: send before the 4th sample class TrafficLightMachine(StateChart): "A traffic light machine" green = State(initial=True) yellow = State() red = State() cycle = green.to(yellow) | yellow.to(red) | red.to(green) class Controller: def __init__(self): self.statuses_history = [] self.fsm = TrafficLightMachine() # set up thread self.thread = threading.Thread(target=self.recv_cmds) self.thread.start() def recv_cmds(self): """Pretend we receive a command triggering a state change.""" for i in range(total_iterations): if i == send_at_iteration: self.fsm.cycle() self.statuses_history.append(self.fsm.current_state_value) c1 = Controller() c2 = Controller() c1.thread.join() c2.thread.join() assert c1.statuses_history == ["green", "green", "green", "yellow"] assert c2.statuses_history == ["green", "green", "green", "yellow"] def test_regression_443_with_modifications(): """ Test for https://github.com/fgmacedo/python-statemachine/issues/443 """ total_iterations = 4 send_at_iteration = 3 # 0-indexed: send before the 4th sample class TrafficLightMachine(StateChart): "A traffic light machine" green = State(initial=True) yellow = State() red = State() cycle = green.to(yellow) | yellow.to(red) | red.to(green) def __init__(self, name): self.name = name self.statuses_history = [] super().__init__() def beat(self): for i in range(total_iterations): if i == send_at_iteration: self.cycle() self.statuses_history.append(f"{self.name}.{self.current_state_value}") class Controller: def __init__(self, name): self.fsm = TrafficLightMachine(name) # set up thread self.thread = threading.Thread(target=self.fsm.beat) self.thread.start() c1 = Controller("c1") c2 = Controller("c2") c3 = Controller("c3") c1.thread.join() c2.thread.join() c3.thread.join() assert c1.fsm.statuses_history == ["c1.green", "c1.green", "c1.green", "c1.yellow"] assert c2.fsm.statuses_history == ["c2.green", "c2.green", "c2.green", "c2.yellow"] assert c3.fsm.statuses_history == ["c3.green", "c3.green", "c3.green", "c3.yellow"] class TestThreadSafety: """Stress tests for concurrent access to a single state machine instance. These tests exercise real contention: multiple threads sending events to the same SM simultaneously, synchronized via barriers to maximize overlap. """ @pytest.fixture() def cycling_machine(self): class CyclingMachine(StateChart): s1 = State(initial=True) s2 = State() s3 = State() cycle = s1.to(s2) | s2.to(s3) | s3.to(s1) return CyclingMachine() @pytest.mark.parametrize("num_threads", [4, 8]) def test_concurrent_sends_no_lost_events(self, cycling_machine, num_threads): """All events sent concurrently must be processed — none lost.""" events_per_thread = 300 total_events = num_threads * events_per_thread barrier = threading.Barrier(num_threads) errors = [] def sender(): try: barrier.wait(timeout=5) for _ in range(events_per_thread): cycling_machine.send("cycle") except Exception as e: errors.append(e) threads = [threading.Thread(target=sender) for _ in range(num_threads)] for t in threads: t.start() for t in threads: t.join(timeout=30) assert not errors, f"Thread errors: {errors}" # The machine cycles s1→s2→s3→s1. After N total cycle events starting # from s1, the state is determined by (N % 3). expected_states = {0: "s1", 1: "s2", 2: "s3"} expected = expected_states[total_events % 3] assert cycling_machine.current_state_value == expected def test_concurrent_sends_state_consistency(self, cycling_machine): """State must always be one of the valid states, never corrupted.""" valid_values = {"s1", "s2", "s3"} num_threads = 6 events_per_thread = 500 barrier = threading.Barrier(num_threads + 1) # +1 for observer stop_event = threading.Event() observed_values = [] errors = [] def sender(): try: barrier.wait(timeout=5) for _ in range(events_per_thread): cycling_machine.send("cycle") except Exception as e: errors.append(e) def observer(): barrier.wait(timeout=5) while not stop_event.is_set(): val = cycling_machine.current_state_value observed_values.append(val) threads = [threading.Thread(target=sender) for _ in range(num_threads)] obs_thread = threading.Thread(target=observer) for t in threads: t.start() obs_thread.start() for t in threads: t.join(timeout=30) stop_event.set() obs_thread.join(timeout=5) assert not errors, f"Thread errors: {errors}" # None may appear transiently during configuration updates — that's expected. invalid = [v for v in observed_values if v not in valid_values and v is not None] assert not invalid, f"Observed invalid state values: {set(invalid)}" assert len(observed_values) > 100, "Observer didn't collect enough samples" def test_concurrent_sends_with_callbacks(self): """Callbacks must execute exactly once per transition under contention.""" call_log = [] lock = threading.Lock() class CallbackMachine(StateChart): s1 = State(initial=True) s2 = State() go = s1.to(s2) | s2.to(s1) def on_enter_s2(self): with lock: call_log.append("enter_s2") def on_enter_s1(self): with lock: call_log.append("enter_s1") sm = CallbackMachine() num_threads = 4 events_per_thread = 200 total_events = num_threads * events_per_thread barrier = threading.Barrier(num_threads) errors = [] def sender(): try: barrier.wait(timeout=5) for _ in range(events_per_thread): sm.send("go") except Exception as e: errors.append(e) threads = [threading.Thread(target=sender) for _ in range(num_threads)] for t in threads: t.start() for t in threads: t.join(timeout=30) assert not errors, f"Thread errors: {errors}" # Each transition fires exactly one on_enter callback. # +1 because initial activation also fires on_enter_s1. counts = Counter(call_log) total_callbacks = counts["enter_s1"] + counts["enter_s2"] assert total_callbacks == total_events + 1 def test_concurrent_send_and_read_configuration(self, cycling_machine): """Reading configuration while events are being processed must not raise.""" num_senders = 4 events_per_sender = 300 barrier = threading.Barrier(num_senders + 1) stop_event = threading.Event() errors = [] def sender(): try: barrier.wait(timeout=5) for _ in range(events_per_sender): cycling_machine.send("cycle") except Exception as e: errors.append(e) def reader(): barrier.wait(timeout=5) while not stop_event.is_set(): try: _ = cycling_machine.configuration _ = cycling_machine.current_state_value _ = list(cycling_machine.configuration) except Exception as e: errors.append(e) threads = [threading.Thread(target=sender) for _ in range(num_senders)] reader_thread = threading.Thread(target=reader) for t in threads: t.start() reader_thread.start() for t in threads: t.join(timeout=30) stop_event.set() reader_thread.join(timeout=5) assert not errors, f"Thread errors: {errors}" async def test_regression_443_with_modifications_for_async_engine(): """ Test for https://github.com/fgmacedo/python-statemachine/issues/443 """ total_iterations = 4 send_at_iteration = 3 # 0-indexed: send before the 4th sample class TrafficLightMachine(StateChart): "A traffic light machine" green = State(initial=True) yellow = State() red = State() cycle = green.to(yellow) | yellow.to(red) | red.to(green) async def on_cycle(self): return "caution" def __init__(self, name): self.name = name self.statuses_history = [] super().__init__() def beat(self): for i in range(total_iterations): if i == send_at_iteration: self.cycle() self.statuses_history.append(f"{self.name}.{self.current_state_value}") class Controller: def __init__(self, name): self.fsm = TrafficLightMachine(name) async def start(self): # set up thread await self.fsm.activate_initial_state() self.thread = threading.Thread(target=self.fsm.beat) self.thread.start() c1 = Controller("c1") c2 = Controller("c2") c3 = Controller("c3") await c1.start() await c2.start() await c3.start() c1.thread.join() c2.thread.join() c3.thread.join() assert c1.fsm.statuses_history == ["c1.green", "c1.green", "c1.green", "c1.yellow"] assert c2.fsm.statuses_history == ["c2.green", "c2.green", "c2.green", "c2.yellow"] assert c3.fsm.statuses_history == ["c3.green", "c3.green", "c3.green", "c3.yellow"] ================================================ FILE: tests/test_transition_list.py ================================================ import pytest from statemachine.callbacks import CallbacksRegistry from statemachine.dispatcher import resolver_factory_from_objects from statemachine.transition import Transition from statemachine.transition_list import TransitionList from statemachine import State def test_transition_list_or_operator(): s1 = State("s1", initial=True) s2 = State("s2") s3 = State("s3") s4 = State("s4", final=True) t12 = s1.to(s2) t23 = s2.to(s3) t34 = s3.to(s4) cycle = t12 | t23 | t34 assert [(t.source.name, t.target.name) for t in t12] == [("s1", "s2")] assert [(t.source.name, t.target.name) for t in t23] == [("s2", "s3")] assert [(t.source.name, t.target.name) for t in t34] == [("s3", "s4")] assert [(t.source.name, t.target.name) for t in cycle] == [ ("s1", "s2"), ("s2", "s3"), ("s3", "s4"), ] class TestDecorators: @pytest.mark.parametrize( ("callback_name", "list_attr_name", "expected_value"), [ ("before", None, 42), ("after", None, 42), ("on", None, 42), ("validators", None, 42), ("cond", None, True), ("unless", "cond", False), ], ) def test_should_assign_callback_to_transitions( self, callback_name, list_attr_name, expected_value ): registry = CallbacksRegistry() if list_attr_name is None: list_attr_name = callback_name s1 = State("s1", initial=True) transition_list = s1.to.itself() decorator = getattr(transition_list, callback_name) @decorator def my_callback(): return 42 transition = s1.transitions[0] specs_grouper = getattr(transition, list_attr_name) resolver_factory_from_objects(object()).resolve(transition._specs, registry=registry) assert registry[specs_grouper.key].call() == [expected_value] def test_has_eventless_transition(): """TransitionList.has_eventless_transition returns True for eventless transitions.""" s1 = State("s1", initial=True) s2 = State("s2") t = Transition(s1, s2) tl = TransitionList([t]) assert tl.has_eventless_transition is True def test_has_no_eventless_transition(): """TransitionList.has_eventless_transition returns False when all have events.""" s1 = State("s1", initial=True) s2 = State("s2") t = Transition(s1, s2, event="go") tl = TransitionList([t]) assert tl.has_eventless_transition is False def test_transition_list_call_with_callable(): """Calling a TransitionList with a single callable registers it as an on callback.""" s1 = State("s1", initial=True) s2 = State("s2", final=True) tl = s1.to(s2) def my_callback(): ... # No-op: used only to test callback registration result = tl(my_callback) assert result is my_callback def test_transition_list_call_with_non_callable_raises(): """Calling a TransitionList with a non-callable raises TypeError.""" s1 = State("s1", initial=True) s2 = State("s2", final=True) tl = s1.to(s2) with pytest.raises(TypeError, match="only supports the decorator syntax"): tl("not_a_callable", "extra_arg") ================================================ FILE: tests/test_transition_table.py ================================================ from statemachine.contrib.diagram.extract import extract from statemachine.contrib.diagram.model import DiagramGraph from statemachine.contrib.diagram.model import DiagramState from statemachine.contrib.diagram.model import DiagramTransition from statemachine.contrib.diagram.model import StateType from statemachine.contrib.diagram.renderers.table import TransitionTableRenderer from statemachine import State from statemachine import StateChart class TestTransitionTableMarkdown: """Markdown transition table tests.""" def test_simple_table(self): graph = DiagramGraph( name="Simple", states=[ DiagramState(id="s1", name="S1", type=StateType.REGULAR, is_initial=True), DiagramState(id="s2", name="S2", type=StateType.REGULAR), ], transitions=[ DiagramTransition(source="s1", targets=["s2"], event="go"), ], ) result = TransitionTableRenderer().render(graph, fmt="md") assert "| State" in result assert "| Event" in result assert "| Guard" in result assert "| Target" in result assert "| S1" in result assert "go" in result assert "| S2" in result def test_with_guards(self): graph = DiagramGraph( name="Guards", states=[ DiagramState(id="s1", name="S1", type=StateType.REGULAR, is_initial=True), DiagramState(id="s2", name="S2", type=StateType.REGULAR), ], transitions=[ DiagramTransition(source="s1", targets=["s2"], event="go", guards=["is_ready"]), ], ) result = TransitionTableRenderer().render(graph, fmt="md") assert "is_ready" in result def test_multiple_targets(self): graph = DiagramGraph( name="Multi", states=[ DiagramState(id="s1", name="S1", type=StateType.REGULAR, is_initial=True), DiagramState(id="s2", name="S2", type=StateType.REGULAR), DiagramState(id="s3", name="S3", type=StateType.REGULAR), ], transitions=[ DiagramTransition(source="s1", targets=["s2", "s3"], event="split"), ], ) result = TransitionTableRenderer().render(graph, fmt="md") lines = result.strip().split("\n") # Header + separator + 2 data rows assert len(lines) == 4 def test_skips_initial_transitions(self): graph = DiagramGraph( name="SkipInit", states=[ DiagramState(id="s1", name="S1", type=StateType.REGULAR, is_initial=True), DiagramState(id="s2", name="S2", type=StateType.REGULAR), ], transitions=[ DiagramTransition(source="s1", targets=["s2"], event="", is_initial=True), DiagramTransition(source="s1", targets=["s2"], event="go"), ], ) result = TransitionTableRenderer().render(graph, fmt="md") lines = result.strip().split("\n") # Header + separator + 1 data row (initial skipped) assert len(lines) == 3 def test_skips_internal_transitions(self): graph = DiagramGraph( name="SkipInternal", states=[ DiagramState(id="s1", name="S1", type=StateType.REGULAR, is_initial=True), ], transitions=[ DiagramTransition(source="s1", targets=["s1"], event="check", is_internal=True), ], ) result = TransitionTableRenderer().render(graph, fmt="md") lines = result.strip().split("\n") # Header + separator only (no data rows) assert len(lines) == 2 def test_targetless_transition(self): graph = DiagramGraph( name="Targetless", states=[ DiagramState(id="s1", name="S1", type=StateType.REGULAR, is_initial=True), ], transitions=[ DiagramTransition(source="s1", targets=[], event="tick"), ], ) result = TransitionTableRenderer().render(graph, fmt="md") assert "tick" in result # Target falls back to source name assert "S1" in result class TestTransitionTableRST: """RST grid table tests.""" def test_rst_format(self): graph = DiagramGraph( name="RST", states=[ DiagramState(id="s1", name="S1", type=StateType.REGULAR, is_initial=True), DiagramState(id="s2", name="S2", type=StateType.REGULAR), ], transitions=[ DiagramTransition(source="s1", targets=["s2"], event="go"), ], ) result = TransitionTableRenderer().render(graph, fmt="rst") assert "+---" in result assert "|" in result assert "====" in result # header separator assert "go" in result def test_rst_with_guards(self): graph = DiagramGraph( name="RSTGuards", states=[ DiagramState(id="s1", name="S1", type=StateType.REGULAR, is_initial=True), DiagramState(id="s2", name="S2", type=StateType.REGULAR), ], transitions=[ DiagramTransition(source="s1", targets=["s2"], event="go", guards=["is_ready"]), ], ) result = TransitionTableRenderer().render(graph, fmt="rst") assert "is_ready" in result class TestTransitionTableIntegration: """Integration tests with real state machines.""" def test_traffic_light_md(self): from tests.examples.traffic_light_machine import TrafficLightMachine ir = extract(TrafficLightMachine) result = TransitionTableRenderer().render(ir, fmt="md") assert "Green" in result assert "Yellow" in result assert "Red" in result assert "Cycle" in result def test_traffic_light_rst(self): from tests.examples.traffic_light_machine import TrafficLightMachine ir = extract(TrafficLightMachine) result = TransitionTableRenderer().render(ir, fmt="rst") assert "Green" in result assert "Cycle" in result assert "+---" in result def test_compound_state_names(self): """Child state names are properly resolved.""" class SM(StateChart): class parent(State.Compound, name="Parent"): child1 = State(initial=True) child2 = State(final=True) go = child1.to(child2) start = State(initial=True) enter = start.to(parent) ir = extract(SM) result = TransitionTableRenderer().render(ir, fmt="md") assert "Child1" in result assert "Child2" in result def test_default_format_is_md(self): """render() without fmt defaults to markdown.""" graph = DiagramGraph( name="Default", states=[ DiagramState(id="s1", name="S1", type=StateType.REGULAR, is_initial=True), DiagramState(id="s2", name="S2", type=StateType.REGULAR), ], transitions=[ DiagramTransition(source="s1", targets=["s2"], event="go"), ], ) result = TransitionTableRenderer().render(graph) assert "| State" in result # markdown uses pipes ================================================ FILE: tests/test_transitions.py ================================================ import pytest from statemachine.exceptions import InvalidDefinition from statemachine.transition import Transition from statemachine import State from statemachine import StateChart from .models import MyModel def test_transition_representation(campaign_machine): s = repr([t for t in campaign_machine.draft.transitions if t.event == "produce"][0]) assert s == ( "Transition('Draft', 'Being produced', event=[" "Event('produce', delay=0, internal=False)], internal=False, initial=False)" ) def test_list_machine_events(classic_traffic_light_machine): machine = classic_traffic_light_machine() transitions = [t.name for t in machine.events] assert transitions == ["Slowdown", "Stop", "Go"] def test_list_state_transitions(classic_traffic_light_machine): machine = classic_traffic_light_machine() events = [t.event for t in machine.green.transitions] assert events == ["slowdown"] def test_transition_should_accept_decorator_syntax(traffic_light_machine): machine = traffic_light_machine() assert machine.green.is_active def test_transition_as_decorator_should_call_method_before_activating_state( traffic_light_machine, capsys ): machine = traffic_light_machine() assert machine.green.is_active machine.cycle(1, 2, number=3, text="x") assert machine.yellow.is_active captured = capsys.readouterr() assert captured.out == "Running cycle from green to yellow\n" @pytest.mark.parametrize( "machine_name", [ "traffic_light_machine", "reverse_traffic_light_machine", ], ) def test_cycle_transitions(request, machine_name): machine_class = request.getfixturevalue(machine_name) machine = machine_class() expected_states = ["green", "yellow", "red"] * 2 for expected_state in expected_states: assert machine.current_state_value == expected_state machine.cycle() def test_transition_call_can_only_be_used_as_decorator(): source, dest = State("Source"), State("Destination") transition = Transition(source, dest) with pytest.raises(TypeError): transition("not a callable") def test_transition_list_call_can_only_be_used_as_decorator(): source, dest = State("Source"), State("Destination") transition_list = source.to(dest) with pytest.raises(TypeError, match="TransitionList"): transition_list("not a callable") with pytest.raises(TypeError, match="TransitionList"): transition_list() with pytest.raises(TypeError, match="TransitionList"): transition_list(42, extra="kwarg") @pytest.fixture(params=["bounded", "unbounded"]) def transition_callback_machine(request): if request.param == "bounded": class ApprovalMachine(StateChart): "A workflow" requested = State(initial=True) accepted = State(final=True) validate = requested.to(accepted) def on_validate(self): self.model.calls.append("on_validate") return "accepted" elif request.param == "unbounded": class ApprovalMachine(StateChart): "A workflow" requested = State(initial=True) accepted = State(final=True) @requested.to(accepted) def validate(self): self.model.calls.append("on_validate") return "accepted" else: raise ValueError("machine not defined") return ApprovalMachine def test_statemachine_transition_callback(transition_callback_machine): model = MyModel(state="requested", calls=[]) machine = transition_callback_machine(model) assert machine.validate() == "accepted" assert model.calls == ["on_validate"] def test_can_run_combined_transitions(): class CampaignMachine(StateChart): "A workflow machine" draft = State(initial=True) producing = State() closed = State() abort = draft.to(closed) | producing.to(closed) | closed.to(closed) produce = draft.to(producing) machine = CampaignMachine() machine.abort() assert machine.closed.is_active def test_can_detect_stuck_states(): with pytest.raises( InvalidDefinition, match="All non-final states should have at least one outgoing transition.", ): class CampaignMachine(StateChart): "A workflow machine" draft = State(initial=True) producing = State() paused = State() closed = State() abort = draft.to(closed) | producing.to(closed) | closed.to(closed) produce = draft.to(producing) pause = producing.to(paused) def test_can_opt_out_of_stuck_states_check(): class CampaignMachine(StateChart): "A workflow machine" validate_trap_states = False draft = State(initial=True) producing = State() paused = State() closed = State() abort = draft.to(closed) | producing.to(closed) | closed.to(closed) produce = draft.to(producing) pause = producing.to(paused) def test_can_detect_unreachable_final_states(): with pytest.raises( InvalidDefinition, match="All non-final states should have at least one path to a final state.", ): class CampaignMachine(StateChart): "A workflow machine" draft = State(initial=True) producing = State() paused = State() closed = State(final=True) abort = closed.from_(draft, producing) produce = draft.to(producing) pause = producing.to(paused) | paused.to.itself() def test_can_opt_out_of_unreachable_final_states_check(): class CampaignMachine(StateChart): "A workflow machine" validate_final_reachability = False draft = State(initial=True) producing = State() paused = State() closed = State(final=True) abort = closed.from_(draft, producing) produce = draft.to(producing) pause = producing.to(paused) | paused.to.itself() def test_transitions_to_the_same_estate_as_itself(): class CampaignMachine(StateChart): "A workflow machine" draft = State(initial=True) producing = State() closed = State() update = draft.to.itself() abort = draft.to(closed) | producing.to(closed) | closed.to.itself() produce = draft.to(producing) machine = CampaignMachine() machine.update() assert machine.draft.is_active class TestReverseTransition: @pytest.mark.parametrize( "initial_state", [ "green", "yellow", "red", ], ) def test_reverse_transition(self, reverse_traffic_light_machine, initial_state): machine = reverse_traffic_light_machine(start_value=initial_state) assert machine.current_state_value == initial_state machine.stop() assert machine.red.is_active def test_should_transition_with_a_dict_as_return(): "regression test that verifies if a dict can be used as return" expected_result = { "a": 1, "b": 2, "c": 3, } class ApprovalMachine(StateChart): "A workflow" requested = State(initial=True) accepted = State(final=True) rejected = State(final=True) accept = requested.to(accepted) reject = requested.to(rejected) def on_accept(self): return expected_result machine = ApprovalMachine() result = machine.send("accept") assert result == expected_result class TestInternalTransition: def test_external_self_transition_executes_state_actions(self, engine): calls = [] class TestStateMachine(StateChart): initial = State(initial=True) loop = initial.to.itself(internal=False) def _get_engine(self): return engine(self) def on_exit_initial(self): calls.append("on_exit_initial") def on_enter_initial(self): calls.append("on_enter_initial") sm = TestStateMachine() sm.activate_initial_state() calls.clear() sm.loop() assert calls == ["on_exit_initial", "on_enter_initial"] def test_internal_self_transition_skips_state_actions(self, engine): calls = [] class TestStateMachine(StateChart): enable_self_transition_entries = False initial = State(initial=True) loop = initial.to.itself(internal=True) def _get_engine(self): return engine(self) def on_exit_initial(self): calls.append("on_exit_initial") def on_enter_initial(self): calls.append("on_enter_initial") sm = TestStateMachine() sm.activate_initial_state() calls.clear() sm.loop() assert calls == [] def test_should_not_allow_internal_transitions_from_distinct_states(self): with pytest.raises( InvalidDefinition, match="Not a valid internal transition from source." ): class TestStateMachine(StateChart): initial = State(initial=True) final = State(final=True) execute = initial.to(initial, final, internal=True) class TestAllowEventWithoutTransition: def test_send_unknown_event(self, classic_traffic_light_machine_allow_event): sm = classic_traffic_light_machine_allow_event() sm.activate_initial_state() # no-op on sync engine assert sm.green.is_active sm.send("unknow_event") assert sm.green.is_active def test_send_not_valid_for_the_current_state_event( self, classic_traffic_light_machine_allow_event ): sm = classic_traffic_light_machine_allow_event() sm.activate_initial_state() # no-op on sync engine assert sm.green.is_active sm.stop() assert sm.green.is_active class TestTransitionFromAny: @pytest.fixture() def account_sm(self): class AccountStateMachine(StateChart): allow_event_without_transition = False catch_errors_as_events = False active = State("Active", initial=True) suspended = State("Suspended") overdrawn = State("Overdrawn") closed = State("Closed", final=True) # Define transitions between states suspend = active.to(suspended) activate = suspended.to(active) overdraft = active.to(overdrawn) resolve_overdraft = overdrawn.to(active) close_account = closed.from_.any(cond="can_close_account") can_close_account: bool = True # Actions performed during transitions def on_close_account(self): print("Account has been closed.") return AccountStateMachine def test_transition_from_any(self, account_sm): sm = account_sm() sm.close_account() assert sm.closed.is_active def test_can_close_from_every_state(self, account_sm): sm = account_sm() states_can_close = {} for state in sm.states: for transition in state.transitions: print(f"{state.id} -({transition.event})-> {transition.target.id}") if transition.target == sm.closed: states_can_close[state.id] = state assert list(states_can_close) == ["active", "suspended", "overdrawn"] def test_transition_from_any_with_cond(self, account_sm): sm = account_sm() sm.can_close_account = False with pytest.raises(sm.TransitionNotAllowed): sm.close_account() assert sm.active.is_active def test_any_can_be_used_as_decorator(self): class AccountStateMachine(StateChart): catch_errors_as_events = False active = State("Active", initial=True) suspended = State("Suspended") overdrawn = State("Overdrawn") closed = State("Closed", final=True) # Define transitions between states suspend = active.to(suspended) activate = suspended.to(active) overdraft = active.to(overdrawn) resolve_overdraft = overdrawn.to(active) close_account = closed.from_.any() flag_for_debug: bool = False @close_account.on def do_close_account(self): self.flag_for_debug = True sm = AccountStateMachine() sm.close_account() assert sm.closed.is_active assert sm.flag_for_debug is True def test_initial_transition_with_cond_raises(): """Initial transitions cannot have conditions.""" s1 = State("s1", initial=True) s2 = State("s2") with pytest.raises(InvalidDefinition, match="Initial transitions"): Transition(s1, s2, initial=True, cond="some_cond") def test_initial_transition_with_event_raises(): """Initial transitions cannot have events.""" s1 = State("s1", initial=True) s2 = State("s2") with pytest.raises(InvalidDefinition, match="Initial transitions"): Transition(s1, s2, initial=True, event="some_event") ================================================ FILE: tests/test_validators.py ================================================ """Tests for the validators feature. Validators are imperative guards that raise exceptions to reject transitions. Unlike conditions (cond/unless), which return booleans and silently skip transitions, validators communicate *why* a transition was rejected. Key behavior (since v3): validator exceptions always propagate to the caller, regardless of the ``catch_errors_as_events`` flag. They are NOT converted to ``error.execution`` events — they operate in the transition-selection phase, not the execution phase. """ import pytest from tests.machines.validators.multi_validator import MultiValidator from tests.machines.validators.order_validation import OrderValidation from tests.machines.validators.order_validation_no_error_events import OrderValidationNoErrorEvents from tests.machines.validators.validator_fallthrough import ValidatorFallthrough from tests.machines.validators.validator_with_cond import ValidatorWithCond from tests.machines.validators.validator_with_error_transition import ValidatorWithErrorTransition class TestValidatorPropagation: """Validator exceptions always propagate to the caller.""" async def test_validator_rejects_with_catch_errors_as_events_true(self, sm_runner): """With catch_errors_as_events=True (default), validator exceptions still propagate — they are NOT converted to error.execution events.""" sm = await sm_runner.start(OrderValidation) with pytest.raises(ValueError, match="Quantity must be positive"): await sm_runner.send(sm, "confirm", quantity=0) assert "pending" in sm.configuration_values async def test_validator_rejects_with_catch_errors_as_events_false(self, sm_runner): """With catch_errors_as_events=False, validator exceptions propagate (same behavior as True — validators always propagate).""" sm = await sm_runner.start(OrderValidationNoErrorEvents) with pytest.raises(ValueError, match="Quantity must be positive"): await sm_runner.send(sm, "confirm", quantity=0) assert "pending" in sm.configuration_values async def test_validator_accepts(self, sm_runner): """When the validator passes, the transition proceeds normally.""" sm = await sm_runner.start(OrderValidation) await sm_runner.send(sm, "confirm", quantity=5) assert "confirmed" in sm.configuration_values async def test_state_unchanged_after_rejection(self, sm_runner): """After a validator rejects, the machine stays in the source state and can still process events normally.""" sm = await sm_runner.start(OrderValidation) with pytest.raises(ValueError, match="Quantity must be positive"): await sm_runner.send(sm, "confirm", quantity=0) # Machine is still in pending — retry with valid data await sm_runner.send(sm, "confirm", quantity=10) assert "confirmed" in sm.configuration_values class TestMultipleValidators: """When multiple validators are declared, they run in order.""" async def test_first_validator_fails(self, sm_runner): """First validator failure stops the chain — second is not called.""" sm = await sm_runner.start(MultiValidator) with pytest.raises(ValueError, match="A failed"): await sm_runner.send(sm, "start", a_ok=False, b_ok=True) assert "idle" in sm.configuration_values async def test_second_validator_fails(self, sm_runner): """First passes, second fails.""" sm = await sm_runner.start(MultiValidator) with pytest.raises(ValueError, match="B failed"): await sm_runner.send(sm, "start", a_ok=True, b_ok=False) assert "idle" in sm.configuration_values async def test_all_validators_pass(self, sm_runner): sm = await sm_runner.start(MultiValidator) await sm_runner.send(sm, "start", a_ok=True, b_ok=True) assert "active" in sm.configuration_values class TestValidatorWithConditions: """Validators and conditions can be combined on the same transition. Validators run first (see execution order in actions.md).""" async def test_validator_rejects_before_cond_is_evaluated(self, sm_runner): """Validator runs before cond — if it rejects, cond is never checked.""" sm = await sm_runner.start(ValidatorWithCond) sm.has_permission = True # cond would pass with pytest.raises(PermissionError, match="Invalid token"): await sm_runner.send(sm, "start", token="bad") assert "idle" in sm.configuration_values async def test_validator_passes_but_cond_rejects(self, sm_runner): """Validator passes, but cond returns False — no transition, no exception.""" sm = await sm_runner.start(ValidatorWithCond) sm.has_permission = False await sm_runner.send(sm, "start", token="valid") assert "idle" in sm.configuration_values async def test_both_validator_and_cond_pass(self, sm_runner): sm = await sm_runner.start(ValidatorWithCond) sm.has_permission = True await sm_runner.send(sm, "start", token="valid") assert "active" in sm.configuration_values class TestValidatorDoesNotTriggerErrorExecution: """The key semantic: validator rejection is NOT an execution error. Even when catch_errors_as_events=True and an error.execution transition exists, a validator raising should propagate to the caller — not be routed through the error.execution mechanism. """ async def test_validator_does_not_trigger_error_transition(self, sm_runner): sm = await sm_runner.start(ValidatorWithErrorTransition) with pytest.raises(ValueError, match="Input required"): await sm_runner.send(sm, "start") # Machine stays in idle — NOT in error_state assert "idle" in sm.configuration_values async def test_action_error_does_trigger_error_transition(self, sm_runner): """Contrast: actual action errors DO trigger error.execution.""" sm = await sm_runner.start(ValidatorWithErrorTransition) # First, get to active state (validator passes) await sm_runner.send(sm, "start", value="something") assert "active" in sm.configuration_values # Now trigger an action error — this SHOULD go to error_state await sm_runner.send(sm, "do_work") assert "error_state" in sm.configuration_values class TestValidatorFallthrough: """When a validator rejects, the exception propagates immediately. The engine does NOT try the next transition in the chain.""" async def test_validator_rejection_does_not_fallthrough(self, sm_runner): sm = await sm_runner.start(ValidatorFallthrough) with pytest.raises(PermissionError, match="Premium required"): await sm_runner.send(sm, "go", premium=False) # Machine stays in idle — did NOT fall through to path_b assert "idle" in sm.configuration_values async def test_validator_passes_takes_first_transition(self, sm_runner): sm = await sm_runner.start(ValidatorFallthrough) await sm_runner.send(sm, "go", premium=True) assert "path_a" in sm.configuration_values ================================================ FILE: tests/test_weighted_transitions.py ================================================ from collections import Counter import pytest from statemachine.contrib.weighted import _make_weighted_cond from statemachine.contrib.weighted import _WeightedGroup from statemachine.contrib.weighted import to from statemachine.contrib.weighted import weighted_transitions from statemachine import State from statemachine import StateChart from statemachine import StateMachine @pytest.fixture() def WeightedIdleSC(): from tests.examples.weighted_idle_machine import WeightedIdleMachine return WeightedIdleMachine class TestWeightedTransitionsBasic: def test_deterministic_with_seed(self, WeightedIdleSC): sm = WeightedIdleSC() sm.send("idle") first_config = sm.configuration sm.send("finish") sm.send("idle") second_config = sm.configuration # With seed=42, results are deterministic # Create a fresh instance to verify same seed produces same sequence sm2 = WeightedIdleSC() sm2.send("idle") assert sm2.configuration == first_config sm2.send("finish") sm2.send("idle") assert sm2.configuration == second_config def test_statistical_distribution(self, WeightedIdleSC): """Over many iterations, the distribution should approximate the weights.""" sm = WeightedIdleSC() counts = Counter() iterations = 10000 for _ in range(iterations): sm.send("idle") counts[next(iter(sm.configuration)).id] += 1 sm.send("finish") # With 70/20/10 weights, check roughly correct distribution (within 5%) assert abs(counts["shift_weight"] / iterations - 0.70) < 0.05 assert abs(counts["adjust_hair"] / iterations - 0.20) < 0.05 assert abs(counts["bang_shield"] / iterations - 0.10) < 0.05 def test_single_weighted_transition(self): class SingleWeighted(StateChart): s1 = State(initial=True) s2 = State() go = weighted_transitions(s1, (s2, 100), seed=0) back = s2.to(s1) sm = SingleWeighted() sm.send("go") assert sm.configuration == {SingleWeighted.s2} def test_equal_weights(self): class EqualWeights(StateChart): s1 = State(initial=True) s2 = State() s3 = State() go = weighted_transitions(s1, (s2, 50), (s3, 50)) back = s2.to(s1) | s3.to(s1) sm = EqualWeights() counts = Counter() iterations = 5000 for _ in range(iterations): sm.send("go") counts[next(iter(sm.configuration)).id] += 1 sm.send("back") # Should be roughly 50/50 within 5% assert abs(counts["s2"] / iterations - 0.50) < 0.05 assert abs(counts["s3"] / iterations - 0.50) < 0.05 def test_float_weights(self): class FloatWeights(StateChart): s1 = State(initial=True) s2 = State() s3 = State() go = weighted_transitions(s1, (s2, 0.7), (s3, 0.3)) back = s2.to(s1) | s3.to(s1) sm = FloatWeights() counts = Counter() iterations = 5000 for _ in range(iterations): sm.send("go") counts[next(iter(sm.configuration)).id] += 1 sm.send("back") assert abs(counts["s2"] / iterations - 0.70) < 0.05 assert abs(counts["s3"] / iterations - 0.30) < 0.05 def test_mixed_int_and_float_weights(self): class MixedWeights(StateChart): s1 = State(initial=True) s2 = State() s3 = State() go = weighted_transitions(s1, (s2, 10), (s3, 5.5), seed=42) back = s2.to(s1) | s3.to(s1) sm = MixedWeights() sm.send("go") assert sm.configuration & {MixedWeights.s2, MixedWeights.s3} class TestWeightedTransitionsWithGuards: def test_with_user_cond_guard(self): class GuardedWeighted(StateChart): s1 = State(initial=True) s2 = State() s3 = State() go = weighted_transitions( s1, to(s2, 50, cond="is_allowed"), (s3, 50), seed=0, ) back = s2.to(s1) | s3.to(s1) def is_allowed(self): return self.allow_s2 sm = GuardedWeighted() sm.allow_s2 = True # When is_allowed=True, both transitions can fire counts = Counter() for _ in range(1000): sm.send("go") counts[next(iter(sm.configuration)).id] += 1 sm.send("back") assert counts["s2"] > 0 assert counts["s3"] > 0 def test_with_unless_guard(self): class UnlessWeighted(StateChart): s1 = State(initial=True) s2 = State() s3 = State() go = weighted_transitions( s1, to(s2, 90, unless="is_blocked"), (s3, 10), seed=0, ) back = s2.to(s1) | s3.to(s1) def is_blocked(self): return self.blocked sm = UnlessWeighted() sm.blocked = False # When not blocked, s2 can fire sm.send("go") first_state = next(iter(sm.configuration)) sm.send("back") # When blocked, s2 cond fails even if weight selects it sm.blocked = True results = Counter() for _ in range(100): try: sm.send("go") results[next(iter(sm.configuration)).id] += 1 sm.send("back") except Exception: results["failed"] += 1 # s3 should still work when weight selects it assert results["s3"] > 0 assert first_state is not None def test_guard_failure_no_fallback(self): """When the selected transition's guard fails, no fallback occurs.""" class NoFallback(StateMachine): s1 = State(initial=True) s2 = State() s3 = State() go = weighted_transitions( s1, to(s2, 90, cond="allow_s2"), (s3, 10), seed=1, ) back = s2.to(s1) | s3.to(s1) allow_s2 = True sm = NoFallback() sm.allow_s2 = False from statemachine.exceptions import TransitionNotAllowed got_failure = False for _ in range(50): try: sm.send("go") sm.send("back") except TransitionNotAllowed: got_failure = True break assert got_failure, "Expected TransitionNotAllowed when guard blocks selection" class TestWeightedTransitionsValidation: def test_empty_destinations(self): s1 = State(initial=True) with pytest.raises(ValueError, match="requires at least one"): weighted_transitions(s1) def test_source_not_a_state(self): with pytest.raises(TypeError, match="First argument must be a source State"): weighted_transitions("not_a_state", ("target", 10)) # type: ignore[arg-type] def test_not_a_tuple(self): s1 = State(initial=True) with pytest.raises(TypeError, match="must be a .* tuple"): weighted_transitions(s1, "not a tuple") # type: ignore[arg-type] def test_wrong_tuple_length(self): s1 = State(initial=True) with pytest.raises(TypeError, match="must be a .* tuple"): weighted_transitions(s1, (1, 2, 3, 4)) # type: ignore[arg-type] def test_target_not_a_state(self): s1 = State(initial=True) with pytest.raises(TypeError, match="first element must be a State"): weighted_transitions(s1, ("not_a_state", 10)) # type: ignore[arg-type] def test_weight_not_a_number(self): s1 = State(initial=True) s2 = State() with pytest.raises(TypeError, match="weight must be a positive number"): weighted_transitions(s1, (s2, "ten")) # type: ignore[arg-type] def test_weight_zero(self): s1 = State(initial=True) s2 = State() with pytest.raises(ValueError, match="weight must be positive"): weighted_transitions(s1, (s2, 0)) def test_weight_negative(self): s1 = State(initial=True) s2 = State() with pytest.raises(ValueError, match="weight must be positive"): weighted_transitions(s1, (s2, -5)) def test_kwargs_not_a_dict(self): s1 = State(initial=True) s2 = State() with pytest.raises(TypeError, match="third element must be a dict"): weighted_transitions(s1, (s2, 10, "bad")) # type: ignore[arg-type] def test_kwargs_forwarded_to_transition(self): """Verify that kwargs dict is forwarded to source.to().""" class WithKwargs(StateChart): s1 = State(initial=True) s2 = State() s3 = State() go = weighted_transitions( s1, (s2, 50, {"on": "do_something"}), (s3, 50), seed=42, ) back = s2.to(s1) | s3.to(s1) def __init__(self): self.log = [] super().__init__() def do_something(self): self.log.append("did_it") sm = WithKwargs() # Run enough iterations that s2 is selected at least once for _ in range(50): sm.send("go") sm.send("back") assert "did_it" in sm.log def test_to_helper_forwards_kwargs(self): """Verify that to() helper passes kwargs to source.to().""" class WithTo(StateChart): s1 = State(initial=True) s2 = State() s3 = State() go = weighted_transitions( s1, to(s2, 50, on="do_something"), to(s3, 50), seed=42, ) back = s2.to(s1) | s3.to(s1) def __init__(self): self.log = [] super().__init__() def do_something(self): self.log.append("did_it") sm = WithTo() for _ in range(50): sm.send("go") sm.send("back") assert "did_it" in sm.log def test_to_returns_tuple(self): """to() returns a plain tuple compatible with weighted_transitions.""" s2 = State() result = to(s2, 70) assert isinstance(result, tuple) assert result == (s2, 70, {}) result_with_kwargs = to(s2, 30, cond="is_ready", on="go") assert isinstance(result_with_kwargs, tuple) assert result_with_kwargs == (s2, 30, {"cond": "is_ready", "on": "go"}) class TestWeightedTransitionsWithCallbacks: def test_action_decorators_on_weighted_event(self): """Transition callbacks (before/on/after) work with weighted transitions.""" class WithCallbacks(StateChart): s1 = State(initial=True) s2 = State() s3 = State() go = weighted_transitions(s1, (s2, 50), (s3, 50), seed=42) back = s2.to(s1) | s3.to(s1) def __init__(self): self.log = [] super().__init__() def on_go(self): self.log.append("on_go") def after_go(self): self.log.append("after_go") sm = WithCallbacks() sm.send("go") assert "on_go" in sm.log assert "after_go" in sm.log class TestWeightedTransitionsEngines: async def test_sync_and_async_engines(self, sm_runner): class WeightedSC(StateChart): s1 = State(initial=True) s2 = State() s3 = State() go = weighted_transitions(s1, (s2, 70), (s3, 30), seed=42) back = s2.to(s1) | s3.to(s1) sm = await sm_runner.start(WeightedSC) await sm_runner.send(sm, "go") assert "s2" in sm.configuration_values or "s3" in sm.configuration_values await sm_runner.send(sm, "back") assert "s1" in sm.configuration_values async def test_works_with_state_machine(self, sm_runner): class WeightedSM(StateMachine): s1 = State(initial=True) s2 = State() s3 = State() go = weighted_transitions(s1, (s2, 70), (s3, 30), seed=42) back = s2.to(s1) | s3.to(s1) sm = await sm_runner.start(WeightedSM) await sm_runner.send(sm, "go") assert "s2" in sm.configuration_values or "s3" in sm.configuration_values await sm_runner.send(sm, "back") assert "s1" in sm.configuration_values class TestMultipleWeightedGroups: def test_independent_groups(self): class MultiGroup(StateChart): s1 = State(initial=True) s2 = State() s3 = State() s4 = State() s5 = State() go_a = weighted_transitions(s1, (s2, 80), (s3, 20), seed=42) go_b = weighted_transitions(s1, (s4, 30), (s5, 70), seed=99) back = s2.to(s1) | s3.to(s1) | s4.to(s1) | s5.to(s1) sm = MultiGroup() sm.send("go_a") state_a = next(iter(sm.configuration)) assert state_a in (MultiGroup.s2, MultiGroup.s3) sm.send("back") sm.send("go_b") state_b = next(iter(sm.configuration)) assert state_b in (MultiGroup.s4, MultiGroup.s5) class TestWeightedCondRepr: def test_cond_name_includes_weight_and_percentage(self): group = _WeightedGroup([70, 20, 10]) cond = _make_weighted_cond(0, group, 70.0, 100.0) assert cond.__name__ == "weight=70.0 (70%)" def test_cond_name_with_fractional_percentage(self): group = _WeightedGroup([1, 2]) cond = _make_weighted_cond(0, group, 1.0, 3.0) assert cond.__name__ == "weight=1.0 (33%)" def test_non_zero_index_cond_rolls_dice_if_not_yet_selected(self): """When a non-zero index cond is evaluated before index 0, it rolls the dice.""" group = _WeightedGroup([50, 50], seed=42) cond_1 = _make_weighted_cond(1, group, 50.0, 100.0) assert group.selected is None result = cond_1() assert group.selected is not None # dice was rolled assert result == (group.selected == 1) ================================================ FILE: tests/testcases/__init__.py ================================================ ================================================ FILE: tests/testcases/issue308.md ================================================ ### Issue 308 A StateMachine that exercises the example given on issue #[308](https://github.com/fgmacedo/python-statemachine/issues/308). In this example, we share the transition list between events. ```py >>> from statemachine import StateChart, State >>> class TestSM(StateChart): ... state1 = State('s1', initial=True) ... state2 = State('s2') ... state3 = State('s3') ... state4 = State('s4', final=True) ... ... event1 = state1.to(state2) ... event2 = state2.to(state3) ... event3 = state3.to(state4) ... ... # cycle = state1.to(state2) | state2.to(state3) | state3.to(state4) ... cycle = event1 | event2 | event3 ... ... def before_cycle(self): ... print("before cycle") ... ... def on_cycle(self): ... print("on cycle") ... ... def after_cycle(self): ... print("after cycle") ... ... def on_enter_state1(self): ... print('enter state1') ... ... def on_exit_state1(self): ... print('exit state1') ... ... def on_enter_state2(self): ... print('enter state2') ... ... def on_exit_state2(self): ... print('exit state2') ... ... def on_enter_state3(self): ... print('enter state3') ... ... def on_exit_state3(self): ... print('exit state3') ... ... def on_enter_state4(self): ... print('enter state4') ... ... def on_exit_state4(self): ... print('exit state4') ... ... def before_trans12(self): ... print('before event1') ... ... def on_trans12(self): ... print('on event1') ... ... def after_trans12(self): ... print('after event1') ... ... def before_trans23(self): ... print('before event2') ... ... def on_trans23(self): ... print('on event2') ... ... def after_trans23(self): ... print('after event2') ... ... def before_trans34(self): ... print('before event3') ... ... def on_trans34(self): ... print('on event3') ... ... def after_trans34(self): ... print('after event3') ... ``` Example given: ```py >>> m = TestSM() enter state1 >>> m.state1.is_active, m.state2.is_active, m.state3.is_active, m.state4.is_active, list(m.configuration_values) (True, False, False, False, ['state1']) >>> _ = m.cycle() before cycle exit state1 on cycle enter state2 after cycle >>> m.state1.is_active, m.state2.is_active, m.state3.is_active, m.state4.is_active, list(m.configuration_values) (False, True, False, False, ['state2']) >>> _ = m.cycle() before cycle exit state2 on cycle enter state3 after cycle >>> m.state1.is_active, m.state2.is_active, m.state3.is_active, m.state4.is_active, list(m.configuration_values) (False, False, True, False, ['state3']) >>> _ = m.cycle() before cycle exit state3 on cycle enter state4 after cycle >>> m.state1.is_active, m.state2.is_active, m.state3.is_active, m.state4.is_active, list(m.configuration_values) (False, False, False, True, ['state4']) ``` ================================================ FILE: tests/testcases/issue384_multiple_observers.md ================================================ ### Issue 384 A StateMachine that exercises the example given on issue #[384](https://github.com/fgmacedo/python-statemachine/issues/384). In this example, we register multiple observers to the same named callback. This works also as a regression test. ```py >>> from statemachine import State >>> from statemachine import StateChart >>> class MyObs: ... def on_move_car(self): ... print("I observed moving from 1") >>> class MyObs2: ... def on_move_car(self): ... print("I observed moving from 2") ... >>> class Car(StateChart): ... stopped = State(initial=True) ... moving = State() ... ... move_car = stopped.to(moving) ... stop_car = moving.to(stopped) ... ... def on_move_car(self): ... print("I'm moving") ``` Running: ```py >>> car = Car() >>> obs = MyObs() >>> obs2 = MyObs2() >>> car.add_listener(obs) Car(model=Model(state=stopped), state_field='state', configuration=['stopped']) >>> car.add_listener(obs2) Car(model=Model(state=stopped), state_field='state', configuration=['stopped']) >>> car.add_listener(obs2) # test to not register duplicated observer callbacks Car(model=Model(state=stopped), state_field='state', configuration=['stopped']) >>> car.move_car() I'm moving I observed moving from 1 I observed moving from 2 [None, None, None] ``` ================================================ FILE: tests/testcases/issue449.md ================================================ ### Issue 449 A StateMachine that exercises the example given on issue #[449](https://github.com/fgmacedo/python-statemachine/issues/449). ```py >>> from statemachine import StateChart, State >>> class ExampleStateMachine(StateChart): ... initial = State(initial=True) ... second = State() ... third = State() ... fourth = State() ... final = State(final=True) ... ... initial_to_second = initial.to(second) ... second_to_third = second.to(third) ... third_to_fourth = third.to(fourth) ... completion = fourth.to(final) ... ... def on_enter_state(self, target: State, event: str): ... print(f"Entering state {target.id}. Event: {event}") ... if event == "initial_to_second": ... self.send("second_to_third") ... if event == "second_to_third": ... self.send("third_to_fourth") ... if event == "third_to_fourth": ... print("third_to_fourth on on_enter_state worked") ``` Exercise: ```py >>> example = ExampleStateMachine() Entering state initial. Event: __initial__ >>> print(list(example.configuration_values)) ['initial'] >>> example.send("initial_to_second") # this will call second_to_third and third_to_fourth Entering state second. Event: initial_to_second Entering state third. Event: second_to_third Entering state fourth. Event: third_to_fourth third_to_fourth on on_enter_state worked >>> print("My current state is", list(example.configuration_values)) My current state is ['fourth'] ``` ================================================ FILE: tests/testcases/test_issue434.py ================================================ from time import sleep import pytest from statemachine import State from statemachine import StateChart class Model: def __init__(self, data: dict): self.data = data class DataCheckerMachine(StateChart): catch_errors_as_events = False check_data = State(initial=True) data_good = State(final=True) data_bad = State(final=True) MAX_CYCLE_COUNT = 10 cycle_count = 0 cycle = ( check_data.to(data_good, cond="data_looks_good") | check_data.to(data_bad, cond="max_cycle_reached") | check_data.to.itself(internal=True) ) def data_looks_good(self): return self.model.data.get("value") > 10.0 def max_cycle_reached(self): return self.cycle_count > self.MAX_CYCLE_COUNT def after_cycle(self, event: str, source: State, target: State): print(f"Running {event} {self.cycle_count} from {source!s} to {target!s}.") self.cycle_count += 1 @pytest.fixture() def initial_data(): return {"value": 1} @pytest.fixture() def data_checker_machine(initial_data): return DataCheckerMachine(Model(initial_data)) def test_max_cycle_without_success(data_checker_machine): sm = data_checker_machine cycle_rate = 0.1 while not sm.is_terminated: sm.cycle() sleep(cycle_rate) assert sm.data_bad.is_active assert sm.cycle_count == 12 def test_data_turns_good_mid_cycle(initial_data): sm = DataCheckerMachine(Model(initial_data)) cycle_rate = 0.1 while not sm.is_terminated: sm.cycle() if sm.cycle_count == 5: print("Now data looks good!") sm.model.data["value"] = 20 sleep(cycle_rate) assert sm.data_good.is_active assert sm.cycle_count == 6 # Transition occurs at the 6th cycle ================================================ FILE: tests/testcases/test_issue480.py ================================================ """ ### Issue 480 A StateMachine that exercises the example given on issue #[480](https://github.com/fgmacedo/python-statemachine/issues/480). Should be possible to trigger an event on the initial state activation handler. """ from unittest.mock import MagicMock from unittest.mock import call from statemachine import State from statemachine import StateChart class MyStateMachine(StateChart): state_1 = State(initial=True) state_2 = State(final=True) trans_1 = state_1.to(state_2) def __init__(self): self.mock = MagicMock() super().__init__() def on_enter_state_1(self): self.mock("on_enter_state_1") self.long_running_task() def on_exit_state_1(self): self.mock("on_exit_state_1") def on_enter_state_2(self): self.mock("on_enter_state_2") def long_running_task(self): self.mock("long_running_task_started") self.trans_1() self.mock("long_running_task_ended") def test_initial_state_activation_handler(): sm = MyStateMachine() expected_calls = [ call("on_enter_state_1"), call("long_running_task_started"), call("long_running_task_ended"), call("on_exit_state_1"), call("on_enter_state_2"), ] assert sm.mock.mock_calls == expected_calls assert sm.state_2.is_active ================================================ FILE: tests/testcases/test_issue509.py ================================================ """ ### Issue 509 A StateChart that exercises the example given on issue #[509](https://github.com/fgmacedo/python-statemachine/issues/509). When multiple async coroutines send events concurrently, each caller should receive its own event's result or exception — not another caller's. Original problem: fn2 triggers a validator exception, but fn1 receives it instead. """ import asyncio import pytest from statemachine import State from statemachine import StateChart class Issue509SC(StateChart): INITIAL = State(initial=True) FINAL = State() noop = INITIAL.to(FINAL, on="do_nothing") noop2 = INITIAL.to(FINAL, on="do_nothing", validators="raise_exception") | FINAL.to.itself( on="do_nothing", validators="raise_exception" ) async def do_nothing(self, name): await asyncio.sleep(0.01) return f"Did nothing via {name}" def raise_exception(self): raise ValueError("noop2 is not allowed") @pytest.mark.asyncio() async def test_issue509_exception_routed_to_correct_caller(): test = Issue509SC() await test.activate_initial_state() results = {} async def fn1(): results["fn1"] = await test.send("noop", "fn1") async def fn2(): try: await test.send("noop2", "fn2") results["fn2"] = "no error" except ValueError as e: results["fn2"] = f"caught: {e}" task1 = asyncio.create_task(fn1()) task2 = asyncio.create_task(fn2()) await asyncio.gather(task1, task2) # fn1 should get its own result, not fn2's exception assert results["fn1"] == "Did nothing via fn1" # fn2 should catch the ValueError from its own validator assert results["fn2"] == "caught: noop2 is not allowed"