diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 74705aa7..ab560ec6 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -13,3 +13,5 @@ Tell us what happened, what went wrong, and what you expected to happen. 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. diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 5aa31a98..8a0a64cc 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 @@ -46,7 +46,7 @@ jobs: #---------------------------------------------- - name: Test with pytest run: | - uv run pytest --cov-report=xml:coverage.xml + uv run pytest -n auto --cov --cov-report=xml:coverage.xml uv run coverage xml #---------------------------------------------- # upload coverage diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e5b3855f..08f97e10 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,7 +33,7 @@ jobs: - name: Test run: | - uv run pytest + uv run pytest -n auto --cov - name: Build run: | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b39fb525..ecbebb33 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,9 +25,15 @@ repos: types: [python] language: system pass_filenames: false + - id: pyright + name: Pyright + entry: uv run pyright statemachine/ + types: [python] + language: system + pass_filenames: false - id: pytest name: Pytest - entry: uv run pytest + entry: uv run pytest -n auto --cov-fail-under=100 types: [python] language: system pass_filenames: false diff --git a/AGENTS.md b/AGENTS.md index 9715876f..11d82713 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,19 +13,85 @@ Django integration, diagram generation, and a flexible callback/listener system. ## Architecture -- `statemachine.py` — Core `StateMachine` class +- `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/sync.py`, `engines/async_.py` — Sync and async run-to-completion engines +- `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 (`error_on_execution`) + +- `StateChart` has `error_on_execution=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. + +### 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 @@ -35,11 +101,11 @@ pre-commit install ## Running tests -Always use `uv` to run commands: +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) -uv run pytest -n auto +timeout 120 uv run pytest -n 4 # Run a specific test file uv run pytest tests/test_signature.py @@ -51,8 +117,48 @@ uv run pytest tests/test_signature.py::TestSignatureAdapter::test_wrap_fn_single uv run pytest -m "not slow" ``` -Tests include doctests from both source modules (`--doctest-modules`) and markdown docs -(`--doctest-glob=*.md`). Coverage is enabled by default. +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. + +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`. ## Linting and formatting @@ -74,7 +180,8 @@ uv run mypy statemachine/ tests/ - **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 +- **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 @@ -82,6 +189,21 @@ uv run mypy statemachine/ tests/ ## 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 @@ -99,6 +221,13 @@ uv run sphinx-build docs docs/_build/html 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` diff --git a/README.md b/README.md index 7e9274fa..c8dd58ea 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![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) -Python [finite-state machines](https://en.wikipedia.org/wiki/Finite-state_machine) made easy. +Python [finite-state machines](https://en.wikipedia.org/wiki/Finite-state_machine) and [statecharts](https://statecharts.dev/) made easy.
@@ -17,55 +17,17 @@ Python [finite-state machines](https://en.wikipedia.org/wiki/Finite-state_machin
Welcome to python-statemachine, an intuitive and powerful state machine library designed for a -great developer experience. We provide a _pythonic_ and expressive API for implementing state -machines in sync or asynchonous Python codebases. - -## Features - -- ✨ **Basic components**: Easily define **States**, **Events**, and **Transitions** to model your logic. -- ⚙️ **Actions and handlers**: Attach actions and handlers to states, events, and transitions to control behavior dynamically. -- 🛡️ **Conditional transitions**: Implement **Guards** and **Validators** to conditionally control transitions, ensuring they only occur when specific conditions are met. -- 🚀 **Full async support**: Enjoy full asynchronous support. Await events, and dispatch callbacks asynchronously for seamless integration with async codebases. -- 🔄 **Full sync support**: Use the same state machine from synchronous codebases without any modifications. -- 🎨 **Declarative and simple API**: Utilize a clean, elegant, and readable API to define your state machine, making it easy to maintain and understand. -- 👀 **Observer pattern support**: Register external and generic objects to watch events and register callbacks. -- 🔍 **Decoupled design**: Separate concerns with a decoupled "state machine" and "model" design, promoting cleaner architecture and easier maintenance. -- ✅ **Correctness guarantees**: Ensured correctness with validations at class definition time: - - Ensures exactly one `initial` state. - - Disallows transitions from `final` states. - - Requires ongoing transitions for all non-final states. - - Guarantees all non-final states have at least one path to a final state if final states are declared. - - Validates the state machine graph representation has a single component. -- 📦 **Flexible event dispatching**: Dispatch events with any extra data, making it available to all callbacks, including actions and guards. -- 🔧 **Dependency injection**: Needed parameters are injected into callbacks. -- 📊 **Graphical representation**: Generate and output graphical representations of state machines. Create diagrams from the command line, at runtime, or even in Jupyter notebooks. -- 🌍 **Internationalization support**: Provides error messages in different languages, making the library accessible to a global audience. -- 🛡️ **Robust testing**: Ensured reliability with a codebase that is 100% covered by automated tests, including all docs examples. Releases follow semantic versioning for predictable releases. -- 🏛️ **Domain model integration**: Seamlessly integrate with domain models using Mixins. -- 🔧 **Django integration**: Automatically discover state machines in Django applications. +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. - -## Installing - -To install Python State Machine, run this command in your terminal: - - pip install python-statemachine - -To generate diagrams from your machines, you'll also need `pydot` and `Graphviz`. You can -install this library already with `pydot` dependency using the `extras` install option. See -our docs for more details. - - pip install python-statemachine[diagrams] - -## First example - -Define your state machine: +## Quick start ```py ->>> from statemachine import StateMachine, State +>>> from statemachine import StateChart, State ->>> class TrafficLightMachine(StateMachine): +>>> class TrafficLightMachine(StateChart): ... "A traffic light machine" ... green = State(initial=True) ... yellow = State() @@ -77,9 +39,8 @@ Define your state machine: ... | red.to(green) ... ) ... -... 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 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.") @@ -89,14 +50,35 @@ Define your state machine: ``` -You can now create an instance: +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 ``` -This state machine can be represented graphically as follows: +Generate a diagram: ```py >>> # This example will only run on automated tests if dot is present @@ -108,13 +90,8 @@ This state machine can be represented graphically as follows: ![](https://raw.githubusercontent.com/fgmacedo/python-statemachine/develop/docs/images/readme_trafficlightmachine.png) - -Where on the `TrafficLightMachine`, we've defined `green`, `yellow`, and `red` as states, and -one event called `cycle`, which is bound to the transitions from `green` to `yellow`, `yellow` to `red`, -and `red` to `green`. We also have defined three callbacks by name convention, `before_cycle`, `on_enter_red`, and `on_exit_red`. - - -Then start sending events to your new state machine: +Parameters are injected into callbacks automatically — the library inspects the +signature and provides only the arguments each callback needs: ```py >>> sm.send("cycle") @@ -122,268 +99,275 @@ Then start sending events to your new state machine: ``` -**That's it.** This is all an external object needs to know about your state machine: How to send events. -Ideally, all states, transitions, and actions should be kept internally and not checked externally to avoid unnecessary coupling. -But if your use case needs, you can inspect state machine properties, like the current state: +## Guards and conditional transitions -```py ->>> sm.current_state.id -'yellow' - -``` - -Or get a complete state representation for debugging purposes: +Use `cond=` and `unless=` to add guards. When multiple transitions share the same +event, declaration order determines priority: ```py ->>> sm.current_state -State('Yellow', id='yellow', value='yellow', initial=False, final=False) +>>> from statemachine import StateChart, State -``` - -The `State` instance can also be checked by equality: +>>> 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 -```py ->>> sm.current_state == TrafficLightMachine.yellow +>>> sm = ApprovalWorkflow() +>>> sm.send("review", score=50) +>>> sm.rejected.is_active True ->>> sm.current_state == sm.yellow +>>> sm = ApprovalWorkflow() +>>> sm.send("review", score=85) +>>> sm.approved.is_active True ``` -Or you can check if a state is active at any time: - -```py ->>> sm.green.is_active -False - ->>> sm.yellow.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. ->>> sm.red.is_active -False -``` +## Compound states — hierarchy -Easily iterate over all states: +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 ->>> [s.id for s in sm.states] -['green', 'yellow', 'red'] +>>> 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 -Or over events: +>>> sm.send("submit") +>>> "review" in sm.configuration_values +True -```py ->>> [t.id for t in sm.events] -['cycle'] +>>> sm.send("approve") +>>> set(sm.configuration_values) == {"published"} +True ``` -Call an event by its id: -```py ->>> sm.cycle() -Don't move. -'Running cycle from yellow to red' +## Parallel states — concurrency -``` -Or send an event with the event id: +`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 ->>> sm.send('cycle') -Go ahead! -'Running cycle from red to green' - ->>> sm.green.is_active +>>> 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 -``` - -You can pass arbitrary positional or keyword arguments to the event, and -they will be propagated to all actions and callbacks using something similar to dependency injection. In other words, the library will only inject the parameters declared on the -callback method. +>>> sm.send("finish_build") +>>> "compiled" in sm.configuration_values and "running" in sm.configuration_values +True -Note how `before_cycle` was declared: +>>> sm.send("finish_tests") +>>> set(sm.configuration_values) == {"released"} +True -```py -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}" ``` -The params `event`, `source`, `target` (and others) are available built-in to be used on any action. -The param `message` is user-defined, in our example we made it default empty so we can call `cycle` with -or without a `message` parameter. -If we pass a `message` parameter, it will be used on the `before_cycle` action: - -```py ->>> sm.send("cycle", message="Please, now slowdown.") -'Running cycle from green to yellow. Please, now slowdown.' - -``` +## History states - -By default, events with transitions that cannot run from the current state or unknown events -raise a `TransitionNotAllowed` exception: +`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 ->>> sm.send("go") -Traceback (most recent call last): -statemachine.exceptions.TransitionNotAllowed: Can't go when in Yellow. - -``` - -Keeping the same state as expected: +>>> 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 -```py ->>> sm.yellow.is_active +>>> sm.send("open_settings") +>>> sm.send("back") +>>> "visual" in sm.configuration_values True ``` -A human-readable name is automatically derived from the `State.id`, which is used on the messages -and in diagrams: - -```py ->>> sm.current_state.name -'Yellow' - -``` +Use `HistoryState(type="deep")` for deep history that remembers the exact leaf +state across nested compounds. -## Async support -We support native coroutine using `asyncio`, enabling seamless integration with asynchronous code. -There's no change on the public API of the library to work on async codebases. +## Eventless transitions +Transitions without an event trigger fire automatically. With a guard, they +fire after any event processing when the condition is met: ```py ->>> class AsyncStateMachine(StateMachine): -... initial = State('Initial', initial=True) -... final = State('Final', final=True) +>>> from statemachine import StateChart, State + +>>> class AutoCounter(StateChart): +... counting = State(initial=True) +... done = State(final=True) ... -... advance = initial.to(final) +... counting.to(done, cond="limit_reached") +... increment = counting.to.itself(internal=True, on="do_increment") ... -... async def on_advance(self): -... return 42 - ->>> async def run_sm(): -... sm = AsyncStateMachine() -... result = await sm.advance() -... print(f"Result is {result}") -... print(sm.current_state) +... 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 ->>> asyncio.run(run_sm()) -Result is 42 -Final +>>> sm.send("increment") +>>> "done" in sm.configuration_values +True ``` -## A more useful example -A simple didactic state machine for controlling an `Order`: +## 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 ->>> class OrderControl(StateMachine): -... 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(OrderControl, self).__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 +>>> from statemachine import StateChart, State + +>>> class ResilientService(StateChart): +... running = State(initial=True) +... failed = State(final=True) ... -... def after_receive_payment(self): -... self.payment_received = True +... process = running.to(running, on="do_work") +... error_execution = running.to(failed) ... -... def on_enter_waiting_for_payment(self): -... self.payment_received = False +... def do_work(self): +... raise RuntimeError("something broke") + +>>> sm = ResilientService() +>>> sm.send("process") +>>> sm.failed.is_active +True ``` -You can use this machine as follows. -```py ->>> control = OrderControl() - ->>> control.add_to_order(3) -3 +## Async support ->>> control.add_to_order(7) -10 +Async callbacks just work — same API, no changes needed. The engine +detects async callbacks and switches to the async engine automatically: ->>> control.receive_payment(4) -[4] +```py +>>> import asyncio +>>> from statemachine import StateChart, State ->>> control.current_state.id -'waiting_for_payment' +>>> class AsyncWorkflow(StateChart): +... idle = State(initial=True) +... done = State(final=True) +... +... finish = idle.to(done) +... +... async def on_finish(self): +... return 42 ->>> control.current_state.name -'Waiting for payment' +>>> async def run(): +... sm = AsyncWorkflow() +... result = await sm.finish() +... print(f"Result: {result}") +... print(sm.done.is_active) ->>> control.process_order() -Traceback (most recent call last): -... -statemachine.exceptions.TransitionNotAllowed: Can't process_order when in Waiting for payment. +>>> asyncio.run(run()) +Result: 42 +True ->>> control.receive_payment(6) -[4, 6] +``` ->>> control.current_state.id -'processing' ->>> control.process_order() +## More features ->>> control.ship_order() +There's a lot more to explore: ->>> control.payment_received -True +- **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** — from the CLI, at runtime, or in Jupyter notebooks +- **Dictionary-based definitions** — create state machines from data structures +- **Internationalization** — error messages in multiple languages ->>> control.order_total -10 +Full documentation: https://python-statemachine.readthedocs.io ->>> control.payments -[4, 6] ->>> control.completed.is_active -True +## Installing +``` +pip install python-statemachine ``` -There's a lot more to cover, please take a look at our docs: -https://python-statemachine.readthedocs.io. +To generate diagrams, install with the `diagrams` extra (requires +[Graphviz](https://graphviz.org/)): +``` +pip install python-statemachine[diagrams] +``` -## Contributing -* Star this project -* Open an Issue -* Fork +## Contributing - If you found this project helpful, please consider giving it a star on GitHub. @@ -393,7 +377,7 @@ request. For more information on how to contribute, please see our [contributing - **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, of feels something being harder than it should be, +- **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. diff --git a/conftest.py b/conftest.py index e8be96c0..2ae7e7bd 100644 --- a/conftest.py +++ b/conftest.py @@ -9,6 +9,7 @@ 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: @@ -23,15 +24,16 @@ 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, path, config): +def pytest_ignore_collect(collection_path, config): if sys.version_info >= (3, 10): # noqa: UP036 return None - if "django_project" in str(path): + if "django_project" in str(collection_path): return True diff --git a/docs/actions.md b/docs/actions.md index f1c10c52..aaef6775 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -1,6 +1,6 @@ # Actions -Action is the way a {ref}`StateMachine` can cause things to happen in the +Action is the way a {ref}`StateChart` can cause things to happen in the outside world, and indeed they are the main reason why they exist at all. The main point of introducing a state machine is for the @@ -11,11 +11,13 @@ Actions are most commonly performed on entry or exit of a state, although it is possible to add them before/after a transition. There are several action callbacks that you can define to interact with a -StateMachine in execution. +StateChart in execution. There are callbacks that you can specify that are generic and will be called when something changes, and are not bound to a specific state or event: +- `prepare_event()` + - `before_transition()` - `on_exit_state()` @@ -29,9 +31,9 @@ when something changes, and are not bound to a specific state or event: The following example offers an overview of the "generic" callbacks available: ```py ->>> from statemachine import StateMachine, State +>>> from statemachine import StateChart, State ->>> class ExampleStateMachine(StateMachine): +>>> class ExampleStateMachine(StateChart): ... initial = State(initial=True) ... final = State(final=True) ... @@ -89,7 +91,7 @@ For each defined {ref}`state`, you can declare `enter` and `exit` callbacks. ### Bind state actions by naming convention -Callbacks by naming convention will be searched on the StateMachine and on the +Callbacks by naming convention will be searched on the StateChart and on the model, using the patterns: - `on_enter_()` @@ -98,9 +100,9 @@ model, using the patterns: ```py ->>> from statemachine import StateMachine, State +>>> from statemachine import StateChart, State ->>> class ExampleStateMachine(StateMachine): +>>> class ExampleStateMachine(StateChart): ... initial = State(initial=True) ... ... loop = initial.to.itself() @@ -118,9 +120,9 @@ model, using the patterns: Use the `enter` or `exit` params available on the `State` constructor. ```py ->>> from statemachine import StateMachine, State +>>> from statemachine import StateChart, State ->>> class ExampleStateMachine(StateMachine): +>>> class ExampleStateMachine(StateChart): ... initial = State(initial=True, enter="entering_initial", exit="leaving_initial") ... ... loop = initial.to.itself() @@ -141,9 +143,9 @@ It's also possible to use an event name as action. ```py ->>> from statemachine import StateMachine, State +>>> from statemachine import StateChart, State ->>> class ExampleStateMachine(StateMachine): +>>> class ExampleStateMachine(StateChart): ... initial = State(initial=True) ... ... loop = initial.to.itself() @@ -158,6 +160,22 @@ It's also possible to use an event name as action. ``` +### Invoke actions + +States can also declare **invoke** callbacks — background work that is spawned when the +state is entered and cancelled when it is exited. Like `enter` and `exit`, invoke supports +all three binding patterns: + +- **Naming convention:** `on_invoke_()` +- **Inline parameter:** `State(invoke=callable)` +- **Decorator:** `@state.invoke` + +```{seealso} +Invoke handlers run outside the main processing loop (in threads or executors) and have +their own completion and error events. See {ref}`invoke` for the full execution model, +cancellation semantics, and advanced patterns like `IInvoke` and `invoke_group`. +``` + ## Transition actions For each {ref}`events`, you can register `before`, `on`, and `after` callbacks. @@ -177,9 +195,9 @@ using the patterns: ```py ->>> from statemachine import StateMachine, State +>>> from statemachine import StateChart, State ->>> class ExampleStateMachine(StateMachine): +>>> class ExampleStateMachine(StateChart): ... initial = State(initial=True) ... ... loop = initial.to.itself() @@ -199,9 +217,9 @@ using the patterns: ### Bind transition actions using params ```py ->>> from statemachine import StateMachine, State +>>> from statemachine import StateChart, State ->>> class ExampleStateMachine(StateMachine): +>>> class ExampleStateMachine(StateChart): ... initial = State(initial=True) ... ... loop = initial.to.itself(before="just_before", on="its_happening", after="loop_completed") @@ -227,9 +245,9 @@ The action will be registered for every {ref}`transition` in the list associated ```py ->>> from statemachine import StateMachine, State +>>> from statemachine import StateChart, State ->>> class ExampleStateMachine(StateMachine): +>>> class ExampleStateMachine(StateChart): ... initial = State(initial=True) ... ... loop = initial.to.itself() @@ -261,9 +279,9 @@ The action will be registered for every {ref}`transition` in the list associated You can also declare an event while also adding a callback: ```py ->>> from statemachine import StateMachine, State +>>> from statemachine import StateChart, State ->>> class ExampleStateMachine(StateMachine): +>>> class ExampleStateMachine(StateChart): ... initial = State(initial=True) ... ... @initial.to.itself() @@ -275,7 +293,7 @@ You can also declare an event while also adding a callback: Note that with this syntax, the resulting `loop` that is present on the `ExampleStateMachine.loop` namespace is not a simple method, but an {ref}`event` trigger. So it only executes if the -StateMachine is in the right state. +StateChart is in the right state. So, you can use the event-oriented approach: @@ -297,6 +315,32 @@ In addition to {ref}`actions`, you can specify {ref}`validators and guards` that See {ref}`conditions` and {ref}`validators`. ``` +### Preparing events + +You can use the `prepare_event` method to add custom information +that will be included in `**kwargs` to all other callbacks. + +A not so usefull example: + +```py +>>> class ExampleStateMachine(StateChart): +... 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' + +``` ## Ordering @@ -314,6 +358,10 @@ Actions registered on the same group don't have order guaranties and are execute - Action - Current state - Description +* - Preparation + - `prepare_event()` + - `source` + - Add custom event metadata. * - Validators - `validators()` - `source` @@ -342,6 +390,10 @@ Actions registered on the same group don't have order guaranties and are execute - `on_enter_state()`, `on_enter_()` - `destination` - Callbacks declared in the destination state. +* - Invoke + - `on_invoke_state()`, `on_invoke_()` + - `destination` + - Background work spawned after entering the state. See {ref}`invoke`. * - After - `after_()`, `after_transition()` - `destination` @@ -367,7 +419,7 @@ Note that `None` will be used if the action callback does not return anything, b defined explicitly. The following provides an example: ```py ->>> class ExampleStateMachine(StateMachine): +>>> class ExampleStateMachine(StateChart): ... initial = State(initial=True) ... ... loop = initial.to.itself() @@ -389,15 +441,15 @@ defined explicitly. The following provides an example: ``` -For {ref}`RTC model`, only the main event will get its value list, while the chained ones simply get -`None` returned. For {ref}`Non-RTC model`, results for every event will always be collected and returned. +Only the main event will get its value list, while the chained ones simply get +`None` returned. (dynamic-dispatch)= (dynamic dispatch)= ## Dependency injection -{ref}`statemachine` implements a dependency injection mechanism on all available {ref}`Actions` and +{ref}`StateChart` implements a dependency injection mechanism on all available {ref}`Actions` and {ref}`Conditions` that automatically inspects and matches the expected callback params with those available by the library in conjunction with any values informed when calling an event using `*args` and `**kwargs`. The library ensures that your method signatures match the expected arguments. diff --git a/docs/api.md b/docs/api.md index a35ef877..e28dae34 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,5 +1,16 @@ # API +## StateChart + +```{versionadded} 3.0.0 +``` + +```{eval-rst} +.. autoclass:: statemachine.statemachine.StateChart + :members: + :undoc-members: +``` + ## StateMachine ```{eval-rst} @@ -20,6 +31,16 @@ :members: ``` +## HistoryState + +```{versionadded} 3.0.0 +``` + +```{eval-rst} +.. autoclass:: statemachine.state.HistoryState + :members: +``` + ## States (class) ```{eval-rst} @@ -79,3 +100,37 @@ .. 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 + ... +``` + +## create_machine_class_from_definition + +```{versionadded} 3.0.0 +``` + +```{eval-rst} +.. autofunction:: statemachine.io.create_machine_class_from_definition +``` diff --git a/docs/async.md b/docs/async.md index 0e4ad08a..c9ec7ef1 100644 --- a/docs/async.md +++ b/docs/async.md @@ -4,7 +4,7 @@ Support for async code was added! ``` -The {ref}`StateMachine` fully supports asynchronous code. You can write async {ref}`actions`, {ref}`guards`, and {ref}`events` triggers, while maintaining the same external API for both synchronous and asynchronous codebases. +The {ref}`StateChart` fully supports asynchronous code. You can write async {ref}`actions`, {ref}`guards`, and {ref}`events` triggers, while maintaining the same external API for both synchronous and asynchronous codebases. This is achieved through a new concept called **engine**, an internal strategy pattern abstraction that manages transitions and callbacks. @@ -89,7 +89,7 @@ async code with a state machine. ```py ->>> class AsyncStateMachine(StateMachine): +>>> class AsyncStateMachine(StateChart): ... initial = State('Initial', initial=True) ... final = State('Final', final=True) ... @@ -103,11 +103,11 @@ async code with a state machine. ... sm = AsyncStateMachine() ... result = await sm.advance() ... print(f"Result is {result}") -... print(sm.current_state) +... print(list(sm.configuration_values)) >>> asyncio.run(run_sm()) Result is 42 -Final +['final'] ``` @@ -124,8 +124,8 @@ If needed, the state machine will create a loop using `asyncio.new_event_loop()` >>> result = sm.advance() >>> print(f"Result is {result}") Result is 42 ->>> print(sm.current_state) -Final +>>> print(list(sm.configuration_values)) +['final'] ``` @@ -134,26 +134,24 @@ Final ## Initial State Activation for Async Code -If **on async code** you perform checks against the `current_state`, like a loop `while sm.current_state.is_final:`, then you must manually -await for the [activate initial state](statemachine.StateMachine.activate_initial_state) to be able to check the current state. +If **on async code** you perform checks against the `configuration`, like a loop `while not sm.is_terminated:`, then you must manually +await for the [activate initial state](statemachine.StateChart.activate_initial_state) to be able to check the configuration. ```{hint} This manual initial state activation on async is because Python don't allow awaiting at class initalization time and the initial state activation may contain async callbacks that must be awaited. ``` -If you don't do any check for current state externally, just ignore this as the initial state is activated automatically before the first event trigger is handled. +If you don't do any check for configuration externally, just ignore this as the initial state is activated automatically before the first event trigger is handled. -You get an error checking the current state before the initial state activation: +You get an error checking the configuration before the initial state activation: ```py >>> async def initialize_sm(): ... sm = AsyncStateMachine() -... print(sm.current_state) +... print(list(sm.configuration_values)) >>> asyncio.run(initialize_sm()) -Traceback (most recent call last): -... -InvalidStateValue: There's no current state set. In async code, did you activate the initial state? (e.g., `await sm.activate_initial_state()`) +[None] ``` @@ -164,10 +162,10 @@ You can activate the initial state explicitly: >>> async def initialize_sm(): ... sm = AsyncStateMachine() ... await sm.activate_initial_state() -... print(sm.current_state) +... print(list(sm.configuration_values)) >>> asyncio.run(initialize_sm()) -Initial +['initial'] ``` @@ -178,9 +176,98 @@ before the event is handled: >>> async def initialize_sm(): ... sm = AsyncStateMachine() ... await sm.keep() # first event activates the initial state before the event is handled -... print(sm.current_state) +... print(list(sm.configuration_values)) >>> asyncio.run(initialize_sm()) -Initial +['initial'] + +``` + +## StateChart async support + +```{versionadded} 3.0.0 +``` + +`StateChart` works identically with the async engine. All statechart features — +compound states, parallel states, history pseudo-states, eventless transitions, +and `done.state` events — are fully supported in async code. The same +`activate_initial_state()` pattern applies: + +```py +>>> async def run(): +... sm = AsyncStateMachine() +... await sm.activate_initial_state() +... result = await sm.send("advance") +... return result + +>>> asyncio.run(run()) +42 ``` + +### Concurrent event sending + +```{versionadded} 3.0.0 +``` + +When multiple coroutines send events concurrently (e.g., via `asyncio.gather`), +each caller receives its own event's result — even though only one coroutine +actually runs the processing loop at a time. + +```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 couldn'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 (reentrant calls) +follow the existing run-to-completion (RTC) model — they are enqueued and +processed within the current macrostep, and the callback receives ``None``. +``` + +If an exception occurs during processing (with `error_on_execution=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. + +### Async-specific limitations + +- **Initial state activation**: In async code, you must `await sm.activate_initial_state()` + before inspecting `sm.configuration`. In sync code this happens + automatically at instantiation time. +- **Delayed events**: Both sync and async engines support `delay=` on `send()`. The async + engine uses `asyncio.sleep()` internally, so it integrates naturally with event loops. +- **Thread safety**: The processing loop uses a non-blocking lock (`_processing.acquire`). + All callbacks run on the same thread they are called from — do not share a state machine + instance across threads without external synchronization. diff --git a/docs/contributing.md b/docs/contributing.md index 079345e3..e9930283 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -159,3 +159,133 @@ Before you submit a pull request, check that it meets these guidelines: 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. diff --git a/docs/diagram.md b/docs/diagram.md index 8dae0aee..62124af5 100644 --- a/docs/diagram.md +++ b/docs/diagram.md @@ -1,6 +1,6 @@ # Diagrams -You can generate diagrams from your {ref}`StateMachine`. +You can generate diagrams from your {ref}`StateChart`. ```{note} This functionality depends on [pydot](https://github.com/pydot/pydot), it means that you need to @@ -42,7 +42,7 @@ Graphviz. For example, on Debian-based systems (such as Ubuntu), you can use the >>> dot = graph() >>> dot.to_string() # doctest: +ELLIPSIS -'digraph list {... +'digraph OrderControl {... ``` @@ -116,7 +116,7 @@ usage: diagram.py [OPTION] Generate diagrams for StateMachine classes. positional arguments: - classpath A fully-qualified dotted path to the StateMachine class. + classpath A fully-qualified dotted path to the StateChart class. out File to generate the image using extension as the output format. optional arguments: diff --git a/docs/guards.md b/docs/guards.md index 84140657..d3cf71f2 100644 --- a/docs/guards.md +++ b/docs/guards.md @@ -22,7 +22,7 @@ A conditional transition occurs only if specific conditions or criteria are met. When a transition is conditional, it includes a condition (also known as a _guard_) that must be satisfied for the transition to take place. If the condition is not met, the transition does not occur, and the state machine remains in its current state or follows an alternative path. -This feature allows for multiple transitions on the same {ref}`event`, with each {ref}`transition` checked in **declaration order** — that is, the order in which the transitions themselves were created using `state.to()`. A condition acts like a predicate (a function that evaluates to true/false) and is checked when a {ref}`statemachine` handles an {ref}`event` with a transition from the current state bound to this event. The first transition that meets the conditions (if any) is executed. If none of the transitions meet the conditions, the state machine either raises an exception or does nothing (see the `allow_event_without_transition` parameter of {ref}`StateMachine`). +This feature allows for multiple transitions on the same {ref}`event`, with each {ref}`transition` checked in **declaration order** — that is, the order in which the transitions themselves were created using `state.to()`. A condition acts like a predicate (a function that evaluates to true/false) and is checked when a {ref}`statemachine` handles an {ref}`event` with a transition from the current state bound to this event. The first transition that meets the conditions (if any) is executed. If none of the transitions meet the conditions, the state machine either raises an exception or does nothing (see the `allow_event_without_transition` attribute of {ref}`StateChart`). ````{important} **Evaluation order is based on declaration order, not composition order.** @@ -161,18 +161,18 @@ So, a condition `s1.to(s2, cond=lambda: [])` will evaluate as `False`, as an emp ### Checking enabled events -The {ref}`StateMachine.allowed_events` property returns events reachable from the current state, +The {ref}`StateChart.allowed_events` property returns events reachable from the current state, but it does **not** evaluate `cond`/`unless` guards. To check which events actually have their -conditions satisfied, use {ref}`StateMachine.enabled_events`. +conditions satisfied, use {ref}`StateChart.enabled_events`. ```{testsetup} ->>> from statemachine import StateMachine, State +>>> from statemachine import StateChart, State ``` ```py ->>> class ApprovalMachine(StateMachine): +>>> class ApprovalMachine(StateChart): ... pending = State(initial=True) ... approved = State(final=True) ... rejected = State(final=True) @@ -202,7 +202,7 @@ arguments. Any `*args`/`**kwargs` passed to `enabled_events()` are forwarded to condition callbacks, just like when triggering an event: ```py ->>> class TaskMachine(StateMachine): +>>> class TaskMachine(StateChart): ... idle = State(initial=True) ... running = State(final=True) ... diff --git a/docs/images/order_control_machine_initial.png b/docs/images/order_control_machine_initial.png index 23f35e6a..e843ddf0 100644 Binary files a/docs/images/order_control_machine_initial.png and b/docs/images/order_control_machine_initial.png differ diff --git a/docs/images/order_control_machine_initial_300dpi.png b/docs/images/order_control_machine_initial_300dpi.png index ac76af90..c4c3bcb3 100644 Binary files a/docs/images/order_control_machine_initial_300dpi.png and b/docs/images/order_control_machine_initial_300dpi.png differ diff --git a/docs/images/order_control_machine_processing.png b/docs/images/order_control_machine_processing.png index a8e23fa9..747d5f78 100644 Binary files a/docs/images/order_control_machine_processing.png and b/docs/images/order_control_machine_processing.png differ diff --git a/docs/images/readme_trafficlightmachine.png b/docs/images/readme_trafficlightmachine.png index 85f38f45..2defa820 100644 Binary files a/docs/images/readme_trafficlightmachine.png and b/docs/images/readme_trafficlightmachine.png differ diff --git a/docs/images/test_state_machine_internal.png b/docs/images/test_state_machine_internal.png index bbe8fb48..f3077f4c 100644 Binary files a/docs/images/test_state_machine_internal.png and b/docs/images/test_state_machine_internal.png differ diff --git a/docs/index.md b/docs/index.md index 26b3e20c..4f3a9988 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,7 +17,10 @@ async mixins integrations diagram +weighted_transitions processing_model +invoke +statecharts api auto_examples/index contributing diff --git a/docs/integrations.md b/docs/integrations.md index 937f501c..c7a7d2f0 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -7,17 +7,17 @@ When used in a Django App, this library implements an auto-discovery hook simila built-in **admin** [autodiscover](https://docs.djangoproject.com/en/dev/ref/contrib/admin/#django.contrib.admin.autodiscover). > This library attempts to import an **statemachine** or **statemachines** module in each installed -> application. Such modules are expected to register `StateMachine` classes to be used with +> application. Such modules are expected to register `StateChart` classes to be used with > the {ref}`MachineMixin`. ```{hint} When using `python-statemachine` to control the state of a Django model, we advise keeping the -{ref}`StateMachine` definitions on their own modules. +{ref}`StateChart` definitions on their own modules. So as circular references may occur, and as a way to help you organize your code, if you put state machines on modules named as mentioned above inside installed -Django Apps, these {ref}`StateMachine` classes will be automatically +Django Apps, these {ref}`StateChart` classes will be automatically imported and registered. This is only an advice, nothing stops you do declare your state machine alongside your models. @@ -31,12 +31,12 @@ Given this StateMachine: ```py # campaign/statemachines.py -from statemachine import StateMachine +from statemachine import StateChart from statemachine import State from statemachine.mixins import MachineMixin -class CampaignMachineWithKeys(StateMachine): +class CampaignMachineWithKeys(StateChart): "A workflow machine" draft = State('Draft', initial=True, value=1) producing = State('Being produced', value=2) diff --git a/docs/invoke.md b/docs/invoke.md new file mode 100644 index 00000000..cec39876 --- /dev/null +++ b/docs/invoke.md @@ -0,0 +1,487 @@ +(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 `error_on_execution=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. diff --git a/docs/listeners.md b/docs/listeners.md index 7fcbdf37..4da4378c 100644 --- a/docs/listeners.md +++ b/docs/listeners.md @@ -87,8 +87,196 @@ Paulista Avenue after: red--(cycle)-->green ``` +## Class-level listener declarations + +```{versionadded} 3.0.0 +``` + +You can declare listeners at the class level so they are automatically attached to every +instance of the state machine. This is useful for cross-cutting concerns like logging, +persistence, or telemetry that should always be present. + +The `listeners` class attribute accepts two forms: + +- **Callable** (class, `functools.partial`, lambda): acts as a factory — called once per + SM instance to produce a fresh listener. Use this for listeners that accumulate state. +- **Instance** (pre-built object): shared across all SM instances. Use this for stateless + listeners like a global logger. + +```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") +>>> [type(l).__name__ for l in sm.active_listeners] +['AuditListener'] + +>>> sm.active_listeners[0].log +['confirm: draft -> confirmed'] + +``` + +### Listeners with configuration + +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'] + +``` + +### Runtime listeners merge with class-level + +Runtime listeners passed via the `listeners=` constructor parameter 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'] + +``` + +### Inheritance + +Child class listeners are appended after parent listeners. The full MRO chain is respected: + +```py +>>> class LogListener: +... pass + +>>> class BaseMachine(StateChart): +... listeners = [LogListener] +... +... 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] +['LogListener', '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'] + +``` + +### Listener `setup()` protocol + +Listeners that need runtime dependencies (e.g., a database session, Redis client) can +define a `setup()` method. It is called during SM `__init__` with the SM instance and +any extra `**kwargs` passed to the constructor. The {ref}`dynamic-dispatch` mechanism +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 PersistentMachine(StateChart): +... listeners = [DBListener] +... +... s1 = State(initial=True) +... s2 = State(final=True) +... go = s1.to(s2) + +>>> sm = PersistentMachine(session="my_db_session") +>>> sm.active_listeners[0].session +'my_db_session' + +``` + +Multiple listeners with different dependencies compose naturally — each `setup()` picks +only the kwargs it needs: + +```py +>>> class CacheListener: +... def __init__(self): +... self.redis = None +... +... def setup(self, sm, redis=None, **kwargs): +... self.redis = redis + +>>> class FullMachine(StateChart): +... listeners = [DBListener, CacheListener] +... +... s1 = State(initial=True) +... s2 = State(final=True) +... go = s1.to(s2) + +>>> sm = FullMachine(session="db_conn", redis="redis_conn") +>>> sm.active_listeners[0].session +'db_conn' +>>> sm.active_listeners[1].redis +'redis_conn' + +``` + +```{note} +The `setup()` method is only called on **factory-created** instances (callable entries). +Shared instances (pre-built objects) do not receive `setup()` calls — they are assumed +to be already configured by whoever created them. +``` + ```{hint} -The `StateMachine` itself is registered as a listener, so by using `listeners` an +The `StateChart` itself is registered as a listener, so by using `listeners` an external object can have the same level of functionalities provided to the built-in class. ``` diff --git a/docs/mixins.md b/docs/mixins.md index 28065a5e..37e4ca50 100644 --- a/docs/mixins.md +++ b/docs/mixins.md @@ -1,7 +1,7 @@ # Mixins -Your {ref}`domain models` can be inherited from a custom mixin to auto-instantiate a {ref}`statemachine`. +Your {ref}`domain models` can be inherited from a custom mixin to auto-instantiate a {ref}`StateChart`. ## MachineMixin @@ -17,11 +17,11 @@ Your {ref}`domain models` can be inherited from a custom mixin to auto-instantia Given a state machine definition: ```py ->>> from statemachine import StateMachine, State +>>> from statemachine import StateChart, State >>> from statemachine.mixins import MachineMixin ->>> class CampaignMachineWithKeys(StateMachine): +>>> class CampaignMachineWithKeys(StateChart): ... "A workflow machine" ... draft = State('Draft', initial=True, value=1) ... producing = State('Being produced', value=2) @@ -51,8 +51,8 @@ class. ``` -When an instance of `Workflow` is created, it receives an instance of `CampaignMachineWithKeys`` -assigned using the `state_machine_attr` name. Also, the `current_state` is stored using the `state_field_name`, in this case, `workflow_step`. +When an instance of `Workflow` is created, it receives an instance of `CampaignMachineWithKeys` +assigned using the `state_machine_attr` name. Also, the state value is stored using the `state_field_name`, in this case, `workflow_step`. ``` py >>> model = Workflow() @@ -63,7 +63,7 @@ True >>> model.workflow_step 1 ->>> model.sm.current_state == model.sm.draft +>>> model.sm.draft in model.sm.configuration True >>> model.produce() # `bind_events_as_methods = True` adds triggers to events in the mixin instance @@ -75,7 +75,7 @@ True >>> model.workflow_step 4 ->>> model.sm.current_state == model.sm.cancelled +>>> model.sm.cancelled in model.sm.configuration True ``` diff --git a/docs/models.md b/docs/models.md index 756ae8b8..f8d9d94c 100644 --- a/docs/models.md +++ b/docs/models.md @@ -3,7 +3,7 @@ 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 `StateMachine` constructor. +to the `StateChart` constructor. If you don't pass an explicit model instance, this simple `Model` will be used: @@ -16,12 +16,54 @@ If you don't pass an explicit model instance, this simple `Model` will be used: ```{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 `StateMachine` definition. +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}`StateMachine`, such as implementing all {ref}`actions` and +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}`StateMachine`. +{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. ``` diff --git a/docs/processing_model.md b/docs/processing_model.md index c7d9b6b9..9acd1f1a 100644 --- a/docs/processing_model.md +++ b/docs/processing_model.md @@ -1,6 +1,8 @@ +(processing-model)= + # Processing model -In the literature, It's expected that all state-machine events should execute on a +In the literature, it's expected that all state-machine events should execute on a [run-to-completion](https://en.wikipedia.org/wiki/UML_state_machine#Run-to-completion_execution_model) (RTC) model. @@ -8,28 +10,18 @@ In the literature, It's expected that all state-machine events should execute on > completes processing of each event before it can start processing the next event. This model of > execution is called run to completion, or RTC. -The main point is: What should happen if the state machine triggers nested events while processing a parent event? - -```{hint} -The importance of this decision depends on your state machine definition. Also the difference between RTC -and non-RTC processing models is more pronounced in a multi-threaded system than in a single-threaded system. -In other words, even if you run in {ref}`Non-RTC model`, only one external {ref}`event` will be -handled at a time and all internal events will run before the next external event is called, -so you only notice the difference if your state machine definition has nested event triggers while -processing these external events. -``` +The main point is: What should happen if the state machine triggers nested events while +processing a parent event? -There are two distinct models for processing events in the library. The 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 in -{ref}`Non-RTC model`, where the {ref}`event` will be run immediately. +This library adheres to the {ref}`RTC model ` to be compliant with the specs, where +the {ref}`event` is put on a queue before processing. Consider this state machine: ```py ->>> from statemachine import StateMachine, State +>>> from statemachine import StateChart, State ->>> class ServerConnection(StateMachine): +>>> class ServerConnection(StateChart): ... disconnected = State(initial=True) ... connecting = State() ... connected = State(final=True) @@ -56,17 +48,23 @@ Consider this state machine: ``` +(rtc-model)= + ## RTC model -In a run-to-completion (RTC) processing model (**default**), the state machine executes each event to completion before processing the next event. This means that the state machine completes all the actions associated with an event before moving on to the next event. This guarantees that the system is always in a consistent state. +In a run-to-completion (RTC) processing model (**default**), the state machine executes each +event to completion before processing the next event. This means that the state machine +completes all the actions associated with an event before moving on to the next event. This +guarantees that the system is always in a consistent state. -If the machine is in `rtc` mode, the event is put on a queue. +Internally, the events are put on a queue before processing. ```{note} -While processing the queue items, if others events are generated, they will be processed sequentially. +While processing the queue items, if other events are generated, they will be processed +sequentially in FIFO order. ``` -Running the above state machine will give these results on the RTC model: +Running the above state machine will give these results: ```py >>> sm = ServerConnection() @@ -86,53 +84,216 @@ after 'connection_succeed' from 'connecting' to 'connected' ``` ```{note} -Note that the events `connect` and `connection_succeed` are executed sequentially, and the `connect.after` runs on the expected order. +Note that the events `connect` and `connection_succeed` are executed sequentially, and the +`connect.after` runs in the expected order. ``` -## Non-RTC model -```{deprecated} 2.3.2 -`StateMachine.rtc` option is deprecated. We'll keep only the **run-to-completion** (RTC) model. -``` +(macrostep-microstep)= + +## Macrosteps and microsteps + +The processing loop is organized into two levels: **macrosteps** and **microsteps**. +Understanding these concepts is key to predicting how the engine processes events, +especially with {ref}`eventless transitions `, internal events +({func}`raise_() `), and {ref}`error.execution `. + +### Microstep + +A **microstep** is the smallest unit of processing. It takes a set of enabled transitions +and executes them atomically: + +1. Run `before` callbacks. +2. Exit source states (run `on_exit` callbacks). +3. Execute transition actions (`on` callbacks). +4. Enter target states (run `on_enter` callbacks). +5. Run `after` callbacks. + +If an error occurs during steps 1–4 and `error_on_execution` is enabled, the error is +caught at the **block level** — meaning remaining actions in that block are skipped, but +the microstep continues and `after` callbacks still run. Each phase (exit, `on`, enter) +is an independent block, so an error in the transition `on` action does not prevent target +states from being entered. See {ref}`block-level error catching ` 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** — a state where 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 trigger that fire + automatically when their guard conditions are met. +2. **Drain the internal queue** — events placed by {func}`raise_() ` + are processed immediately, before any external events. +3. If neither step produced a transition, the macrostep is **done**. -In contrast, in a non-RTC (synchronous) processing model, the state machine starts executing nested events -while processing a parent event. This means that when an event is triggered, the state machine -chains the processing when another event was triggered as a result of the first event. +After the macrostep completes, the engine picks the next event from the **external queue** +(placed by {func}`send() `) and starts a new macrostep. -```{warning} -This can lead to complex and unpredictable behavior in the system if your state-machine definition triggers **nested -events**. +### 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}`triggering-events` for examples of `send()` vs `raise_()`. ``` -If your state machine does not trigger nested events while processing a parent event, -and you plan to use the API in an _imperative programming style_, you can consider using the synchronous mode (non-RTC). +### Processing loop overview -In this model, you can think of events as analogous to simple method calls. +The following diagram shows the complete processing loop algorithm: -```{note} -While processing the {ref}`event`, if others events are generated, they will also be processed immediately, so a **nested** behavior happens. ``` + send("event") + │ + ▼ + ┌──────────────┐ + │ External │ + │ Queue │◄─────────────────────────────┐ + └──────┬───────┘ │ + │ pop event │ + ▼ │ + ┌──────────────────────────────────────┐ │ + │ Macrostep │ │ + │ │ │ + │ ┌──────────────────────┐ │ │ + │ │ Eventless transitions│◄──┐ │ │ + │ │ enabled? │ │ │ │ + │ └──────┬───────────────┘ │ │ │ + │ yes │ no │ │ │ + │ │ │ │ │ │ + │ │ ▼ │ │ │ + │ │ ┌──────────────┐ │ │ │ + │ │ │ Internal │ │ │ │ + │ │ │ queue empty? │ │ │ │ + │ │ └──┬───────┬───┘ │ │ │ + │ │ no │ yes │ │ │ │ + │ │ │ │ │ │ │ + │ │ │ ▼ │ │ │ + │ │ │ Stable │ │ │ + │ │ │ config ───┼───────┼──────┘ + │ │ │ │ │ + │ ▼ ▼ │ │ + │ ┌──────────────┐ │ │ + │ │ Microstep │────────┘ │ + │ │ (execute │ │ + │ │ transitions)│ │ + │ └──────────────┘ │ + │ │ + └─────────────────────────────────────┘ +``` + +(continuous-machines)= -Running the above state machine will give these results on the non-RTC (synchronous) model: +## Continuous state machines + +Most state machines are driven by external events — you call `send()` and the machine +responds. But some use cases require a machine that **processes multiple steps +automatically** within a single macrostep, driven by eventless transitions and internal +events rather than external calls. + +### Chaining 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 ->>> sm = ServerConnection(rtc=False) -enter 'disconnected' from '' given '__initial__' +>>> from statemachine import State, StateChart ->>> sm.send("connect") -exit 'disconnected' to 'connecting' given 'connect' -on 'connect' from 'disconnected' to 'connecting' -enter 'connecting' from 'disconnected' given 'connect' -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' -after 'connect' from 'disconnected' to 'connecting' -['on_transition', 'on_connect'] +>>> 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'] ``` -```{note} -Note that the events `connect` and `connection_succeed` are nested, and the `connect.after` -unexpectedly only runs after `connection_succeed.after`. +All three steps execute within a single macrostep — the caller receives control back only +after the pipeline reaches a stable configuration. + +### Self-loop with eventless transitions + +{ref}`Eventless transitions ` fire automatically whenever their guard condition +is satisfied. A self-transition with a guard 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 eventless +`give_up` transition fires — all within a single macrostep triggered by initialization. diff --git a/docs/releases/2.0.0.md b/docs/releases/2.0.0.md index 594f6407..fbc433f1 100644 --- a/docs/releases/2.0.0.md +++ b/docs/releases/2.0.0.md @@ -89,7 +89,10 @@ 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. -```py +``` +>>> 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") diff --git a/docs/releases/2.2.0.md b/docs/releases/2.2.0.md index 99fcb032..9ac3e83a 100644 --- a/docs/releases/2.2.0.md +++ b/docs/releases/2.2.0.md @@ -39,21 +39,21 @@ and warn you if any states would result in the statemachine becoming trapped in This will currently issue a warning, but can be turned into an exception by setting `strict_states=True` on the class. ``` -```py ->>> from statemachine import StateMachine, State +```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) -Traceback (most recent call last): -... -InvalidDefinition: All non-final states should have at least one outgoing transition. These states have no outgoing transition: ['hazard'] +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} diff --git a/docs/releases/2.3.0.md b/docs/releases/2.3.0.md index 57cb9278..83bb0901 100644 --- a/docs/releases/2.3.0.md +++ b/docs/releases/2.3.0.md @@ -26,24 +26,22 @@ async code with a state machine. ``` -```py ->>> class AsyncStateMachine(StateMachine): -... initial = State('Initial', initial=True) -... final = State('Final', final=True) -... -... advance = initial.to(final) -... -... async def on_advance(self): -... return 42 +```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') +async def run_sm(): + sm = AsyncStateMachine() + res = await sm.advance() + return (42, sm.current_state.name) +asyncio.run(run_sm()) +# (42, 'Final') ``` diff --git a/docs/releases/2.4.0.md b/docs/releases/2.4.0.md index d2f776bb..8054af36 100644 --- a/docs/releases/2.4.0.md +++ b/docs/releases/2.4.0.md @@ -16,31 +16,29 @@ This release introduces support for conditionals with Boolean algebra. You can n Example (with a spoiler of the next highlight): -```py ->>> from statemachine import StateMachine, State, Event +```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 +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", + ) ->>> sm = AnyConditionSM() ->>> sm.submit() -Traceback (most recent call last): -TransitionNotAllowed: Can't finish order when in Start. + used_money: bool = False + used_credit: bool = False ->>> sm.used_credit = True ->>> sm.submit() ->>> sm.current_state.id -'end' +sm = AnyConditionSM() +sm.submit() +# TransitionNotAllowed: Can't finish order when in Start. +sm.used_credit = True +sm.submit() +sm.current_state.id +# 'end' ``` ```{seealso} diff --git a/docs/releases/2.5.0.md b/docs/releases/2.5.0.md index fbdb8b01..8cfe5840 100644 --- a/docs/releases/2.5.0.md +++ b/docs/releases/2.5.0.md @@ -64,46 +64,27 @@ You can think of the event as an implementation of the **command** design patter 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: -``` ->>> import random ->>> random.seed("15") +```python +import random +random.seed("15") ->>> sm = AccountStateMachine() +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() -Choose an action: -0 - Suspend -1 - Overdraft -2 - Close account -User input: 0 -Running the option 0 - Suspend -Choose an action: -0 - Activate -1 - Close account -User input: 0 -Running the option 0 - Activate -Choose an action: -0 - Suspend -1 - Overdraft -2 - Close account -User input: 2 -Running the option 2 - Close account -Account has been closed. +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}") ->>> print(f"SM is in {sm.current_state.name} state.") -SM is in Closed state. + 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 @@ -120,30 +101,28 @@ The following comparison operators are supported: Example: -```py ->>> from statemachine import StateMachine, State, Event +```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 +class AnyConditionSM(StateMachine): + start = State(initial=True) + end = State(final=True) + + submit = Event( + start.to(end, cond="order_value > 100"), + name="finish order", + ) ->>> sm = AnyConditionSM() ->>> sm.submit() -Traceback (most recent call last): -TransitionNotAllowed: Can't finish order when in Start. + order_value: float = 0 ->>> sm.order_value = 135.0 ->>> sm.submit() ->>> sm.current_state.id -'end' +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} diff --git a/docs/releases/3.0.0.md b/docs/releases/3.0.0.md new file mode 100644 index 00000000..c90c9b73 --- /dev/null +++ b/docs/releases/3.0.0.md @@ -0,0 +1,697 @@ +# StateMachine 3.0.0 + +*Not released yet* + +## What's new in 3.0.0 + +Statecharts are there! 🎉 + +Statecharts are a powerful extension to state machines, in a way to organize complex reactive systems as a hierarchical state machine. They extend the concept of state machines by adding two new kinds of states: **parallel states** and **compound states**. + +**Parallel states** are states that can be active at the same time. They are useful for separating the state machine in multiple orthogonal state machines that can be active at the same time. + +**Compound states** are states that have inner states. They are useful for breaking down complex state machines into multiple simpler ones. + +The support for statecharts in this release follows the [SCXML specification](https://www.w3.org/TR/scxml/)*, which is a W3C standard for statecharts notation. Adhering as much as possible to this specification ensures compatibility with other tools and platforms that also implement SCXML, but more important, +sets a standard on the expected behaviour that the library should assume on various edge cases, enabling easier integration and interoperability in complex systems. + +To verify the standard adoption, now the automated tests suite includes several `.scxml` testcases provided by the W3C group. Many thanks for this amazing work! Some of the tests are still failing, in such cases, we've added an `xfail` mark by including a `test.scxml.md` markdown file with details of the execution output. + +While these are exiting news for the library and our community, it also introduces several backwards incompatible changes. Due to the major version release, the new behaviour is assumed by default, but we put +a lot of effort to minimize the changes needed in your codebase, and also introduced a few configuration options that you can enable to restore the old behaviour when possible. The following sections navigate to the new features and includes a migration guide. + +### 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'] + +``` + +Constructor keyword arguments are forwarded to initial state callbacks, so self-contained +machines can receive context at creation time: + +```py +>>> 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!' + +``` + +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. + +### 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}`statecharts` 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}`statecharts` 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}`statecharts` 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}`statecharts`. + +### 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. + +### Create state machine class from a dict definition + +Dinamically create state machine classes by using `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 + +``` + + +### In(state) checks in condition expressions + +Now a condition can check if the state machine current set of active states (a.k.a `configuration`) contains a state using the syntax `cond="In('')"`. + +### Preparing events + +You can use the `prepare_event` method to add custom information +that will be included in `**kwargs` to all other callbacks. + +A not so usefull example: + +```py +>>> 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' + +``` + +### Event matching following SCXML spec + +Now events matching follows the [SCXML spec](https://www.w3.org/TR/scxml/#events): + +> For example, a transition with an `event` attribute of `"error foo"` will match event names `error`, `error.send`, `error.send.failed`, etc. (or `foo`, `foo.bar` etc.) +but would not match events named `errors.my.custom`, `errorhandler.mistake`, `error.send` or `foobar`. + +An event designator consisting solely of `*` can be used as a wildcard matching any sequence of tokens, and thus any event. + +### Error handling with `error.execution` + +When `error_on_execution` 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. See {ref}`block-level error catching `. + +The error object is available as `error` in handler kwargs. See {ref}`error-execution` +for full details. + +### Delayed events + +Specify an event to run in the near future using `delay` (in milliseconds). The engine +will keep track of the execution time and only process the event when `now > execution_time`. + +```python +# Send with delay +sm.send("light_beacons", delay=500) # fires after 500ms + +# Define delay on the Event itself +light = Event(dark.to(lit), delay=100) + +# Cancel a delayed event before it fires +sm.send("light_beacons", delay=5000, send_id="beacon_signal") +sm.cancel_event("beacon_signal") # event is removed from the queue +``` + + +### 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. + + +### `raise_()` method + +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. + +```python +sm.raise_("error_event") # processed in the current macrostep +``` + + +### `cancel_event()` method + +Cancel delayed events by their `send_id`: + +```python +sm.send("timeout", delay=5000, send_id="my_timer") +sm.cancel_event("my_timer") # event is removed from the queue +``` + + +### `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: + +```python +if sm.is_terminated: + print("State machine has finished.") +``` + +### 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. + +### 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: + +```python +from statemachine.contrib.weighted import 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) +``` + +See {ref}`weighted-transitions` for full documentation. + + +### 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. + + +### 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). + + +## Bugfixes in 3.0.0 + +- Fixes [#XXX](https://github.com/fgmacedo/python-statemachine/issues/XXX). + +## Misc in 3.0.0 + +TODO. + +## 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 + + +### Python compatibility in 3.0.0 + +We've dropped support for Python `3.7` and `3.8`. If you need support for these versios use the 2.* series. + +StateMachine 3.0.0 supports Python 3.9, 3.10, 3.11, 3.12, 3.13, and 3.14. + + +### Non-RTC model removed + +This option was deprecated on version 2.3.2. Now all new events are put on a queue before being processed. + + +### Multiple current states + +Due to the support of compound and parallel states, it's now possible to have multiple active states at the same time. + +This introduces an impedance mismatch into the old public API, specifically, `sm.current_state` is deprecated and `sm.current_state_value` can returns a flat value if no compound state or a `set` instead. + +```{note} +To allow a smooth migration, these properties still work as before if there's no compound/parallel states in the state machine definition. +``` + +Old + +```py + def current_state(self) -> "State": +``` + +New + +```py + def current_state(self) -> "State | MutableSet[State]": +``` + +We **strongly** recomend using the new `sm.configuration` that has a stable API returning an `OrderedSet` on all cases: + +```py + @property + def configuration(self) -> OrderedSet["State"]: +``` + +### Entering and exiting states + +Previous versions performed an atomic update of the active state just after the execution of the transition `on` actions. + +Now, we follow the [SCXML spec](https://www.w3.org/TR/scxml/#SelectingTransitions): + +> To execute a microstep, the SCXML Processor MUST execute the transitions in the corresponding optimal enabled transition set. To execute a set of transitions, the SCXML Processor MUST first exit all the states in the transitions' exit set in exit order. It MUST then execute the executable content contained in the transitions in document order. It MUST then enter the states in the transitions' entry set in entry order. + +This introduces backward-incompatible changes, as previously, the `current_state` was never empty, allowing queries on `sm.current_state` or `sm..is_active` even while executing an `on` transition action. + +Now, by default, during a transition, all states in the exit set are exited first, performing the `before` and `exit` callbacks. The `on` callbacks are then executed in an intermediate state that contains only the states that will not be exited, which can be an empty set. Following this, the states in the enter set are entered, with `enter` callbacks executed for each state in document order, and finally, the `after` callbacks are executed with the state machine in the final new configuration. + +We have added two new keyword arguments available only in the `on` callbacks to assist with queries that were performed against `sm.current_state` or active states using `.is_active`: + +- `previous_configuration: OrderedSet[State]`: Contains the set of states that were active before the microstep was taken. +- `new_configuration: OrderedSet[State]`: Contains the set of states that will be active after the microstep finishes. + +Additionally, you can create a state machine instance by passing `atomic_configuration_update=True` (default `False`) to restore the old behavior. When set to `False`, the `sm.configuration` will be updated only once per microstep, just after the `on` callbacks with the `new_configuration`, the set of states that should be active after the microstep. + + +Consider this example that needs to be upgraded: + +```py +class ApprovalMachine(StateMachine): + "A workflow" + + 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): + if self.accepted.is_active and self.model.is_ok(): + return "congrats!" + +``` +The `validate` event is bound to several transitions, and the `on_validate` is expected to return `congrats` only when the state machine was with the `accepted` state active before the event occurs. In the old behavior, checking for `accepted.is_active` evaluates to `True` because the state were not exited before the `on` callback. + +Due to the new behaviour, at the time of the `on_validate` call, the state machine configuration (a.k.a the current set of active states) is empty. So at this point in time `accepted.is_active` evaluates to `False`. To mitigate this case, now you can request one of the two new keyword arguments: `previous_configuration` and `new_configration` in `on` callbacks. + +New way using `previous_configuration`: + +```py +def on_validate(self, previous_configuration): + if self.accepted in previous_configuration and self.model.is_ok(): + return "congrats!" + +``` + + +### `add_observer()` removed + +The method `add_observer`, deprecated since v2.3.2, has been removed. Use `add_listener` instead. + + +### `TransitionNotAllowed` exception changes + +The `TransitionNotAllowed` exception now stores a `configuration` attribute (a `MutableSet[State]`) +instead of a single `state` attribute, reflecting support for multiple active states. The `event` +attribute can also be `None`. + + +### Configuring the event without transition behaviour + +The `allow_event_without_transition` was previously configured as an init parameter, now it's a class-level +attribute. + +Defaults to `False` in `StateMachine` class to preserve maximum backwards compatibility. + + +### `States.from_enum` default `use_enum_instance=True` + +The `use_enum_instance` parameter of `States.from_enum` now defaults to `True` (was `False` in 2.x). +This means state values are the enum instances themselves, not their raw values. + +If your code relies on raw enum values (e.g., integers), pass `use_enum_instance=False` explicitly. + + +### Short registry names removed + +State machine classes are now only registered by their fully-qualified name (`qualname`). +The short-name lookup (by `cls.__name__`) that was deprecated since v0.8 has been removed. + +If you use `get_machine_cls()` (e.g., via `MachineMixin`), make sure you pass the fully-qualified +dotted path. + + +### `strict_states` parameter removed + +The `strict_states` class parameter (introduced in v2.2.0) has been removed and replaced by +two independent class-level attributes that default to `True`: + +- `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. + +**Migration:** + +- Remove `strict_states=True` — this is now the default behavior. +- **Recommended:** fix your state machine definition so that terminal states are marked + `final=True`: + +```py +>>> from statemachine import State, StateChart + +>>> class MySM(StateChart): +... s1 = State(initial=True) +... s2 = State(final=True) +... go = s1.to(s2) + +``` + +- If you intentionally have non-final trap states, replace `strict_states=False` with + `validate_trap_states = False` and/or `validate_final_reachability = False`: + +```py +>>> class MySM(StateChart): +... validate_trap_states = False +... s1 = State(initial=True) +... s2 = State() +... go = s1.to(s2) + +``` diff --git a/docs/releases/index.md b/docs/releases/index.md index d690b684..94617ef5 100644 --- a/docs/releases/index.md +++ b/docs/releases/index.md @@ -10,7 +10,17 @@ with advance notice in the **Deprecations** section of releases. Below are release notes through StateMachine and its patch releases. -### 2.0 releases +### 3.* releases + +```{toctree} +:maxdepth: 2 + +3.0.0 +upgrade_2x_to_3 + +``` + +### 2.* releases ```{toctree} :maxdepth: 2 @@ -34,7 +44,7 @@ Below are release notes through StateMachine and its patch releases. ``` -### 1.0 releases +### 1.* releases This is the last release series to support Python 2.X series. diff --git a/docs/releases/upgrade_2x_to_3.md b/docs/releases/upgrade_2x_to_3.md new file mode 100644 index 00000000..efd7cab8 --- /dev/null +++ b/docs/releases/upgrade_2x_to_3.md @@ -0,0 +1,430 @@ +# Upgrading from 2.x to 3.0 + +This guide covers all backward-incompatible changes in python-statemachine 3.0 and provides +step-by-step instructions for a smooth migration 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. +``` + +## 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`. +5. Replace `sm.add_observer(...)` with `sm.add_listener(...)`. +6. Update code that catches `TransitionNotAllowed` and accesses `.state` → use `.configuration`. +7. Review `on` callbacks that query `is_active` or `current_state` during transitions. +8. If using `States.from_enum`, note that `use_enum_instance` now defaults to `True`. +9. If using `get_machine_cls()` with short names, switch to fully-qualified names. +10. Remove `strict_states=True/False` — replace with `validate_trap_states` / `validate_final_reachability`. + +--- + +## 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` | +| `error_on_execution` | `True` | `False` | + +**Recommendation:** We recommend migrating to `StateChart` for new code. It follows the +[SCXML specification](https://www.w3.org/TR/scxml/) and enables powerful features like compound +states, parallel states, and structured error handling. + +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: + +```python +# Adopt SCXML error handling without switching to StateChart +class MyMachine(StateMachine): + error_on_execution = True + # ... rest of your definition unchanged +``` + +See {ref}`statecharts` 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). + +**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]`. + +**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` + +If you checked whether the machine had reached a final state via `current_state.final`, use the +new `is_terminated` property instead. It works correctly for all topologies (flat, compound, and +parallel). + +**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. + +**Before (2.x):** + +```python +sm.add_observer(my_listener) +``` + +**After (3.0):** + +```python +sm.add_listener(my_listener) +``` + + +## `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. + +**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 +``` + + +## Update `TransitionNotAllowed` exception handling + +The `TransitionNotAllowed` 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] +``` + + +## Configuration update timing during transitions + +This is the most impactful behavioral change for existing code. + +**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). + +**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 +``` + + +## `send()` method — new parameters + +The `send()` method has new optional parameters for delayed events and internal events: + +```python +# 2.x signature +sm.send("event_name", *args, **kwargs) + +# 3.0 signature (fully backward compatible) +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). + +Existing code calling `sm.send("event")` works unchanged. + + +## `__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: + +```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 +``` + + +## `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. + +**Before (2.x):** + +```python +class MyMachine(StateMachine, strict_states=True): + # raises InvalidDefinition for trap states and unreachable final states + ... + +class MyMachine(StateMachine, strict_states=False): + # only warns (default in 2.x) + ... +``` + +**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 + ... +``` + +The two flags are independent — you can disable one while keeping the other enabled. + + +## New features overview + +For full details on all new features, see the {ref}`3.0.0 release notes `. +Here's a summary of what's new: + +- **Compound states** — hierarchical state nesting with `State.Compound` +- **Parallel states** — concurrent regions with `State.Parallel` +- **History pseudo-states** — shallow and deep history with `HistoryState()` +- **Eventless (automatic) transitions** — transitions that fire when guard conditions are met +- **DoneData on final states** — final states can provide data to `done.state` handlers +- **Dynamic state machine creation** — `create_machine_class_from_definition()` from dicts +- **`In()` conditions** — check if a state is active in guard expressions +- **`prepare_event()` callback** — inject custom kwargs into all other callbacks +- **SCXML-compliant event matching** — wildcard events, dot notation +- **Error handling** — `error.execution` event for runtime exceptions +- **Delayed events** — `send(..., delay=500)` with cancellation support +- **`validate_disconnected_states` flag** — disable single-component graph validation +- **`is_terminated` property** — check if the state machine has reached a final state +- **`raise_()` method** — send events to the internal queue +- **`cancel_event()` method** — cancel delayed events by ID diff --git a/docs/statecharts.md b/docs/statecharts.md new file mode 100644 index 00000000..30961e36 --- /dev/null +++ b/docs/statecharts.md @@ -0,0 +1,715 @@ +(statecharts)= +# Statecharts + +Statecharts are a powerful extension to state machines that add hierarchy and concurrency. +They extend the concept of state machines by introducing **compound states** (states with +inner substates) and **parallel states** (states that can be active simultaneously). + +This library's statechart support follows the +[SCXML specification](https://www.w3.org/TR/scxml/), a W3C standard for statechart notation. + +## StateChart vs StateMachine + +The `StateChart` class is the new base class that follows the +[SCXML specification](https://www.w3.org/TR/scxml/). The `StateMachine` class extends +`StateChart` but overrides several defaults to preserve backward compatibility with +existing code. + +The behavioral differences between the two classes are controlled by class-level +attributes. This design allows a gradual upgrade path: you can 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 is the standard +and 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 configuration during a microstep | +| `error_on_execution` | `True` | `False` | Catch runtime errors as `error.execution` events | + +### `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. + +### `enable_self_transition_entries` + +When `True` (SCXML default), a self-transition (a transition where the source and target +are the same state) will execute 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. + +### `atomic_configuration_update` + +When `False` (SCXML default), a microstep follows the SCXML processing order: first exit +all states in the exit set (running exit callbacks), then execute the transition content +(`on` callbacks), then enter all states in the entry set (running entry callbacks). During +the `on` callbacks, the configuration may be empty or partial. + +When `True` (legacy default), the configuration is updated atomically after the `on` +callbacks, so `sm.configuration` and `state.is_active` always reflect a consistent snapshot +during the transition. This was the behavior of all previous versions. + +```{note} +When `atomic_configuration_update` is `False`, `on` callbacks can request +`previous_configuration` and `new_configuration` keyword arguments to inspect which states +were active before and after the microstep. +``` + +### `error_on_execution` + +When `True` (SCXML default), runtime exceptions in callbacks (guards, actions, entry/exit) +are caught by the engine and result in an internal `error.execution` event. When `False` +(legacy default), exceptions propagate normally to the caller. + +See {ref}`error-execution` below for full details. + +### Gradual migration + +You can override any of these attributes individually. For example, to adopt SCXML error +handling in an existing `StateMachine` without changing other behaviors: + +```python +class MyMachine(StateMachine): + error_on_execution = True + # ... everything else behaves as before ... +``` + +Or to use `StateChart` but keep the legacy atomic configuration update: + +```python +class MyChart(StateChart): + atomic_configuration_update = True + # ... SCXML-compliant otherwise ... +``` + +(error-execution)= +## Error handling with `error.execution` + +As described above, when `error_on_execution` is `True`, runtime exceptions during +transitions are caught by the engine and result in an internal `error.execution` event +being placed on the queue. This follows the +[SCXML error handling specification](https://www.w3.org/TR/scxml/#errorsAndEvents). + +You can define transitions for this event to gracefully handle errors within the state +machine itself. + +### The `error_` naming convention + +Since Python identifiers cannot contain dots, the library provides a naming convention: +any event attribute starting with `error_` automatically matches both the underscore form +and the dot-notation form. For example, `error_execution` matches both `"error_execution"` +and `"error.execution"`. + +```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) +... +... def bad_action(self): +... raise RuntimeError("something went wrong") + +>>> sm = MyChart() +>>> sm.send("go") +>>> sm.configuration == {sm.error_state} +True + +``` + +This is equivalent to the more verbose explicit form: + +```python +error_execution = Event(s1.to(error_state), id="error.execution") +``` + +The convention works with both bare transitions and `Event` objects without an explicit `id`: + +```py +>>> from statemachine import Event, State, StateChart + +>>> class ChartWithEvent(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)) +... +... def bad_action(self): +... raise RuntimeError("something went wrong") + +>>> sm = ChartWithEvent() +>>> sm.send("go") +>>> sm.configuration == {sm.error_state} +True + +``` + +```{note} +If you provide an explicit `id=` parameter, it takes precedence and the naming convention +is not applied. +``` + +### Accessing error data + +The error object is passed as `error` in the keyword arguments to callbacks on the +`error.execution` transition: + +```py +>>> from statemachine import State, StateChart + +>>> class ErrorDataChart(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, on="handle_error") +... +... def bad_action(self): +... raise RuntimeError("specific error") +... +... def handle_error(self, error=None, **kwargs): +... self.last_error = error + +>>> sm = ErrorDataChart() +>>> sm.send("go") +>>> str(sm.last_error) +'specific error' + +``` + +### Enabling in StateMachine + +By default, `StateMachine` propagates exceptions (`error_on_execution = False`). You can +enable `error.execution` handling as described in {ref}`gradual migration `: + +```python +class MyMachine(StateMachine): + error_on_execution = True + # ... define states, transitions, error_execution handler ... +``` + +### Error-in-error-handler behavior + +If an error occurs while processing the `error.execution` event itself, the engine +ignores the second error (logging a warning) to prevent infinite loops. The state machine +remains in the configuration it was in before the failed error handler. + +### Block-level error catching + +`StateChart` catches errors at the **block level**, not the microstep level. +Each phase of the microstep — `on_exit`, transition `on` content, `on_enter` — is an +independent block. An error in one block: + +- **Stops remaining actions in that block** (per 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 an exception, the transition +completes: target states are entered and `after_()` callbacks still run. The error +is caught and queued as an `error.execution` internal event, which can be handled by a +separate transition. + +```{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 ignored. 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 +always use block-level error catching regardless of the current event. +``` + +### Cleanup / finalize pattern + +A common need is to run cleanup code after a transition **regardless of success or failure** +— for example, releasing a lock or closing a resource. + +Because `StateChart` catches errors at the **block level** (see above), +`after_()` callbacks still run even when an action raises an exception. This makes +`after_()` a natural **finalize** hook — no need to duplicate cleanup logic in +an error handler. + +For error-specific handling (logging, recovery), define an `error.execution` transition +and use {func}`raise_() ` to auto-recover within the same macrostep. + +See the full working example in {ref}`sphx_glr_auto_examples_statechart_cleanup_machine.py`. + +(compound-states)= +## Compound states + +Compound states contain inner child states. They allow you to break down complex +behavior into hierarchical levels. When a compound state is entered, its `initial` +child is automatically activated along with the parent. + +Use the `State.Compound` inner class syntax to define compound states in Python: + +```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 + +``` + +When entering the `shire` compound state, both `shire` (the parent) and `bag_end` +(the initial child) become active. Transitions within a compound change the active +child while the parent stays active: + +```py +>>> sm.send("visit_pub") +>>> "shire" in sm.configuration_values and "green_dragon" in sm.configuration_values +True + +``` + +Exiting a compound removes the parent **and** all its descendants: + +```py +>>> sm.send("depart") +>>> set(sm.configuration_values) == {"road"} +True + +``` + +Compound states can be nested to any depth: + +```py +>>> from statemachine import State, 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) + +>>> sm = MoriaExpedition() +>>> set(sm.configuration_values) == {"moria", "upper_halls", "entrance"} +True + +``` + +```{note} +Inside a `State.Compound` class body, the class name itself becomes a `State` +instance after the metaclass processes it. The `assert isinstance(upper_halls, State)` +in the example above demonstrates this. +``` + +### `done.state` events + +When a final child of a compound state is entered, the engine automatically queues +a `done.state.{parent_id}` internal event. You can define transitions 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 + +``` + +The `done_state_` naming convention (described below) automatically registers +`done_state_quest` as matching the `done.state.quest` event. + +(parallel-states)= +## Parallel states + +Parallel states activate **all** child regions simultaneously. Each region operates +independently — events in one region don't affect others. 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() +>>> config = set(sm.configuration_values) +>>> all(s in config for s in ("war", "frodos_quest", "shire", "aragorns_path", "ranger")) +True + +``` + +Events in one region leave others unchanged: + +```py +>>> sm.send("journey") +>>> "mordor" in sm.configuration_values and "ranger" in sm.configuration_values +True + +``` + +A `done.state.{parent_id}` event fires only when **all** regions of the parallel +state 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 + +``` +(history-states)= +## History pseudo-states + +A history pseudo-state records the active configuration of a compound state when it +is exited. Re-entering the compound via the history state restores the previously +active child instead of starting from the initial child. + +Import `HistoryState` and place it inside a `State.Compound`: + +```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") +>>> set(sm.configuration_values) == {"outside"} +True + +>>> sm.send("return_via_history") +>>> "gollum" in sm.configuration_values +True + +``` + +### Shallow vs deep history + +By default, `HistoryState()` uses **shallow** history: it remembers only the direct +child of the compound. If the remembered child is itself a compound, it re-enters +from its initial state. + +Use `HistoryState(type="deep")` for **deep** history, which remembers the exact leaf +state and restores the full hierarchy: + +```py +>>> from statemachine import HistoryState, State, 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) + +>>> sm = DeepMemoryOfMoria() +>>> sm.send("explore") +>>> "chamber" in sm.configuration_values +True + +>>> sm.send("escape") +>>> set(sm.configuration_values) == {"outside"} +True + +>>> sm.send("return_deep") +>>> "chamber" in sm.configuration_values and "halls" in sm.configuration_values +True + +``` + +### Default transitions + +You can define a default transition from a history state. This is used when +the compound has never been visited before (no history recorded): + +```python +class MyChart(StateChart): + class compound(State.Compound): + a = State(initial=True) + b = State() + h = HistoryState() + _ = h.to(a) # default: enter 'a' if no history +``` + +(eventless-transitions)= +## Eventless transitions + +Eventless transitions have no event trigger — they fire automatically when their +guard condition is met. If no guard is specified, they fire immediately (unconditional). + +```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 + +``` + +Unconditional eventless chains cascade in a single macrostep. With a guard condition, +the transition fires after any event processing when the guard evaluates to `True`: + +```py +>>> from statemachine import State, StateChart + +>>> class RingCorruption(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 + +>>> sm = RingCorruption() +>>> sm.send("bear_ring") +>>> sm.send("bear_ring") +>>> "resisting" in sm.configuration_values +True + +>>> sm.send("bear_ring") +>>> "corrupted" in sm.configuration_values +True + +``` + +(donedata)= +## DoneData + +Final states can carry data to their `done.state` handlers via the `donedata` parameter. +The `donedata` value should be a callable (or method name string) that returns a `dict`. +The returned dict is passed as keyword arguments to the `done.state` 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`. +``` + +(done-state-convention)= +## The `done_state_` naming convention + +Since Python identifiers cannot contain dots, the library provides a naming convention +for `done.state` events: any event attribute starting with `done_state_` automatically +matches both the underscore form and the dot-notation form. + +Unlike the `error_` convention (which replaces all underscores with dots), `done_state_` +only replaces the prefix, keeping the suffix unchanged. This ensures that multi-word +state names are preserved correctly: + +| Attribute name | Matches | +|-------------------------------|---------------------------------------------------| +| `done_state_quest` | `"done_state_quest"` and `"done.state.quest"` | +| `done_state_lonely_mountain` | `"done_state_lonely_mountain"` and `"done.state.lonely_mountain"` | + +```py +>>> from statemachine import State, 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) + +>>> sm = QuestForErebor() +>>> sm.send("enter_mountain") +>>> set(sm.configuration_values) == {"victory"} +True + +``` + +The convention works with bare transitions, `TransitionList`, and `Event` objects +without an explicit `id`: + +```py +>>> from statemachine import Event, State, StateChart + +>>> class QuestWithEvent(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)) + +>>> sm = QuestWithEvent() +>>> sm.send("finish") +>>> set(sm.configuration_values) == {"celebration"} +True + +``` + +```{note} +If you provide an explicit `id=` parameter, it takes precedence and the naming convention +is not applied. +``` + +(delayed-events)= +## Delayed events + +Events can be scheduled to fire after a delay (in milliseconds) using the `delay` +parameter on `send()`: + +```python +# Fire after 500ms +sm.send("light_beacons", delay=500) + +# Define delay directly on the Event +light = Event(dark.to(lit), delay=100) +``` + +Delayed events remain in the queue until their execution time arrives. They can be +cancelled before firing by providing a `send_id` and calling `cancel_event()`: + +```python +sm.send("light_beacons", delay=5000, send_id="beacon_signal") +sm.cancel_event("beacon_signal") # removed from queue +``` + +(in-conditions)= +## `In()` conditions + +The `In()` function can be used in condition expressions to check whether a state is +currently active. This is especially useful for cross-region guards in parallel states: + +```py +>>> from statemachine import State, 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) +... holding.to(moved_up, cond="In('advanced')") + +>>> sm = CoordinatedAdvance() +>>> "waiting" in sm.configuration_values and "holding" in sm.configuration_values +True + +>>> sm.send("move_forward") +>>> "advanced" in sm.configuration_values and "moved_up" in sm.configuration_values +True + +``` + +The rearguard's eventless transition only fires when the vanguard's `advanced` state +is in the current configuration. diff --git a/docs/states.md b/docs/states.md index 6b1d43e2..9fe6e519 100644 --- a/docs/states.md +++ b/docs/states.md @@ -1,7 +1,7 @@ # States -{ref}`State`, as the name says, holds the representation of a state in a {ref}`StateMachine`. +{ref}`State`, as the name says, holds the representation of a state in a {ref}`StateChart`. ```{eval-rst} .. autoclass:: statemachine.state.State @@ -15,7 +15,7 @@ How to define and attach [](actions.md) to {ref}`States`. ## Initial state -A {ref}`StateMachine` should have one and only one `initial` {ref}`state`. +A {ref}`StateChart` should have one and only one `initial` {ref}`state`. The initial {ref}`state` is entered when the machine starts and the corresponding entering @@ -28,9 +28,9 @@ All states should have at least one transition to and from another state. If any states are unreachable from the initial state, an `InvalidDefinition` exception will be thrown. ```py ->>> from statemachine import StateMachine, State +>>> from statemachine import StateChart, State ->>> class TrafficLightMachine(StateMachine): +>>> class TrafficLightMachine(StateChart): ... "A workflow machine" ... red = State('Red', initial=True, value=1) ... green = State('Green', value=2) @@ -44,17 +44,13 @@ Traceback (most recent call last): InvalidDefinition: There are unreachable states. The statemachine graph should have a single component. Disconnected states: ['hazard'] ``` -`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. -``` +`StateChart` will also check that all non-final states have an outgoing transition. +If any non-final state has no outgoing transitions, an `InvalidDefinition` exception is raised. ```py ->>> from statemachine import StateMachine, State +>>> from statemachine import StateChart, State ->>> class TrafficLightMachine(StateMachine, strict_states=True): +>>> class TrafficLightMachine(StateChart): ... "A workflow machine" ... red = State('Red', initial=True, value=1) ... green = State('Green', value=2) @@ -68,8 +64,19 @@ Traceback (most recent call last): 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 future versions. +You can disable this check by setting `validate_trap_states = False` on the class: + +```py +>>> class TrafficLightMachine(StateChart): +... validate_trap_states = False +... 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) + ``` @@ -81,9 +88,9 @@ You can explicitly set final states. Transitions from these states are not allowed and will raise exceptions. ```py ->>> from statemachine import StateMachine, State +>>> from statemachine import StateChart, State ->>> class CampaignMachine(StateMachine): +>>> class CampaignMachine(StateChart): ... "A workflow machine" ... draft = State('Draft', initial=True, value=1) ... producing = State('Being produced', value=2) @@ -98,14 +105,10 @@ InvalidDefinition: Cannot declare transitions from final state. Invalid state(s) ``` -If you mark any states as final, `StateMachine` will check that all non-final states have a path to reach at least one final state. - -```{note} -This will currently issue a warning, but can be turned into an exception by setting `strict_states=True` on the class. -``` +If you mark any states as final, `StateChart` will check that all non-final states have a path to reach at least one final state. ```py ->>> class CampaignMachine(StateMachine, strict_states=True): +>>> class CampaignMachine(StateChart): ... "A workflow machine" ... draft = State('Draft', initial=True, value=1) ... producing = State('Being produced', value=2) @@ -122,14 +125,12 @@ InvalidDefinition: All non-final states should have at least one path to a final ``` -```{warning} -`strict_states=True` will become the default behaviour in future versions. -``` +You can disable this check by setting `validate_final_reachability = False` on the class. You can query a list of all final states from your statemachine. ```py ->>> class CampaignMachine(StateMachine): +>>> class CampaignMachine(StateChart): ... "A workflow machine" ... draft = State('Draft', initial=True, value=1) ... producing = State('Being produced', value=2) @@ -142,13 +143,36 @@ You can query a list of all final states from your statemachine. >>> machine = CampaignMachine() >>> machine.final_states -[State('Closed', id='closed', value=3, initial=False, final=True)] +[State('Closed', id='closed', value=3, initial=False, final=True, parallel=False)] ->>> machine.current_state in machine.final_states +>>> any(s in machine.final_states for s in machine.configuration) False ``` +### Checking if the machine has terminated + +Use the `is_terminated` property to check whether the state machine has reached a final state +and the engine is no longer running. This is the recommended way to check for completion, +especially with compound and parallel states where multiple states can be active at once. + +```py +>>> machine.send("produce") +>>> machine.is_terminated +False + +>>> machine.send("deliver") +>>> machine.is_terminated +True + +``` + +```{tip} +Prefer `sm.is_terminated` over patterns like `sm.current_state.final` or +`any(s.final for s in sm.configuration)`. It works correctly for all state machine +topologies -- flat, compound, and parallel. +``` + ## States from Enum types {ref}`States` can also be declared from standard `Enum` classes. @@ -164,3 +188,145 @@ For this, use {ref}`States (class)` to convert your `Enum` type to a list of {re ```{seealso} See the example {ref}`sphx_glr_auto_examples_enum_campaign_machine.py`. ``` + +## 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}`compound-states` for full details, nesting, and `done.state` events. +``` + +## 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}`parallel-states` for full details and done events. +``` + +## 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}`history-states` for shallow vs deep history and default transitions. +``` + +## Configuration + +```{versionadded} 3.0.0 +``` + +The `configuration` property returns the set of currently active states as an +`OrderedSet[State]`. With compound and parallel states, multiple states can be +active simultaneously: + +```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 + +``` + +Use `configuration_values` for a set of the active state values (or IDs if no +custom value is defined): + +```py +>>> set(sm.configuration_values) == {"shire", "bag_end"} +True + +``` + +```{note} +The older `current_state` property is deprecated. Use `configuration` instead, +which works consistently for both flat and hierarchical state machines. +``` diff --git a/docs/transitions.md b/docs/transitions.md index 32d17236..98c7e2ef 100644 --- a/docs/transitions.md +++ b/docs/transitions.md @@ -2,7 +2,7 @@ ```{testsetup} ->>> from statemachine import StateMachine, State +>>> from statemachine import StateChart, State >>> from tests.examples.traffic_light_machine import TrafficLightMachine @@ -14,7 +14,7 @@ A state machine is typically composed of a set of {ref}`state`, {ref}`transition and {ref}`actions`. A state is a representation of the system's current condition or behavior. A transition represents the change in the system's state in response to an event or condition. An event is a trigger that causes the system to transition from one state to another, and action -is any side-effect, which is the way a StateMachine can cause things to happen in the +is any side-effect, which is the way a StateChart can cause things to happen in the outside world. @@ -84,7 +84,7 @@ Syntax: >>> draft = State("Draft") >>> draft.to.itself() -TransitionList([Transition(State('Draft', ... +TransitionList([Transition('Draft', 'Draft', event=[], internal=False, initial=False)]) ``` @@ -101,14 +101,15 @@ Syntax: >>> draft = State("Draft") >>> draft.to.itself(internal=True) -TransitionList([Transition(State('Draft', ... +TransitionList([Transition('Draft', 'Draft', event=[], internal=True, initial=False)]) ``` Example: ```py ->>> class TestStateMachine(StateMachine): +>>> class TestStateMachine(StateChart): +... enable_self_transition_entries = False ... initial = State(initial=True) ... ... external_loop = initial.to.itself(on="do_something") @@ -182,9 +183,9 @@ State machine class level. The name will be converted to an {ref}`Event`: ```py >>> from statemachine import Event ->>> class SimpleSM(StateMachine): +>>> class SimpleSM(StateChart): ... initial = State(initial=True) -... final = State() +... final = State(final=True) ... ... start = initial.to(final) # start is a name that will be converted to an `Event` @@ -204,9 +205,9 @@ To declare an explicit event you must also import the {ref}`Event`: ```py >>> from statemachine import Event ->>> class SimpleSM(StateMachine): +>>> class SimpleSM(StateChart): ... initial = State(initial=True) -... final = State() +... final = State(final=True) ... ... start = Event( ... initial.to(final), @@ -224,9 +225,9 @@ To declare an explicit event you must also import the {ref}`Event`: An {ref}`Event` instance or an event id string can also be used as the `event` parameter of a {ref}`transition`. So you can mix these options as you need. ```py ->>> from statemachine import State, StateMachine, Event +>>> from statemachine import State, StateChart, Event ->>> class TrafficLightMachine(StateMachine): +>>> class TrafficLightMachine(StateChart): ... "A traffic light machine" ... ... green = State(initial=True) @@ -321,6 +322,8 @@ Starting from version 2.4.0, use `Event.id` to check for event identifiers inste ``` +(triggering-events)= + ### Triggering events Triggering an event on a state machine means invoking or sending a signal, initiating the @@ -346,8 +349,8 @@ You can invoke the event in an imperative syntax: >>> machine.cycle() 'Running Loop from green to yellow' ->>> machine.current_state.id -'yellow' +>>> [s.id for s in machine.configuration] +['yellow'] ``` @@ -357,8 +360,8 @@ Or in an event-oriented style, events are `send`: >>> machine.send("cycle") 'Running Loop from yellow to red' ->>> machine.current_state.id -'red' +>>> [s.id for s in machine.configuration] +['red'] ``` @@ -366,13 +369,211 @@ This action is executed before the transition associated with `cycle` event is a You can raise an exception at this point to stop a transition from completing. ```py ->>> machine.current_state.id -'red' +>>> [s.id for s in machine.configuration] +['red'] >>> machine.cycle() 'Running Loop from red to green' ->>> machine.current_state.id -'green' +>>> [s.id for s in machine.configuration] +['green'] + +``` + +#### External vs internal events + +{func}`send() ` places events on the **external queue**. External events +are only processed after the current macrostep completes (i.e., after all eventless and +internal events have been handled). + +{func}`raise_() ` places events on the **internal queue**. Internal +events are processed **immediately** within the current macrostep, before any pending external +events. This is equivalent to calling `send(..., internal=True)`. + +Use `raise_()` inside callbacks when you want the event to be handled as part of the current +processing cycle — for example, to trigger auto-recovery after an error or to chain +transitions atomically: + +```py +>>> from statemachine import State, StateChart + +>>> class TwoStepChart(StateChart): +... idle = State("Idle", initial=True) +... step1 = State("Step 1") +... step2 = State("Step 2") +... +... start = idle.to(step1) +... advance = step1.to(step2) +... reset = step2.to(idle) +... +... def on_enter_step1(self): +... self.raise_("advance") # processed before the macrostep ends +... +... def on_enter_step2(self): +... self.raise_("reset") + +>>> sm = TwoStepChart() +>>> sm.send("start") +>>> [s.id for s in sm.configuration] +['idle'] + +``` + +All three transitions (`start → advance → reset`) are processed within a single macrostep. + +```{seealso} +See {ref}`error-execution` for using `raise_()` in error recovery patterns. +``` + +(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). This is useful for modeling automatic state progressions. + +```py +>>> from statemachine import State, StateChart + +>>> class RingCorruption(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 + +>>> sm = RingCorruption() +>>> sm.send("bear_ring") +>>> sm.send("bear_ring") +>>> "resisting" in sm.configuration_values +True + +>>> sm.send("bear_ring") +>>> "corrupted" in sm.configuration_values +True + +``` + +The eventless transition from `resisting` to `corrupted` fires automatically after +the third `bear_ring` event pushes `ring_power` past the threshold. +```{seealso} +See {ref}`eventless-transitions` 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 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) +... march = rivendell.to(moria) + +>>> sm = MiddleEarthJourney() +>>> set(sm.configuration_values) == {"rivendell", "council"} +True + +>>> sm.send("march") +>>> set(sm.configuration_values) == {"moria", "gates"} +True + +``` + +When `march` fires, the engine: +1. Computes the transition domain (the root, since `rivendell` and `moria` are siblings) +2. Exits `council` and `rivendell` (running their exit actions) +3. Enters `moria` and its initial child `gates` (running their entry actions) + +A transition can also go from a deeply nested child to an outer state: + +```py +>>> from statemachine import State, StateChart + +>>> class MoriaEscape(StateChart): +... class moria(State.Compound): +... class halls(State.Compound): +... entrance = State(initial=True) +... bridge = State(final=True) +... cross = entrance.to(bridge) +... assert isinstance(halls, State) +... depths = State(final=True) +... descend = halls.to(depths) +... daylight = State(final=True) +... escape = moria.to(daylight) + +>>> sm = MoriaEscape() +>>> set(sm.configuration_values) == {"moria", "halls", "entrance"} +True + +>>> sm.send("escape") +>>> set(sm.configuration_values) == {"daylight"} +True + +``` + +(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 PriorityExample(StateChart): +... log = [] +... class outer(State.Compound): +... class inner(State.Compound): +... s1 = State(initial=True) +... s2 = State(final=True) +... go = s1.to(s2, on="log_inner") +... assert isinstance(inner, State) +... after_inner = State(final=True) +... done_state_inner = inner.to(after_inner) +... after_outer = State(final=True) +... done_state_outer = outer.to(after_outer) +... def log_inner(self): +... self.log.append("inner won") + +>>> sm = PriorityExample() +>>> sm.send("go") +>>> sm.log +['inner won'] + +``` + +If two transitions at the same level would exit overlapping states (a conflict), +the one selected first in document order wins. diff --git a/docs/weighted_transitions.md b/docs/weighted_transitions.md new file mode 100644 index 00000000..00b066fe --- /dev/null +++ b/docs/weighted_transitions.md @@ -0,0 +1,247 @@ +(weighted-transitions)= + +# Weighted transitions + +```{versionadded} 3.0.0 +``` + +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}`guards` system. +No engine modifications are needed. + +## Installation + +The module is included in the `python-statemachine` package. Import it from the contrib +namespace: + +```python +from statemachine.contrib.weighted import weighted_transitions + +# Only needed when passing transition kwargs (cond, on, etc.) +from statemachine.contrib.weighted import to +``` + +## Basic usage + +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: + +```{testsetup} + +>>> from statemachine import State, StateChart +>>> from statemachine.contrib.weighted import to, weighted_transitions + +``` + +```py +>>> 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}`validators-and-guards`, {ref}`listeners`, async engines, +and {ref}`diagram generation `. diff --git a/pyproject.toml b/pyproject.toml index 922554f6..8e126525 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-statemachine" -version = "2.6.0" +version = "3.0.0" description = "Python Finite State Machines made easy." authors = [{ name = "Fernando Macedo", email = "fgmacedo@gmail.com" }] maintainers = [{ name = "Fernando Macedo", email = "fgmacedo@gmail.com" }] @@ -18,13 +18,11 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Home Automation", "Topic :: Software Development :: Libraries", ] -requires-python = ">=3.7" +requires-python = ">=3.9" [project.urls] homepage = "https://github.com/fgmacedo/python-statemachine" @@ -34,16 +32,16 @@ diagrams = ["pydot >= 2.0.0"] [dependency-groups] dev = [ - "ruff >=0.8.1", + "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.10.0", + "pytest-mock >=3.14.0", "pytest-benchmark >=4.0.0", - "pytest-asyncio", + "pytest-asyncio >=0.25.0", "pydot", "django >=5.2.11; python_version >='3.10'", "pytest-django >=4.8.0; python_version >'3.8'", @@ -56,6 +54,9 @@ dev = [ "sphinx-copybutton >=0.5.2; 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] @@ -67,13 +68,11 @@ packages = ["statemachine/"] [tool.pytest.ini_options] addopts = [ + "-s", "--ignore=docs/conf.py", "--ignore=docs/auto_examples/", "--ignore=docs/_build/", "--ignore=tests/examples/", - "--cov", - "--cov-config", - ".coveragerc", "--doctest-glob=*.md", "--doctest-modules", "--doctest-continue-on-failure", @@ -83,9 +82,20 @@ addopts = [ ] 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"')"""] +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_cli = true +log_cli_level = "DEBUG" +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 @@ -195,3 +205,8 @@ convention = "google" [tool.ruff.lint.flake8-pytest-style] fixture-parentheses = true mark-parentheses = true + +[tool.pyright] +pythonVersion = "3.9" +typeCheckingMode = "basic" +include = ["statemachine"] diff --git a/statemachine/__init__.py b/statemachine/__init__.py index aa743701..6993e7cf 100644 --- a/statemachine/__init__.py +++ b/statemachine/__init__.py @@ -1,9 +1,21 @@ 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__ = "2.6.0" +__version__ = "3.0.0" -__all__ = ["StateMachine", "State", "Event"] +__all__ = [ + "StateChart", + "StateMachine", + "State", + "HistoryState", + "HistoryType", + "Event", + "TModel", +] diff --git a/statemachine/callbacks.py b/statemachine/callbacks.py index 0a6613c1..3da2d9a1 100644 --- a/statemachine/callbacks.py +++ b/statemachine/callbacks.py @@ -5,6 +5,7 @@ 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 @@ -42,8 +43,10 @@ class SpecReference(IntFlag): class CallbackGroup(IntEnum): + PREPARE = auto() ENTER = auto() EXIT = auto() + INVOKE = auto() VALIDATOR = auto() BEFORE = auto() ON = auto() @@ -89,13 +92,13 @@ def __init__( self.attr_name: str = func and func.fget and func.fget.__name__ or "" elif callable(func): self.reference = SpecReference.CALLABLE - self.is_bounded = hasattr(func, "__self__") - self.attr_name = ( - func.__name__ if not self.is_event or self.is_bounded else f"_{func.__name__}_" - ) + 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 - func.is_event = is_event + 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 @@ -110,7 +113,7 @@ def __repr__(self): return f"{type(self).__name__}({self.func!r}, is_convention={self.is_convention!r})" def __str__(self): - name = getattr(self.func, "__name__", self.func) + name = self.attr_name if self.expected_value is False: name = f"!{name}" return name @@ -268,7 +271,7 @@ class CallbacksExecutor: """A list of callbacks that can be executed in order.""" def __init__(self): - self.items: List[CallbackWrapper] = deque() + self.items: "deque[CallbackWrapper]" = deque() self.items_already_seen = set() def __iter__(self): @@ -296,35 +299,84 @@ def add(self, key: str, spec: CallbackSpec, builder: Callable[[], Callable]): insort(self.items, wrapper) - async def async_call(self, *args, **kwargs): - return await asyncio.gather( - *( - callback(*args, **kwargs) - for callback in self - if callback.condition(*args, **kwargs) + 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) + ) ) - ) - async def async_all(self, *args, **kwargs): - coros = [condition(*args, **kwargs) for condition in self] - for coro in asyncio.as_completed(coros): - if not await coro: - return False + 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, **kwargs): - return [ - callback.call(*args, **kwargs) - for callback in self - if callback.condition(*args, **kwargs) - ] - - def all(self, *args, **kwargs): + 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: - if not condition.call(*args, **kwargs): - return False + 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: @@ -334,6 +386,9 @@ def __init__(self) -> None: 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: @@ -359,21 +414,59 @@ def async_or_sync(self): callback._iscoro for executor in self._registry.values() for callback in executor ) - def call(self, key: str, *args, **kwargs): + 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 self._registry[key].call(*args, **kwargs) + return await self._registry[key].async_call(*args, on_error=on_error, **kwargs) - def async_call(self, key: str, *args, **kwargs): - return self._registry[key].async_call(*args, **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) - def all(self, key: str, *args, **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 self._registry[key].all(*args, **kwargs) + 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) - def async_all(self, key: str, *args, **kwargs): - return self._registry[key].async_all(*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: diff --git a/statemachine/contrib/diagram.py b/statemachine/contrib/diagram.py index ee0d14f4..1ec59804 100644 --- a/statemachine/contrib/diagram.py +++ b/statemachine/contrib/diagram.py @@ -5,7 +5,7 @@ import pydot -from ..statemachine import StateMachine +from ..statemachine import StateChart class DotGraphMachine: @@ -18,62 +18,82 @@ class DotGraphMachine: font_name = "Arial" """Graph font face name""" - state_font_size = "10" - """State font size in points""" + state_font_size = "10pt" + """State font size""" state_active_penwidth = 2 """Active state external line width""" state_active_fillcolor = "turquoise" - transition_font_size = "9" - """Transition font size in points""" + transition_font_size = "9pt" + """Transition font size""" - def __init__(self, machine: StateMachine): + def __init__(self, machine): self.machine = machine - def _get_graph(self): - machine = self.machine + def _get_graph(self, machine): return pydot.Dot( - "list", + machine.name, graph_type="digraph", label=machine.name, fontname=self.font_name, fontsize=self.state_font_size, rankdir=self.graph_rankdir, + compound="true", ) - def _initial_node(self): + def _get_subgraph(self, state): + style = ", solid" + if state.parent and state.parent.parallel: + style = ", dashed" + label = state.name + if state.parallel: + label = f"<{state.name} ☷>" + subgraph = pydot.Subgraph( + label=label, + graph_name=f"cluster_{state.id}", + style=f"rounded{style}", + cluster="true", + ) + return subgraph + + def _initial_node(self, state): node = pydot.Node( - "i", - shape="circle", + self._state_id(state), + label="", + shape="point", style="filled", - fontsize="1", + fontsize="1pt", fixedsize="true", width=0.2, height=0.2, ) - node.set_fillcolor("black") + node.set_fillcolor("black") # type: ignore[attr-defined] return node - def _initial_edge(self): + def _initial_edge(self, initial_node, state): + extra_params = {} + if state.states: + extra_params["lhead"] = f"cluster_{state.id}" return pydot.Edge( - "i", - self.machine.initial_state.id, + initial_node.get_name(), + self._state_id(state), label="", color="blue", fontname=self.font_name, fontsize=self.transition_font_size, + **extra_params, ) def _actions_getter(self): - if isinstance(self.machine, StateMachine): + if isinstance(self.machine, StateChart): - def getter(grouper) -> str: + def getter(grouper): # pyright: ignore[reportRedeclaration] return self.machine._callbacks.str(grouper.key) else: - def getter(grouper) -> str: + def getter(grouper): all_names = set(dir(self.machine)) return ", ".join( str(c) for c in grouper if not c.is_convention or c.func in all_names @@ -104,11 +124,33 @@ def _state_actions(self, state): return actions + @staticmethod + def _state_id(state): + if state.states: + return f"{state.id}_anchor" + else: + return state.id + + def _history_node(self, state): + label = "H*" if state.type.is_deep else "H" + return pydot.Node( + self._state_id(state), + label=label, + shape="circle", + style="filled", + fillcolor="white", + fontname=self.font_name, + fontsize="8pt", + fixedsize="true", + width=0.3, + height=0.3, + ) + def _state_as_node(self, state): actions = self._state_actions(state) node = pydot.Node( - state.id, + self._state_id(state), label=f"{state.name}{actions}", shape="rectangle", style="rounded, filled", @@ -116,45 +158,101 @@ def _state_as_node(self, state): fontsize=self.state_font_size, peripheries=2 if state.final else 1, ) - if state == self.machine.current_state: - node.set_penwidth(self.state_active_penwidth) - node.set_fillcolor(self.state_active_fillcolor) + if ( + isinstance(self.machine, StateChart) + and state.value in self.machine.configuration_values + ): + node.set_penwidth(self.state_active_penwidth) # type: ignore[attr-defined] + node.set_fillcolor(self.state_active_fillcolor) # type: ignore[attr-defined] else: - node.set_fillcolor("white") + node.set_fillcolor("white") # type: ignore[attr-defined] return node - def _transition_as_edge(self, transition): - cond = ", ".join([str(cond) for cond in transition.cond]) + def _transition_as_edges(self, transition): + targets = transition.targets if transition.targets else [None] + cond = ", ".join([str(c) for c in transition.cond]) if cond: cond = f"\n[{cond}]" - return pydot.Edge( - transition.source.id, - transition.target.id, - label=f"{transition.event}{cond}", - color="blue", - fontname=self.font_name, - fontsize=self.transition_font_size, - ) - - def get_graph(self): - graph = self._get_graph() - graph.add_node(self._initial_node()) - graph.add_edge(self._initial_edge()) - for state in self.machine.states: - graph.add_node(self._state_as_node(state)) - for transition in state.transitions: - if transition.internal: - continue - graph.add_edge(self._transition_as_edge(transition)) + edges = [] + for i, target in enumerate(targets): + extra_params = {} + has_substates = transition.source.states or (target and target.states) + if transition.source.states: + extra_params["ltail"] = f"cluster_{transition.source.id}" + if target and target.states: + extra_params["lhead"] = f"cluster_{target.id}" + + targetless = target is None + label = f"{transition.event}{cond}" if i == 0 else "" + dst = self._state_id(target) if not targetless else self._state_id(transition.source) + edges.append( + pydot.Edge( + self._state_id(transition.source), + dst, + label=label, + color="blue", + fontname=self.font_name, + fontsize=self.transition_font_size, + minlen=2 if has_substates else 1, + **extra_params, + ) + ) + return edges + def get_graph(self): + graph = self._get_graph(self.machine) + self._graph_states(self.machine, graph) return graph + def _add_transitions(self, graph, state): + for transition in state.transitions: + if transition.internal: + continue + for edge in self._transition_as_edges(transition): + graph.add_edge(edge) + + def _graph_states(self, state, graph): + initial_node = self._initial_node(state) + initial_subgraph = pydot.Subgraph( + graph_name=f"{initial_node.get_name()}_initial", + label="", + peripheries=0, + margin=0, + ) + atomic_states_subgraph = pydot.Subgraph( + graph_name=f"cluster_{initial_node.get_name()}_atomic", + label="", + peripheries=0, + cluster="true", + ) + initial_subgraph.add_node(initial_node) + graph.add_subgraph(initial_subgraph) + graph.add_subgraph(atomic_states_subgraph) + + if state.states and not getattr(state, "parallel", False): + initial = next((s for s in state.states if s.initial), None) + if initial: # pragma: no branch + graph.add_edge(self._initial_edge(initial_node, initial)) + + for substate in state.states: + if substate.states: + subgraph = self._get_subgraph(substate) + self._graph_states(substate, subgraph) + graph.add_subgraph(subgraph) + else: + atomic_states_subgraph.add_node(self._state_as_node(substate)) + self._add_transitions(graph, substate) + + for history_state in getattr(state, "history", []): + atomic_states_subgraph.add_node(self._history_node(history_state)) + self._add_transitions(graph, history_state) + def __call__(self): return self.get_graph() -def quickchart_write_svg(sm: StateMachine, path: str): +def quickchart_write_svg(sm: StateChart, 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, @@ -165,7 +263,12 @@ def quickchart_write_svg(sm: StateMachine, path: str): >>> from tests.examples.order_control_machine import OrderControl >>> sm = OrderControl() >>> print(sm._graph().to_string()) - digraph list { + digraph OrderControl { + compound=true; + fontname=Arial; + fontsize="10pt"; + label=OrderControl; + rankdir=LR; ... To give you an example, we included this method that will serialize the dot, request the graph @@ -193,12 +296,37 @@ def quickchart_write_svg(sm: StateMachine, path: str): f.write(data) +def _find_sm_class(module): + """Find the first StateChart subclass defined in a module.""" + import inspect + + 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): module_name, class_name = qualname.rsplit(".", 1) module = importlib.import_module(module_name) smclass = getattr(module, class_name, None) - if not smclass or not issubclass(smclass, StateMachine): - raise ValueError(f"{class_name} is not a subclass of StateMachine") + 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 diff --git a/statemachine/contrib/weighted.py b/statemachine/contrib/weighted.py new file mode 100644 index 00000000..22ccff1d --- /dev/null +++ b/statemachine/contrib/weighted.py @@ -0,0 +1,193 @@ +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 diff --git a/statemachine/dispatcher.py b/statemachine/dispatcher.py index e8f24e11..6533a8f9 100644 --- a/statemachine/dispatcher.py +++ b/statemachine/dispatcher.py @@ -166,7 +166,7 @@ def _search_callable(self, spec): yield listener.build_key(spec.attr_name), partial(callable_method, func) return - yield f"{spec.attr_name}@None", partial(callable_method, spec.func) + yield f"{spec.attr_name}-{id(spec.func)}@None", partial(callable_method, spec.func) def search_name(self, name): for listener in self.items: @@ -194,7 +194,7 @@ def callable_method(a_callable) -> Callable: if sig.is_coroutine: - async def signature_adapter(*args: Any, **kwargs: Any) -> Any: + 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: diff --git a/statemachine/engines/async_.py b/statemachine/engines/async_.py index ccc88496..e7b2905c 100644 --- a/statemachine/engines/async_.py +++ b/statemachine/engines/async_.py @@ -1,158 +1,533 @@ +import asyncio +import contextvars +import logging +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 ..i18n import _ +from ..orderedset import OrderedSet +from ..state import State +from .base import _ERROR_EXECUTION from .base import BaseEngine if TYPE_CHECKING: - from ..statemachine import StateMachine + from ..event import Event from ..transition import Transition +logger = logging.getLogger(__name__) + + +# 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): - def __init__(self, sm: "StateMachine", rtc: bool = True): - if not rtc: - raise InvalidDefinition(_("Only RTC is supported on async engine")) - super().__init__(sm=sm, rtc=rtc) + """Async engine with full StateChart support. - async def activate_initial_state(self): - """ - Activate the initial state. + Mirrors :class:`SyncEngine` algorithm but uses ``async``/``await`` for callback dispatch. + All pure-computation helpers are inherited from :class:`BaseEngine`. + """ - Called automatically on state machine creation from sync code, but in - async code, the user must call this method explicitly. + def put(self, trigger_data: TriggerData, internal: bool = False, _delayed: bool = False): + """Override to attach an asyncio.Future for external events. - 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. + 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) """ - return await self.processing_loop() + 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) - async def processing_loop(self): - """Process event triggers. + @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) - The simplest implementation is the non-RTC (synchronous), - where the trigger will be run immediately and the result collected as the return. + @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) - .. note:: + def _reject_pending_futures(self, exc: Exception): + """Reject all unresolved futures in the external queue.""" + self.external_queue.reject_futures(exc) - While processing the trigger, if others events are generated, they - will also be processed immediately, so a "nested" behavior happens. + # --- Callback dispatch overrides (async versions of BaseEngine methods) --- - If the machine is on ``rtc`` model (queued), the event is put on a queue, and only the - first event will have the result collected. + async def _get_args_kwargs( + self, transition: "Transition", trigger_data: TriggerData, target: "State | None" = None + ): + cache_key = (id(transition), id(trigger_data), id(target)) - .. note:: - While processing the queue items, if others events are generated, they - will be processed sequentially (and not nested). + if cache_key in self._cache: + return self._cache[cache_key] - """ - # 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): + 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=on_error, **kwargs + ) + return await self.sm._callbacks.async_all( + transition.cond.key, *args, on_error=on_error, **kwargs + ) + + 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) + + async def first_transition_that_matches( + state: State, event: "Event | None" + ) -> "Transition | None": + for s in chain([state], state.ancestors()): + transition: "Transition" + for transition in s.transitions: + if ( + not transition.initial + and predicate(transition, event) + and await self._conditions_match(transition, trigger_data) + ): + return transition 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. - first_result = self._sentinel - try: - # Execute the triggers in the queue in FIFO order until the queue is empty - while self._external_queue: - trigger_data = self._external_queue.popleft() - try: - result = await self._trigger(trigger_data) - if first_result is self._sentinel: - first_result = result - except Exception: - # Whe clear the queue as we don't have an expected behavior - # and cannot keep processing - self._external_queue.clear() - raise - finally: - self._processing.release() - return first_result if first_result is not self._sentinel else None - - async def _trigger(self, trigger_data: TriggerData): - executed = False - if trigger_data.event == "__initial__": - transition = self._initial_transition(trigger_data) - await self._activate(trigger_data, transition) - return self._sentinel - - state = self.sm.current_state - for transition in state.transitions: - if not transition.match(trigger_data.event): - continue - - executed, result = await self._activate(trigger_data, transition) - if not executed: - continue - break - else: - if not self.sm.allow_event_without_transition: - raise TransitionNotAllowed(trigger_data.event, state) - - return result if executed else None + for state in atomic_states: + transition = await first_transition_that_matches(state, trigger_data.event) + if transition is not None: + enabled_transitions.add(transition) - async def enabled_events(self, *args, **kwargs): - sm = self.sm - enabled = {} - for transition in sm.current_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": sm.current_state, - "transition": 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 + logger.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 ) - 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()) - async def _activate(self, trigger_data: TriggerData, transition: "Transition"): - event_data = EventData(trigger_data=trigger_data, transition=transition) - args, kwargs = event_data.args, event_data.extended_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, + ) + + logger.debug("%s Entering state: %s", self._log_id, target) + self._add_state_to_configuration(target) - await self.sm._callbacks.async_call(transition.validators.key, *args, **kwargs) - if not await self.sm._callbacks.async_all(transition.cond.key, *args, **kwargs): - return False, None + on_entry_result = await self.sm._callbacks.async_call( + target.enter.key, *args, on_error=on_error, **kwargs + ) - source = transition.source - target = transition.target + # 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 + ) - result = await self.sm._callbacks.async_call(transition.before.key, *args, **kwargs) - if source is not None and not transition.internal: - await self.sm._callbacks.async_call(source.exit.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) - result += await self.sm._callbacks.async_call(transition.on.key, *args, **kwargs) + return result - self.sm.current_state = target - event_data.state = target - kwargs["state"] = target + async def microstep(self, transitions: "List[Transition]", trigger_data: TriggerData): + self._microstep_count += 1 + logger.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 - if not transition.internal: - await self.sm._callbacks.async_call(target.enter.key, *args, **kwargs) - await self.sm._callbacks.async_call(transition.after.key, *args, **kwargs) + 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 True, result + 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) + logger.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 + logger.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: + logger.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 + logger.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 + logger.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) + logger.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() + + logger.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()) diff --git a/statemachine/engines/base.py b/statemachine/engines/base.py index 0aa2f131..049f0e4a 100644 --- a/statemachine/engines/base.py +++ b/statemachine/engines/base.py @@ -1,40 +1,943 @@ -from collections import deque +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 weakref import proxy +from typing import Any +from typing import Callable +from typing import Dict +from typing import List +from typing import cast +from weakref import ReferenceType +from weakref import ref from ..event import BoundEvent +from ..event import Event +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 StateMachine + 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: "StateMachine", rtc: bool = True): - self.sm: StateMachine = proxy(sm) - self._external_queue: deque = deque() + def __init__(self, sm: "StateChart"): + self._sm: ReferenceType["StateChart"] = ref(sm) + self.external_queue = EventQueue() + self.internal_queue = EventQueue() self._sentinel = object() - self._rtc = rtc + 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._root_parallel_final_pending: "State | None" = None - def put(self, trigger_data: TriggerData): + def empty(self): # pragma: no cover + return self.external_queue.is_empty() + + @property + def sm(self) -> "StateChart": + sm = self._sm() + assert sm, "StateMachine has been destroyed" + return sm + + 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.""" - self._external_queue.append(trigger_data) + 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: + logger.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 ``error_on_execution`` 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.error_on_execution: + 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 error_on_execution`` check so callers don't need + to know about the variation. + """ + if self.sm.error_on_execution: + 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. + """ + logger.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): + def start(self, **kwargs): if self.sm.current_state_value is not None: return - trigger_data = TriggerData( - machine=self.sm, - event=BoundEvent("__initial__", _sm=self.sm), + 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 _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) + + def first_transition_that_matches( + state: State, event: "Event | None" + ) -> "Transition | None": + for s in chain([state], state.ancestors()): + transition: Transition + for transition in s.transitions: + if ( + not transition.initial + and predicate(transition, event) + and self._conditions_match(transition, trigger_data) + ): + return transition + + return None + + for state in atomic_states: + transition = first_transition_that_matches(state, trigger_data.event) + 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 + logger.debug( + "%s macro:%d micro:%d transitions: %s", + self._log_id, + self._macrostep_count, + self._microstep_count, + transitions, ) - self.put(trigger_data) + 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=on_error, **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]) + logger.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] + + logger.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.configuration -= {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 + logger.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) + + new_configuration = cast( + OrderedSet[State], (previous_configuration - states_to_exit) | states_targets_to_enter + ) + logger.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.configuration |= {target} + + 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.""" + logger.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, + ) + + logger.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: + logger.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 + logger.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: + info_history = StateTransition(transition=transition, state=transition.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: + info_history = StateTransition(transition=transition, state=transition.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 _initial_transition(self, trigger_data): - transition = Transition(State(), self.sm._get_initial_state(), event="__initial__") - transition._specs.clear() - return transition + 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 diff --git a/statemachine/engines/sync.py b/statemachine/engines/sync.py index d65ef119..6c856505 100644 --- a/statemachine/engines/sync.py +++ b/statemachine/engines/sync.py @@ -1,20 +1,43 @@ +import logging +from time import sleep +from time import time from typing import TYPE_CHECKING -from ..event_data import EventData +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 +logger = logging.getLogger(__name__) + class SyncEngine(BaseEngine): - def start(self): - super().start() - self.activate_initial_state() + 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 - def activate_initial_state(self): + self.activate_initial_state(**kwargs) + + def activate_initial_state(self, **kwargs): """ Activate the initial state. @@ -24,32 +47,28 @@ def activate_initial_state(self): 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): + def processing_loop(self, caller_future=None): # noqa: C901 """Process event triggers. - The simplest implementation is the non-RTC (synchronous), - where the trigger will be run immediately and the result collected as the return. - - .. note:: - - While processing the trigger, if others events are generated, they - will also be processed immediately, so a "nested" behavior happens. - - If the machine is on ``rtc`` model (queued), the event is put on a queue, and only the - first event will have the result collected. + 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). """ - if not self._rtc: - # The machine is in "synchronous" mode - trigger_data = self._external_queue.popleft() - return self._trigger(trigger_data) - # 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): @@ -58,101 +77,132 @@ def processing_loop(self): # 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. + logger.debug("%s Processing loop started: %s", self._log_id, self.sm.current_state_value) first_result = self._sentinel try: - # Execute the triggers in the queue in FIFO order until the queue is empty - while self._external_queue: - trigger_data = self._external_queue.popleft() - try: - result = self._trigger(trigger_data) - if first_result is self._sentinel: - first_result = result - except Exception: - # Whe clear the queue as we don't have an expected behavior - # and cannot keep processing - self._external_queue.clear() - raise + 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 + logger.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: + logger.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 + logger.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 + logger.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) + logger.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() + logger.debug("%s Processing loop ended", self._log_id) return first_result if first_result is not self._sentinel else None - def _trigger(self, trigger_data: TriggerData): - executed = False - if trigger_data.event == "__initial__": - transition = self._initial_transition(trigger_data) - self._activate(trigger_data, transition) - return self._sentinel - - state = self.sm.current_state - for transition in state.transitions: - if not transition.match(trigger_data.event): - continue - - executed, result = self._activate(trigger_data, transition) - if not executed: - continue - - break - else: - if not self.sm.allow_event_without_transition: - raise TransitionNotAllowed(trigger_data.event, state) - - return result if executed else None - def enabled_events(self, *args, **kwargs): sm = self.sm enabled = {} - for transition in sm.current_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": sm.current_state, - "transition": transition, - } - ) - try: - if sm._callbacks.all(transition.cond.key, *args, **extended_kwargs): + 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) - except Exception: - enabled[event] = getattr(sm, event) return list(enabled.values()) - - def _activate(self, trigger_data: TriggerData, transition: "Transition"): - event_data = EventData(trigger_data=trigger_data, transition=transition) - args, kwargs = event_data.args, event_data.extended_kwargs - - self.sm._callbacks.call(transition.validators.key, *args, **kwargs) - if not self.sm._callbacks.all(transition.cond.key, *args, **kwargs): - return False, None - - source = transition.source - target = transition.target - - result = self.sm._callbacks.call(transition.before.key, *args, **kwargs) - if source is not None and not transition.internal: - self.sm._callbacks.call(source.exit.key, *args, **kwargs) - - result += self.sm._callbacks.call(transition.on.key, *args, **kwargs) - - self.sm.current_state = target - event_data.state = target - kwargs["state"] = target - - if not transition.internal: - self.sm._callbacks.call(target.enter.key, *args, **kwargs) - self.sm._callbacks.call(transition.after.key, *args, **kwargs) - - if len(result) == 0: - result = None - elif len(result) == 1: - result = result[0] - - return True, result diff --git a/statemachine/event.py b/statemachine/event.py index a82d9186..91c98058 100644 --- a/statemachine/event.py +++ b/statemachine/event.py @@ -1,6 +1,7 @@ -from inspect import isawaitable 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 @@ -8,13 +9,31 @@ from .exceptions import InvalidDefinition from .i18n import _ from .transition_mixin import AddCallbacksMixin -from .utils import run_async_from_sync if TYPE_CHECKING: - from .statemachine import StateMachine + 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", @@ -44,7 +63,13 @@ class Event(AddCallbacksMixin, str): name: str """The event name.""" - _sm: "StateMachine | None" = None + 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 @@ -52,10 +77,12 @@ class Event(AddCallbacksMixin, str): def __new__( cls, - transitions: "str | TransitionList | None" = None, + transitions: "str | Transition | TransitionList | None" = None, id: "str | None" = None, name: "str | None" = None, - _sm: "StateMachine | None" = None, + delay: float = 0, + internal: bool = False, + _sm: "StateChart | None" = None, ): if isinstance(transitions, str): id = transitions @@ -66,6 +93,8 @@ def __new__( instance = super().__new__(cls, id) instance.id = id + instance.delay = delay + instance.internal = internal if name: instance.name = name elif _has_real_id: @@ -73,13 +102,15 @@ def __new__( else: instance.name = "" if transitions: - instance._transitions = 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})" + 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 @@ -106,19 +137,19 @@ def __get__(self, instance, owner): """ if instance is None: return self - return BoundEvent(id=self.id, name=self.name, _sm=instance) + return BoundEvent(id=self.id, name=self.name, delay=self.delay, _sm=instance) - def __call__(self, *args, **kwargs): - """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. - """ + 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. - machine = self._sm + 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)) @@ -126,14 +157,25 @@ def __call__(self, *args, **kwargs): trigger_data = TriggerData( machine=machine, event=self, + send_id=send_id, args=args, kwargs=kwargs, ) - machine._put_nonblocking(trigger_data) - result = machine._processing_loop() - if not isawaitable(result): - return result - return run_async_from_sync(result) + + 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 @@ -143,6 +185,33 @@ def split( # type: ignore[override] 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 diff --git a/statemachine/event_data.py b/statemachine/event_data.py index 00eaa65e..a54c0cc0 100644 --- a/statemachine/event_data.py +++ b/statemachine/event_data.py @@ -1,33 +1,52 @@ 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 StateMachine + from .statemachine import StateChart from .transition import Transition -@dataclass +@dataclass(order=True) class TriggerData: - machine: "StateMachine" + machine: "StateChart" = field(compare=False) - event: "Event" + event: "Event | None" = field(compare=False) """The Event that was triggered.""" - model: Any = field(init=False) + 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) + args: tuple = field(default_factory=tuple, compare=False) """All positional arguments provided on the :ref:`Event`.""" - kwargs: dict = field(default_factory=dict) + 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 @@ -47,10 +66,6 @@ class EventData: target: "State" = field(init=False) """The destination :ref:`State` of the :ref:`transition`.""" - result: "Any | None" = None - - executed: bool = False - def __post_init__(self): self.state = self.transition.source self.source = self.transition.source diff --git a/statemachine/events.py b/statemachine/events.py index 052d053a..2fe2be01 100644 --- a/statemachine/events.py +++ b/statemachine/events.py @@ -8,10 +8,13 @@ class Events: def __init__(self): self._items: list[Event] = [] - def __repr__(self): + 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) @@ -31,9 +34,15 @@ def add(self, events): return self - def match(self, event: str): - return any(e == event for e in 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 diff --git a/statemachine/exceptions.py b/statemachine/exceptions.py index f91daddc..6ac3c82c 100644 --- a/statemachine/exceptions.py +++ b/statemachine/exceptions.py @@ -1,4 +1,5 @@ from typing import TYPE_CHECKING +from typing import MutableSet from .i18n import _ @@ -30,10 +31,13 @@ class AttrNotFound(InvalidDefinition): class TransitionNotAllowed(StateMachineError): - "Raised when there's no transition that can run from the current :ref:`state`." + "Raised when there's no transition that can run from the current :ref:`configuration`." - def __init__(self, event: "Event", state: "State"): + def __init__(self, event: "Event | None", configuration: MutableSet["State"]): self.event = event - self.state = state - msg = _("Can't {} when in {}.").format(self.event.name, self.state.name) + 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) diff --git a/statemachine/factory.py b/statemachine/factory.py index e5428e4c..d470e3bd 100644 --- a/statemachine/factory.py +++ b/statemachine/factory.py @@ -1,15 +1,20 @@ -import warnings -from typing import TYPE_CHECKING 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 visit_connected_states +from .graph import states_without_path_to_final_states from .i18n import _ from .state import State from .states import States @@ -20,61 +25,128 @@ 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], - strict_states: bool = False, ) -> 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._strict_states = strict_states cls._events: Dict[Event, None] = {} # used Dict to preserve order and avoid duplicates cls._protected_attrs: set = set() - cls._events_to_update: Dict[Event, Event | None] = {} - + 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() - try: - cls.initial_state: State = next(s for s in cls.states if s.initial) - except StopIteration: - cls.initial_state = None # Abstract SM still don't have states + 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() - if TYPE_CHECKING: - """Makes mypy happy with dynamic created attributes""" + 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 __getattr__(self, attribute: str) -> Any: ... + 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) - has_events = bool(cls._events) - - cls._abstract = not has_states and not has_events + cls._abstract = not has_states # do not validate the base abstract classes - if cls._abstract: + if cls._abstract: # pragma: no cover return - if not has_states: - raise InvalidDefinition(_("There are no states.")) - - if not has_events: - raise InvalidDefinition(_("There are no events.")) - cls._check_initial_state() cls._check_final_states() cls._check_disconnected_state() @@ -83,13 +155,16 @@ def _check(cls): def _check_initial_state(cls): initials = [s for s in cls.states if s.initial] - if len(initials) != 1: + 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 = [ @@ -104,51 +179,43 @@ def _check_final_states(cls): ) 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: - message = _( - "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]) - if cls._strict_states: - raise InvalidDefinition(message) - else: - warnings.warn(message, UserWarning, stacklevel=4) + 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 = cls._states_without_path_to_final_states() + disconnected_states = list(states_without_path_to_final_states(cls.states)) if disconnected_states: - message = _( - "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]) - if cls._strict_states: - raise InvalidDefinition(message) - else: - warnings.warn(message, UserWarning, stacklevel=1) - - def _states_without_path_to_final_states(cls): - return [ - state - for state in cls.states - if not state.final and not any(s.final for s in visit_connected_states(state)) - ] - - def _disconnected_states(cls, starting_state): - visitable_states = set(visit_connected_states(starting_state)) - return set(cls.states) - visitable_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): - disconnected_states = cls._disconnected_states(cls.initial_state) - if disconnected_states: + 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 disconnected_states]) + ).format([s.id for s in states]) ) def _setup(cls): @@ -168,6 +235,27 @@ def _setup(cls): "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", []): @@ -184,16 +272,22 @@ def add_from_attributes(cls, attrs): # noqa: C901 if isinstance(value, State): cls.add_state(key, value) elif isinstance(value, (Transition, TransitionList)): - cls.add_event(event=Event(transitions=value, id=key, name=key)) + event_id = _expand_event_id(key) + cls.add_event(event=Event(transitions=value, id=event_id, name=key)) elif isinstance(value, (Event,)): - cls.add_event( - event=Event( - transitions=value._transitions, - id=key, - name=value.name, - ), - old_event=value, + 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) @@ -211,15 +305,19 @@ def _add_unbounded_callback(cls, attr_name, func): def add_state(cls, id, state: State): state._set_id(id) - cls.states.append(state) cls.states_map[state.value] = state - if not hasattr(cls, id): - setattr(cls, id, 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, diff --git a/statemachine/graph.py b/statemachine/graph.py index ef3c013a..b6f7315e 100644 --- a/statemachine/graph.py +++ b/statemachine/graph.py @@ -1,8 +1,14 @@ 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): - visit = deque() + +def visit_connected_states(state: "State"): + visit = deque["State"]() already_visited = set() visit.append(state) while visit: @@ -11,10 +17,46 @@ def visit_connected_states(state): continue already_visited.add(state) yield state - visit.extend(t.target for t in state.transitions) + 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): +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)) + ) diff --git a/statemachine/invoke.py b/statemachine/invoke.py new file mode 100644 index 00000000..9c775563 --- /dev/null +++ b/statemachine/invoke.py @@ -0,0 +1,603 @@ +"""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 logging +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 + +logger = logging.getLogger(__name__) + + +@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 + logger.debug("invoke: stopping child machine %s", type(child).__name__) + try: + child._engine.running = False + child._engine._invoke_manager.cancel_all() + except Exception: + logger.debug("Error stopping child machine", exc_info=True) + + +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 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.""" + logger.debug("invoke cancel_for_state: %s", 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.""" + logger.debug("invoke cancel_all: %d active", 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 + logger.debug("invoke spawn sync: %s on state %s", 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 + logger.debug( + "invoke %s: completed (cancelled=%s)", 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 + logger.debug("invoke spawn async: %s on state %s", 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 + logger.debug( + "invoke %s: completed (cancelled=%s)", 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 + + logger.debug("invoke cancel: %s", 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: + logger.debug("Error in on_cancel for %s", 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") + ): + logger.debug("invoke autoforward: %s -> %s", 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 diff --git a/statemachine/io/__init__.py b/statemachine/io/__init__.py new file mode 100644 index 00000000..41d947e7 --- /dev/null +++ b/statemachine/io/__init__.py @@ -0,0 +1,225 @@ +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] diff --git a/statemachine/io/scxml/__init__.py b/statemachine/io/scxml/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/statemachine/io/scxml/actions.py b/statemachine/io/scxml/actions.py new file mode 100644 index 00000000..9830f268 --- /dev/null +++ b/statemachine/io/scxml/actions.py @@ -0,0 +1,649 @@ +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__) +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) + logger.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) + logger.debug(f"Assign: {self.action.location} = {value!r}") + + +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.error_on_execution: + 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, name=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, name=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 diff --git a/statemachine/io/scxml/invoke.py b/statemachine/io/scxml/invoke.py new file mode 100644 index 00000000..436c1bf2 --- /dev/null +++ b/statemachine/io/scxml/invoke.py @@ -0,0 +1,233 @@ +"""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) diff --git a/statemachine/io/scxml/parser.py b/statemachine/io/scxml/parser.py new file mode 100644 index 00000000..227955ef --- /dev/null +++ b/statemachine/io/scxml/parser.py @@ -0,0 +1,473 @@ +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 + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test303.scxml b/tests/scxml/w3c/mandatory/test303.scxml new file mode 100644 index 00000000..c245732f --- /dev/null +++ b/tests/scxml/w3c/mandatory/test303.scxml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test304.scxml b/tests/scxml/w3c/mandatory/test304.scxml new file mode 100644 index 00000000..208b36d3 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test304.scxml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test309.scxml b/tests/scxml/w3c/mandatory/test309.scxml new file mode 100644 index 00000000..645268f9 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test309.scxml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test310.scxml b/tests/scxml/w3c/mandatory/test310.scxml new file mode 100644 index 00000000..11e4ae3a --- /dev/null +++ b/tests/scxml/w3c/mandatory/test310.scxml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test311.scxml b/tests/scxml/w3c/mandatory/test311.scxml new file mode 100644 index 00000000..700ec79d --- /dev/null +++ b/tests/scxml/w3c/mandatory/test311.scxml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test312.scxml b/tests/scxml/w3c/mandatory/test312.scxml new file mode 100644 index 00000000..b9e51a55 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test312.scxml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test318.scxml b/tests/scxml/w3c/mandatory/test318.scxml new file mode 100644 index 00000000..27f9836c --- /dev/null +++ b/tests/scxml/w3c/mandatory/test318.scxml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test319.scxml b/tests/scxml/w3c/mandatory/test319.scxml new file mode 100644 index 00000000..29746e96 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test319.scxml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test321.scxml b/tests/scxml/w3c/mandatory/test321.scxml new file mode 100644 index 00000000..8f01dc85 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test321.scxml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test322.scxml b/tests/scxml/w3c/mandatory/test322.scxml new file mode 100644 index 00000000..21c7f28b --- /dev/null +++ b/tests/scxml/w3c/mandatory/test322.scxml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test323.scxml b/tests/scxml/w3c/mandatory/test323.scxml new file mode 100644 index 00000000..5183cdf2 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test323.scxml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test324.scxml b/tests/scxml/w3c/mandatory/test324.scxml new file mode 100644 index 00000000..f763ceec --- /dev/null +++ b/tests/scxml/w3c/mandatory/test324.scxml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test325.scxml b/tests/scxml/w3c/mandatory/test325.scxml new file mode 100644 index 00000000..7159ae91 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test325.scxml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test326.scxml b/tests/scxml/w3c/mandatory/test326.scxml new file mode 100644 index 00000000..5a56c01d --- /dev/null +++ b/tests/scxml/w3c/mandatory/test326.scxml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test329.scxml b/tests/scxml/w3c/mandatory/test329.scxml new file mode 100644 index 00000000..603faa4b --- /dev/null +++ b/tests/scxml/w3c/mandatory/test329.scxml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test330.scxml b/tests/scxml/w3c/mandatory/test330.scxml new file mode 100644 index 00000000..7f7a68de --- /dev/null +++ b/tests/scxml/w3c/mandatory/test330.scxml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test331.scxml b/tests/scxml/w3c/mandatory/test331.scxml new file mode 100644 index 00000000..6394f587 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test331.scxml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test332.scxml b/tests/scxml/w3c/mandatory/test332.scxml new file mode 100644 index 00000000..fc32fba1 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test332.scxml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test333.scxml b/tests/scxml/w3c/mandatory/test333.scxml new file mode 100644 index 00000000..ea92ef05 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test333.scxml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test335.scxml b/tests/scxml/w3c/mandatory/test335.scxml new file mode 100644 index 00000000..bbeb4601 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test335.scxml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test336.scxml b/tests/scxml/w3c/mandatory/test336.scxml new file mode 100644 index 00000000..78d2a06a --- /dev/null +++ b/tests/scxml/w3c/mandatory/test336.scxml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test337.scxml b/tests/scxml/w3c/mandatory/test337.scxml new file mode 100644 index 00000000..47709e63 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test337.scxml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test338.scxml b/tests/scxml/w3c/mandatory/test338.scxml new file mode 100644 index 00000000..b91e0815 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test338.scxml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test339.scxml b/tests/scxml/w3c/mandatory/test339.scxml new file mode 100644 index 00000000..58ca6cea --- /dev/null +++ b/tests/scxml/w3c/mandatory/test339.scxml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test342.scxml b/tests/scxml/w3c/mandatory/test342.scxml new file mode 100644 index 00000000..41755839 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test342.scxml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test343.scxml b/tests/scxml/w3c/mandatory/test343.scxml new file mode 100644 index 00000000..3ccc31a1 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test343.scxml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test344.scxml b/tests/scxml/w3c/mandatory/test344.scxml new file mode 100644 index 00000000..d50e0883 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test344.scxml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test346.scxml b/tests/scxml/w3c/mandatory/test346.scxml new file mode 100644 index 00000000..f7dfc2dc --- /dev/null +++ b/tests/scxml/w3c/mandatory/test346.scxml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test347.scxml b/tests/scxml/w3c/mandatory/test347.scxml new file mode 100644 index 00000000..6b77af2c --- /dev/null +++ b/tests/scxml/w3c/mandatory/test347.scxml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test348.scxml b/tests/scxml/w3c/mandatory/test348.scxml new file mode 100644 index 00000000..f08c5544 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test348.scxml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test349.scxml b/tests/scxml/w3c/mandatory/test349.scxml new file mode 100644 index 00000000..3c68245d --- /dev/null +++ b/tests/scxml/w3c/mandatory/test349.scxml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test350.scxml b/tests/scxml/w3c/mandatory/test350.scxml new file mode 100644 index 00000000..8d3e07de --- /dev/null +++ b/tests/scxml/w3c/mandatory/test350.scxml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test351.scxml b/tests/scxml/w3c/mandatory/test351.scxml new file mode 100644 index 00000000..0a40f0f3 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test351.scxml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test352.scxml b/tests/scxml/w3c/mandatory/test352.scxml new file mode 100644 index 00000000..b45006a4 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test352.scxml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test354.scxml b/tests/scxml/w3c/mandatory/test354.scxml new file mode 100644 index 00000000..f7d19c8c --- /dev/null +++ b/tests/scxml/w3c/mandatory/test354.scxml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + foo + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test355.scxml b/tests/scxml/w3c/mandatory/test355.scxml new file mode 100644 index 00000000..1601eeb8 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test355.scxml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test364.scxml b/tests/scxml/w3c/mandatory/test364.scxml new file mode 100644 index 00000000..0a3e5469 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test364.scxml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test372.scxml b/tests/scxml/w3c/mandatory/test372.scxml new file mode 100644 index 00000000..e7cf923d --- /dev/null +++ b/tests/scxml/w3c/mandatory/test372.scxml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test375.scxml b/tests/scxml/w3c/mandatory/test375.scxml new file mode 100644 index 00000000..c4612ab3 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test375.scxml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test376.scxml b/tests/scxml/w3c/mandatory/test376.scxml new file mode 100644 index 00000000..60d0c1ad --- /dev/null +++ b/tests/scxml/w3c/mandatory/test376.scxml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test377.scxml b/tests/scxml/w3c/mandatory/test377.scxml new file mode 100644 index 00000000..7ed4f73e --- /dev/null +++ b/tests/scxml/w3c/mandatory/test377.scxml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test378.scxml b/tests/scxml/w3c/mandatory/test378.scxml new file mode 100644 index 00000000..7a48ad34 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test378.scxml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test387.scxml b/tests/scxml/w3c/mandatory/test387.scxml new file mode 100644 index 00000000..1ece9493 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test387.scxml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test388.scxml b/tests/scxml/w3c/mandatory/test388.scxml new file mode 100644 index 00000000..1fb1153d --- /dev/null +++ b/tests/scxml/w3c/mandatory/test388.scxml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test396.scxml b/tests/scxml/w3c/mandatory/test396.scxml new file mode 100644 index 00000000..a8aeda66 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test396.scxml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test399.scxml b/tests/scxml/w3c/mandatory/test399.scxml new file mode 100644 index 00000000..566dd43a --- /dev/null +++ b/tests/scxml/w3c/mandatory/test399.scxml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test401.scxml b/tests/scxml/w3c/mandatory/test401.scxml new file mode 100644 index 00000000..ac3b2229 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test401.scxml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test402.scxml b/tests/scxml/w3c/mandatory/test402.scxml new file mode 100644 index 00000000..56e997b7 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test402.scxml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test403a.scxml b/tests/scxml/w3c/mandatory/test403a.scxml new file mode 100644 index 00000000..771ca16a --- /dev/null +++ b/tests/scxml/w3c/mandatory/test403a.scxml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test403b.scxml b/tests/scxml/w3c/mandatory/test403b.scxml new file mode 100644 index 00000000..15b354a5 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test403b.scxml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test403c.scxml b/tests/scxml/w3c/mandatory/test403c.scxml new file mode 100644 index 00000000..e3ce184a --- /dev/null +++ b/tests/scxml/w3c/mandatory/test403c.scxml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test404.scxml b/tests/scxml/w3c/mandatory/test404.scxml new file mode 100644 index 00000000..7c9ccb76 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test404.scxml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test405.scxml b/tests/scxml/w3c/mandatory/test405.scxml new file mode 100644 index 00000000..49146384 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test405.scxml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test406.scxml b/tests/scxml/w3c/mandatory/test406.scxml new file mode 100644 index 00000000..7d8862a2 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test406.scxml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test407.scxml b/tests/scxml/w3c/mandatory/test407.scxml new file mode 100644 index 00000000..0e001288 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test407.scxml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test409.scxml b/tests/scxml/w3c/mandatory/test409.scxml new file mode 100644 index 00000000..10551864 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test409.scxml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test411.scxml b/tests/scxml/w3c/mandatory/test411.scxml new file mode 100644 index 00000000..ae92d718 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test411.scxml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test412.scxml b/tests/scxml/w3c/mandatory/test412.scxml new file mode 100644 index 00000000..f57219e1 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test412.scxml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test413.scxml b/tests/scxml/w3c/mandatory/test413.scxml new file mode 100644 index 00000000..6b0f1db3 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test413.scxml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test416.scxml b/tests/scxml/w3c/mandatory/test416.scxml new file mode 100644 index 00000000..9892ebe9 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test416.scxml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test417.scxml b/tests/scxml/w3c/mandatory/test417.scxml new file mode 100644 index 00000000..d114256b --- /dev/null +++ b/tests/scxml/w3c/mandatory/test417.scxml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test419.scxml b/tests/scxml/w3c/mandatory/test419.scxml new file mode 100644 index 00000000..9d0b527b --- /dev/null +++ b/tests/scxml/w3c/mandatory/test419.scxml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test421.scxml b/tests/scxml/w3c/mandatory/test421.scxml new file mode 100644 index 00000000..c50581aa --- /dev/null +++ b/tests/scxml/w3c/mandatory/test421.scxml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test422.scxml b/tests/scxml/w3c/mandatory/test422.scxml new file mode 100644 index 00000000..667e398b --- /dev/null +++ b/tests/scxml/w3c/mandatory/test422.scxml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test423.scxml b/tests/scxml/w3c/mandatory/test423.scxml new file mode 100644 index 00000000..6d79f169 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test423.scxml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test487.scxml b/tests/scxml/w3c/mandatory/test487.scxml new file mode 100644 index 00000000..4fd6e270 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test487.scxml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test488.scxml b/tests/scxml/w3c/mandatory/test488.scxml new file mode 100644 index 00000000..ebb5b96f --- /dev/null +++ b/tests/scxml/w3c/mandatory/test488.scxml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test495.scxml b/tests/scxml/w3c/mandatory/test495.scxml new file mode 100644 index 00000000..fefbdeec --- /dev/null +++ b/tests/scxml/w3c/mandatory/test495.scxml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test496.scxml b/tests/scxml/w3c/mandatory/test496.scxml new file mode 100644 index 00000000..2f848784 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test496.scxml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test500.scxml b/tests/scxml/w3c/mandatory/test500.scxml new file mode 100644 index 00000000..c6baa107 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test500.scxml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test501.scxml b/tests/scxml/w3c/mandatory/test501.scxml new file mode 100644 index 00000000..59641b39 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test501.scxml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test503.scxml b/tests/scxml/w3c/mandatory/test503.scxml new file mode 100644 index 00000000..f5e57bc3 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test503.scxml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test504.scxml b/tests/scxml/w3c/mandatory/test504.scxml new file mode 100644 index 00000000..305c04e1 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test504.scxml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test505.scxml b/tests/scxml/w3c/mandatory/test505.scxml new file mode 100644 index 00000000..7db44935 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test505.scxml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test506.scxml b/tests/scxml/w3c/mandatory/test506.scxml new file mode 100644 index 00000000..4a478e7d --- /dev/null +++ b/tests/scxml/w3c/mandatory/test506.scxml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test521.scxml b/tests/scxml/w3c/mandatory/test521.scxml new file mode 100644 index 00000000..569938ee --- /dev/null +++ b/tests/scxml/w3c/mandatory/test521.scxml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test525.scxml b/tests/scxml/w3c/mandatory/test525.scxml new file mode 100644 index 00000000..aebe01e7 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test525.scxml @@ -0,0 +1,31 @@ + + + + [1, 2, 3] + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test527.scxml b/tests/scxml/w3c/mandatory/test527.scxml new file mode 100644 index 00000000..37e5984c --- /dev/null +++ b/tests/scxml/w3c/mandatory/test527.scxml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test528.scxml b/tests/scxml/w3c/mandatory/test528.scxml new file mode 100644 index 00000000..947ef0f5 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test528.scxml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test529.scxml b/tests/scxml/w3c/mandatory/test529.scxml new file mode 100644 index 00000000..9012d26a --- /dev/null +++ b/tests/scxml/w3c/mandatory/test529.scxml @@ -0,0 +1,27 @@ + + + + + + + + + + + 21 + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test530.scxml b/tests/scxml/w3c/mandatory/test530.scxml new file mode 100644 index 00000000..30a3254b --- /dev/null +++ b/tests/scxml/w3c/mandatory/test530.scxml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test533.scxml b/tests/scxml/w3c/mandatory/test533.scxml new file mode 100644 index 00000000..c9ac388a --- /dev/null +++ b/tests/scxml/w3c/mandatory/test533.scxml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test550.scxml b/tests/scxml/w3c/mandatory/test550.scxml new file mode 100644 index 00000000..d4874242 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test550.scxml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test551.scxml b/tests/scxml/w3c/mandatory/test551.scxml new file mode 100644 index 00000000..a84190a1 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test551.scxml @@ -0,0 +1,28 @@ + + + + + 123 + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test552.scxml b/tests/scxml/w3c/mandatory/test552.scxml new file mode 100644 index 00000000..e46f6543 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test552.scxml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test552.txt b/tests/scxml/w3c/mandatory/test552.txt new file mode 100644 index 00000000..af801f43 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test552.txt @@ -0,0 +1 @@ + diff --git a/tests/scxml/w3c/mandatory/test553.scxml b/tests/scxml/w3c/mandatory/test553.scxml new file mode 100644 index 00000000..68c8c366 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test553.scxml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test554.scxml b/tests/scxml/w3c/mandatory/test554.scxml new file mode 100644 index 00000000..fa370e25 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test554.scxml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test570.scxml b/tests/scxml/w3c/mandatory/test570.scxml new file mode 100644 index 00000000..81723b27 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test570.scxml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test576.scxml b/tests/scxml/w3c/mandatory/test576.scxml new file mode 100644 index 00000000..e2ae3371 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test576.scxml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test579.scxml b/tests/scxml/w3c/mandatory/test579.scxml new file mode 100644 index 00000000..12fa1952 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test579.scxml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/mandatory/test580.scxml b/tests/scxml/w3c/mandatory/test580.scxml new file mode 100644 index 00000000..d8a61af8 --- /dev/null +++ b/tests/scxml/w3c/mandatory/test580.scxml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test193.scxml b/tests/scxml/w3c/optional/test193.scxml new file mode 100644 index 00000000..d9496c19 --- /dev/null +++ b/tests/scxml/w3c/optional/test193.scxml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test201.scxml b/tests/scxml/w3c/optional/test201.scxml new file mode 100644 index 00000000..de31c22e --- /dev/null +++ b/tests/scxml/w3c/optional/test201.scxml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test278.scxml b/tests/scxml/w3c/optional/test278.scxml new file mode 100644 index 00000000..81f8ec26 --- /dev/null +++ b/tests/scxml/w3c/optional/test278.scxml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test444.scxml b/tests/scxml/w3c/optional/test444.scxml new file mode 100644 index 00000000..1d45b46b --- /dev/null +++ b/tests/scxml/w3c/optional/test444.scxml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test445.scxml b/tests/scxml/w3c/optional/test445.scxml new file mode 100644 index 00000000..90fad6e4 --- /dev/null +++ b/tests/scxml/w3c/optional/test445.scxml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test446.scxml b/tests/scxml/w3c/optional/test446.scxml new file mode 100644 index 00000000..55ed6677 --- /dev/null +++ b/tests/scxml/w3c/optional/test446.scxml @@ -0,0 +1,28 @@ + + + + + [1, 2, 3] + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test446.txt b/tests/scxml/w3c/optional/test446.txt new file mode 100644 index 00000000..3cc0ecbe --- /dev/null +++ b/tests/scxml/w3c/optional/test446.txt @@ -0,0 +1 @@ +[1,2,3] diff --git a/tests/scxml/w3c/optional/test448.scxml b/tests/scxml/w3c/optional/test448.scxml new file mode 100644 index 00000000..183b8965 --- /dev/null +++ b/tests/scxml/w3c/optional/test448.scxml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test449.scxml b/tests/scxml/w3c/optional/test449.scxml new file mode 100644 index 00000000..8bfea009 --- /dev/null +++ b/tests/scxml/w3c/optional/test449.scxml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test451.scxml b/tests/scxml/w3c/optional/test451.scxml new file mode 100644 index 00000000..beaca3e4 --- /dev/null +++ b/tests/scxml/w3c/optional/test451.scxml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test452.scxml b/tests/scxml/w3c/optional/test452.scxml new file mode 100644 index 00000000..60d81470 --- /dev/null +++ b/tests/scxml/w3c/optional/test452.scxml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test453.scxml b/tests/scxml/w3c/optional/test453.scxml new file mode 100644 index 00000000..8040cb84 --- /dev/null +++ b/tests/scxml/w3c/optional/test453.scxml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test456.scxml b/tests/scxml/w3c/optional/test456.scxml new file mode 100644 index 00000000..2683ba9a --- /dev/null +++ b/tests/scxml/w3c/optional/test456.scxml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test457.scxml b/tests/scxml/w3c/optional/test457.scxml new file mode 100644 index 00000000..bbe09a7d --- /dev/null +++ b/tests/scxml/w3c/optional/test457.scxml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test459.scxml b/tests/scxml/w3c/optional/test459.scxml new file mode 100644 index 00000000..9b278951 --- /dev/null +++ b/tests/scxml/w3c/optional/test459.scxml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test460.scxml b/tests/scxml/w3c/optional/test460.scxml new file mode 100644 index 00000000..ad1aed1d --- /dev/null +++ b/tests/scxml/w3c/optional/test460.scxml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test509.scxml b/tests/scxml/w3c/optional/test509.scxml new file mode 100644 index 00000000..e898b41c --- /dev/null +++ b/tests/scxml/w3c/optional/test509.scxml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test510.scxml b/tests/scxml/w3c/optional/test510.scxml new file mode 100644 index 00000000..ed8421e3 --- /dev/null +++ b/tests/scxml/w3c/optional/test510.scxml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test518.scxml b/tests/scxml/w3c/optional/test518.scxml new file mode 100644 index 00000000..c09c975b --- /dev/null +++ b/tests/scxml/w3c/optional/test518.scxml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test519.scxml b/tests/scxml/w3c/optional/test519.scxml new file mode 100644 index 00000000..f6d8e819 --- /dev/null +++ b/tests/scxml/w3c/optional/test519.scxml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test520.scxml b/tests/scxml/w3c/optional/test520.scxml new file mode 100644 index 00000000..0f23a7b2 --- /dev/null +++ b/tests/scxml/w3c/optional/test520.scxml @@ -0,0 +1,31 @@ + + + + + + + + this is some content + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test522.scxml b/tests/scxml/w3c/optional/test522.scxml new file mode 100644 index 00000000..74aa3941 --- /dev/null +++ b/tests/scxml/w3c/optional/test522.scxml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test531.scxml b/tests/scxml/w3c/optional/test531.scxml new file mode 100644 index 00000000..38d30dd7 --- /dev/null +++ b/tests/scxml/w3c/optional/test531.scxml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test532.scxml b/tests/scxml/w3c/optional/test532.scxml new file mode 100644 index 00000000..20872c36 --- /dev/null +++ b/tests/scxml/w3c/optional/test532.scxml @@ -0,0 +1,28 @@ + + + + + + + + + some content + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test534.scxml b/tests/scxml/w3c/optional/test534.scxml new file mode 100644 index 00000000..4adc62e2 --- /dev/null +++ b/tests/scxml/w3c/optional/test534.scxml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test557.scxml b/tests/scxml/w3c/optional/test557.scxml new file mode 100644 index 00000000..379113a2 --- /dev/null +++ b/tests/scxml/w3c/optional/test557.scxml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test557.txt b/tests/scxml/w3c/optional/test557.txt new file mode 100644 index 00000000..1344d3aa --- /dev/null +++ b/tests/scxml/w3c/optional/test557.txt @@ -0,0 +1,4 @@ + + + + diff --git a/tests/scxml/w3c/optional/test558.scxml b/tests/scxml/w3c/optional/test558.scxml new file mode 100644 index 00000000..231d345a --- /dev/null +++ b/tests/scxml/w3c/optional/test558.scxml @@ -0,0 +1,33 @@ + + + + + this is + a string + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test558.txt b/tests/scxml/w3c/optional/test558.txt new file mode 100644 index 00000000..fcbd22ab --- /dev/null +++ b/tests/scxml/w3c/optional/test558.txt @@ -0,0 +1,3 @@ + +this is +a string diff --git a/tests/scxml/w3c/optional/test560.scxml b/tests/scxml/w3c/optional/test560.scxml new file mode 100644 index 00000000..fa9b3075 --- /dev/null +++ b/tests/scxml/w3c/optional/test560.scxml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test561.scxml b/tests/scxml/w3c/optional/test561.scxml new file mode 100644 index 00000000..050919fd --- /dev/null +++ b/tests/scxml/w3c/optional/test561.scxml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test562.scxml b/tests/scxml/w3c/optional/test562.scxml new file mode 100644 index 00000000..58cf99dd --- /dev/null +++ b/tests/scxml/w3c/optional/test562.scxml @@ -0,0 +1,28 @@ + + + + + + + this is a + string + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test567.scxml b/tests/scxml/w3c/optional/test567.scxml new file mode 100644 index 00000000..d25c0b2d --- /dev/null +++ b/tests/scxml/w3c/optional/test567.scxml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test569.scxml b/tests/scxml/w3c/optional/test569.scxml new file mode 100644 index 00000000..9291e845 --- /dev/null +++ b/tests/scxml/w3c/optional/test569.scxml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test577.scxml b/tests/scxml/w3c/optional/test577.scxml new file mode 100644 index 00000000..678b5f07 --- /dev/null +++ b/tests/scxml/w3c/optional/test577.scxml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/scxml/w3c/optional/test578.scxml b/tests/scxml/w3c/optional/test578.scxml new file mode 100644 index 00000000..e6302f07 --- /dev/null +++ b/tests/scxml/w3c/optional/test578.scxml @@ -0,0 +1,25 @@ + + + + + + { "productName" : "bar", "size" : 27 } + + + + + + + + + + + + + + + + diff --git a/tests/test_async.py b/tests/test_async.py index 7a995e88..03a717e5 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -1,15 +1,18 @@ import re import pytest +from statemachine.exceptions import InvalidDefinition from statemachine.exceptions import InvalidStateValue from statemachine import State -from statemachine import StateMachine +from statemachine import StateChart @pytest.fixture() def async_order_control_machine(): # noqa: C901 - class OrderControl(StateMachine): + class OrderControl(StateChart): + allow_event_without_transition = False + waiting_for_payment = State(initial=True) processing = State() shipping = State() @@ -96,9 +99,11 @@ def test_async_state_from_sync_context(async_order_control_machine): assert sm.completed.is_active -class AsyncConditionExpressionMachine(StateMachine): +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") @@ -188,22 +193,273 @@ async def test_async_state_should_be_initialized(async_order_control_machine): """ sm = async_order_control_machine() - 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()`)" - ), - ): - assert sm.current_state == sm.waiting_for_payment + 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.current_state == sm.waiting_for_payment + assert sm.waiting_for_payment.is_active + + +@pytest.mark.timeout(5) +async def test_async_error_on_execution_in_condition(): + """Async engine catches errors in conditions with error_on_execution.""" + + 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_error_on_execution_in_transition(): + """Async engine catches errors in transition callbacks with error_on_execution.""" + + 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_error_on_execution_in_after(): + """Async engine catches errors in after callbacks with error_on_execution.""" + + 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_error_on_execution_in_before(): + """Async engine catches errors in before callbacks with error_on_execution.""" + + 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_error_on_execution(): + """RuntimeError in async after callback without error_on_execution propagates.""" + + class SM(StateChart): + error_on_execution = 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 error_on_execution 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_error_on_execution_propagates(): + """AsyncEngine: RuntimeError in async after callback without error_on_execution raises.""" + + class SM(StateChart): + error_on_execution = 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(StateMachine): + class MyMachine(StateChart): s0 = State(initial=True) s1 = State(final=True) @@ -217,7 +473,7 @@ async def is_ready(self): assert [e.id for e in await sm.enabled_events()] == ["go"] async def test_failing_async_condition(self): - class MyMachine(StateMachine): + class MyMachine(StateChart): s0 = State(initial=True) s1 = State(final=True) @@ -231,7 +487,7 @@ async def is_ready(self): assert await sm.enabled_events() == [] async def test_kwargs_forwarded_to_async_conditions(self): - class MyMachine(StateMachine): + class MyMachine(StateChart): s0 = State(initial=True) s1 = State(final=True) @@ -246,7 +502,7 @@ async def check_value(self, value=0): 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(StateMachine): + class MyMachine(StateChart): s0 = State(initial=True) s1 = State(final=True) @@ -259,10 +515,32 @@ async def bad_cond(self): 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(StateMachine): + class MyMachine(StateChart): s0 = State(initial=True) - s1 = State() + s1 = State(final=True) s2 = State(final=True) go = s0.to(s1, cond="cond_true") diff --git a/tests/test_async_futures.py b/tests/test_async_futures.py new file mode 100644 index 00000000..1a56f4ae --- /dev/null +++ b/tests/test_async_futures.py @@ -0,0 +1,341 @@ +"""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 error_on_execution=False (not default for StateChart), the + exception propagates to the caller of that event.""" + + class FailingSC(StateChart): + error_on_execution = 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_error_on_execution_off(self): + """When error_on_execution=False and one event raises, the exception + is routed to that caller's future; the other caller is unaffected. + + With error_on_execution=False, the exception propagates and the + processing loop clears the external queue, so the second event is + never processed. + """ + + class ConcurrentFailMachine(StateChart): + error_on_execution = 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): + error_on_execution = 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() diff --git a/tests/test_callbacks.py b/tests/test_callbacks.py index 6f967a34..c00bb4bc 100644 --- a/tests/test_callbacks.py +++ b/tests/test_callbacks.py @@ -2,6 +2,7 @@ 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 @@ -9,7 +10,7 @@ from statemachine.exceptions import InvalidDefinition from statemachine import State -from statemachine import StateMachine +from statemachine import StateChart @pytest.fixture() @@ -166,7 +167,7 @@ def race_uppercase(race): assert race_uppercase("Hobbit") == "HOBBIT" def test_decorate_unbounded_machine_methods(self): - class MiniHeroJourneyMachine(StateMachine, strict_states=False): + class MiniHeroJourneyMachine(StateChart): ordinary_world = State(initial=True) call_to_adventure = State(final=True) refusal_of_call = State(final=True) @@ -222,7 +223,7 @@ class TestIssue406: def test_issue_406(self, mocker): mock = mocker.Mock() - class ExampleStateMachine(StateMachine, strict_states=False): + class ExampleStateMachine(StateChart): created = State(initial=True) inited = State(final=True) @@ -277,7 +278,10 @@ def can_be_started_as_property_str_on_model(self) -> bool: @pytest.fixture() def sm_class(self, model_class, mock_calls): - class ExampleStateMachine(StateMachine): + class ExampleStateMachine(StateChart): + allow_event_without_transition = False + error_on_execution = False + created = State(initial=True) started = State(final=True) @@ -336,7 +340,9 @@ class StrangeObject: def this_cannot_resolve(self) -> bool: return True - class ExampleStateMachine(StateMachine): + class ExampleStateMachine(StateChart): + error_on_execution = False + created = State(initial=True) started = State(final=True) start = created.to(started, cond=[StrangeObject.this_cannot_resolve]) @@ -346,3 +352,35 @@ class ExampleStateMachine(StateMachine): 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 == [] diff --git a/tests/test_callbacks_isolation.py b/tests/test_callbacks_isolation.py index 15d1f08e..765153fd 100644 --- a/tests/test_callbacks_isolation.py +++ b/tests/test_callbacks_isolation.py @@ -1,12 +1,14 @@ import pytest from statemachine import State -from statemachine import StateMachine +from statemachine import StateChart @pytest.fixture() def simple_sm_cls(): - class TestStateMachine(StateMachine): + class TestStateMachine(StateChart): + allow_event_without_transition = True + # States initial = State(initial=True) final = State(final=True, enter="do_enter_final") @@ -17,7 +19,7 @@ def __init__(self, name): self.name = name self.can_finish = False self.finalized = False - super().__init__(allow_event_without_transition=True) + super().__init__() def do_finish(self): return self.name, self.can_finish diff --git a/tests/test_class_listeners.py b/tests/test_class_listeners.py new file mode 100644 index 00000000..fc1aad08 --- /dev/null +++ b/tests/test_class_listeners.py @@ -0,0 +1,477 @@ +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 == [] diff --git a/tests/test_conditions_algebra.py b/tests/test_conditions_algebra.py index 5c081b05..47094cfd 100644 --- a/tests/test_conditions_algebra.py +++ b/tests/test_conditions_algebra.py @@ -2,10 +2,13 @@ from statemachine.exceptions import InvalidDefinition from statemachine import State -from statemachine import StateMachine +from statemachine import StateChart -class AnyConditionSM(StateMachine): +class AnyConditionSM(StateChart): + allow_event_without_transition = False + error_on_execution = False + start = State(initial=True) end = State(final=True) @@ -20,25 +23,25 @@ def test_conditions_algebra_any_false(): with pytest.raises(sm.TransitionNotAllowed): sm.submit() - assert sm.current_state == sm.start + assert sm.start.is_active def test_conditions_algebra_any_left_true(): sm = AnyConditionSM() sm.used_money = True sm.submit() - assert sm.current_state == sm.end + assert sm.end.is_active def test_conditions_algebra_any_right_true(): sm = AnyConditionSM() sm.used_credit = True sm.submit() - assert sm.current_state == sm.end + assert sm.end.is_active def test_should_raise_invalid_definition_if_cond_is_not_valid_sintax(): - class AnyConditionSM(StateMachine): + class AnyConditionSM(StateChart): start = State(initial=True) end = State(final=True) @@ -52,7 +55,7 @@ class AnyConditionSM(StateMachine): def test_should_raise_invalid_definition_if_cond_is_not_found(): - class AnyConditionSM(StateMachine): + class AnyConditionSM(StateChart): start = State(initial=True) end = State(final=True) diff --git a/tests/test_contrib_diagram.py b/tests/test_contrib_diagram.py index 099d55e1..54c94cbd 100644 --- a/tests/test_contrib_diagram.py +++ b/tests/test_contrib_diagram.py @@ -5,6 +5,11 @@ from statemachine.contrib.diagram import DotGraphMachine from statemachine.contrib.diagram import main from statemachine.contrib.diagram import quickchart_write_svg +from statemachine.transition import Transition + +from statemachine import HistoryState +from statemachine import State +from statemachine import StateChart pytestmark = pytest.mark.usefixtures("requires_dot_installed") @@ -48,7 +53,7 @@ def test_machine_dot(OrderControl): dot = graph() dot_str = dot.to_string() # or dot.to_string() - assert dot_str.startswith("digraph list {") + assert dot_str.startswith("digraph OrderControl {") class TestDiagramCmdLine: @@ -61,6 +66,16 @@ def test_generate_image(self, tmp_path): '\n\n child1" in dot + + +def test_history_state_shallow_diagram(): + """DOT output contains an 'H' circle node for shallow history state.""" + h = HistoryState(name="H") + h._set_id("h_shallow") + + graph_maker = DotGraphMachine.__new__(DotGraphMachine) + graph_maker.font_name = "Arial" + node = graph_maker._history_node(h) + 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.""" + h = HistoryState(name="H*", type="deep") + h._set_id("h_deep") + + graph_maker = DotGraphMachine.__new__(DotGraphMachine) + graph_maker.font_name = "Arial" + node = graph_maker._history_node(h) + # Verify the node renders correctly in DOT output + 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.""" + child1 = State("child1", initial=True) + child1._set_id("child1") + child2 = State("child2") + child2._set_id("child2") + + h = HistoryState(name="H") + h._set_id("hist") + # Add a default transition from history to child1 + t = Transition(source=h, target=child1, initial=True) + h.transitions.add_transitions(t) + + parent = State("parent", states=[child1, child2], history=[h]) + parent._set_id("parent") + + graph_maker = DotGraphMachine.__new__(DotGraphMachine) + graph_maker.font_name = "Arial" + graph_maker.transition_font_size = "9pt" + + edges = graph_maker._transition_as_edges(t) + 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.""" + source = State("source", initial=True) + source._set_id("source") + target1 = State("target1") + target1._set_id("target1") + target2 = State("target2") + target2._set_id("target2") + + t = Transition(source=source, target=[target1, target2]) + t._events.add("go") + + graph_maker = DotGraphMachine.__new__(DotGraphMachine) + graph_maker.font_name = "Arial" + graph_maker.transition_font_size = "9pt" + + edges = graph_maker._transition_as_edges(t) + 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 edges[0].obj_dict["attributes"]["label"] == "go" + 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 + # Verify initial edges exist for compound states (top and regions) + assert "top_anchor -> entry" in dot diff --git a/tests/test_copy.py b/tests/test_copy.py index b2af2819..59b6ca8b 100644 --- a/tests/test_copy.py +++ b/tests/test_copy.py @@ -6,14 +6,12 @@ from enum import auto import pytest -from statemachine.exceptions import TransitionNotAllowed from statemachine.states import States from statemachine import State -from statemachine import StateMachine +from statemachine import StateChart logger = logging.getLogger(__name__) -DEBUG = logging.DEBUG def copy_pickle(obj): @@ -32,8 +30,8 @@ class GameStates(str, Enum): GAME_END = auto() -class GameStateMachine(StateMachine): - s = States.from_enum(GameStates, initial=GameStates.GAME_START) +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) @@ -46,9 +44,9 @@ def game_is_over(self) -> bool: advance_round = end_game | s.TURN_END.to(s.GAME_END) -class MyStateMachine(StateMachine): +class MyStateMachine(StateChart): created = State(initial=True) - started = State() + started = State(final=True) start = created.to(started) @@ -58,36 +56,26 @@ def __init__(self): self.value = [1, 2, 3] -class MySM(StateMachine): +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 on_transition(self, event: str): - logger.debug(f"{self.__class__.__name__} recorded {event} transition") - def let_me_be_visible(self): - logger.debug(f"{type(self).__name__} let_me_be_visible: True") return True class MyModel: def __init__(self, name: str) -> None: self.name = name - self.let_me_be_visible = False + self._let_me_be_visible = False def __repr__(self) -> str: return f"{type(self).__name__}@{id(self)}({self.name!r})" - def on_transition(self, event: str): - logger.debug(f"{type(self).__name__}({self.name!r}) recorded {event} transition") - @property def let_me_be_visible(self): - logger.debug( - f"{type(self).__name__}({self.name!r}) let_me_be_visible: {self._let_me_be_visible}" - ) return self._let_me_be_visible @let_me_be_visible.setter @@ -97,16 +85,19 @@ def let_me_be_visible(self, value): def test_copy(copy_method): sm = MySM(MyModel("main_model")) - sm2 = copy_method(sm) - with pytest.raises(TransitionNotAllowed): - sm2.send("publish") + 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(caplog, copy_method): - model1 = MyModel("main_model") +def test_copy_with_listeners(copy_method): + model1 = MyModel("main_model") sm1 = MySM(model1) listener_1 = MyModel("observer_1") @@ -117,61 +108,28 @@ def test_copy_with_listeners(caplog, copy_method): 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 + ) - caplog.set_level(logging.DEBUG, logger="tests") - - def assertions(sm, _reference): - caplog.clear() - if not sm._listeners: - pytest.fail("did not found any observer") - - for listener in sm._listeners: - listener.let_me_be_visible = False - - with pytest.raises(TransitionNotAllowed): - sm.send("publish") - - sm.model.let_me_be_visible = True - - for listener in sm._listeners: - with pytest.raises(TransitionNotAllowed): - sm.send("publish") - - listener.let_me_be_visible = True - - sm.send("publish") - - assert caplog.record_tuples == [ - ("tests.test_copy", DEBUG, "MySM let_me_be_visible: True"), - ("tests.test_copy", DEBUG, "MyModel('main_model') let_me_be_visible: False"), - ("tests.test_copy", DEBUG, "MySM let_me_be_visible: True"), - ("tests.test_copy", DEBUG, "MyModel('main_model') let_me_be_visible: True"), - ("tests.test_copy", DEBUG, "MyModel('observer_1') let_me_be_visible: False"), - ("tests.test_copy", DEBUG, "MySM let_me_be_visible: True"), - ("tests.test_copy", DEBUG, "MyModel('main_model') let_me_be_visible: True"), - ("tests.test_copy", DEBUG, "MyModel('observer_1') let_me_be_visible: True"), - ("tests.test_copy", DEBUG, "MyModel('observer_2') let_me_be_visible: False"), - ("tests.test_copy", DEBUG, "MySM let_me_be_visible: True"), - ("tests.test_copy", DEBUG, "MyModel('main_model') let_me_be_visible: True"), - ("tests.test_copy", DEBUG, "MyModel('observer_1') let_me_be_visible: True"), - ("tests.test_copy", DEBUG, "MyModel('observer_2') let_me_be_visible: True"), - ("tests.test_copy", DEBUG, "MySM recorded publish transition"), - ("tests.test_copy", DEBUG, "MyModel('main_model') recorded publish transition"), - ("tests.test_copy", DEBUG, "MyModel('observer_1') recorded publish transition"), - ("tests.test_copy", DEBUG, "MyModel('observer_2') recorded publish transition"), - ] + sm2.model.let_me_be_visible = True + for listener in sm2._listeners.values(): + listener.let_me_be_visible = True - assertions(sm1, "original") - assertions(sm2, "copy") + sm2.send("publish") + assert sm2.published.is_active def test_copy_with_enum(copy_method): sm = GameStateMachine() sm.play() - assert sm.current_state == GameStateMachine.GAME_PLAYING + assert GameStates.GAME_PLAYING in sm.configuration_values sm2 = copy_method(sm) - assert sm2.current_state == GameStateMachine.GAME_PLAYING + assert GameStates.GAME_PLAYING in sm2.configuration_values def test_copy_with_custom_init_and_vars(copy_method): @@ -181,10 +139,10 @@ def test_copy_with_custom_init_and_vars(copy_method): sm2 = copy_method(sm) assert sm2.custom == 1 assert sm2.value == [1, 2, 3] - assert sm2.current_state == MyStateMachine.started + assert sm2.started.is_active -class AsyncTrafficLightMachine(StateMachine): +class AsyncTrafficLightMachine(StateChart): green = State(initial=True) yellow = State() red = State() @@ -206,9 +164,9 @@ def test_copy_async_statemachine_before_activation(copy_method): async def verify(): await sm_copy.activate_initial_state() - assert sm_copy.current_state == AsyncTrafficLightMachine.green + assert sm_copy.green.is_active await sm_copy.cycle() - assert sm_copy.current_state == AsyncTrafficLightMachine.yellow + assert sm_copy.yellow.is_active asyncio.run(verify()) @@ -220,13 +178,13 @@ async def setup_and_verify(): sm = AsyncTrafficLightMachine() await sm.activate_initial_state() await sm.cycle() - assert sm.current_state == AsyncTrafficLightMachine.yellow + assert sm.yellow.is_active sm_copy = copy_method(sm) await sm_copy.activate_initial_state() - assert sm_copy.current_state == AsyncTrafficLightMachine.yellow + assert sm_copy.yellow.is_active await sm_copy.cycle() - assert sm_copy.current_state == AsyncTrafficLightMachine.red + assert sm_copy.red.is_active asyncio.run(setup_and_verify()) diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py index d2b17064..06874ff8 100644 --- a/tests/test_dispatcher.py +++ b/tests/test_dispatcher.py @@ -6,7 +6,7 @@ from statemachine.dispatcher import resolver_factory_from_objects from statemachine.exceptions import InvalidDefinition from statemachine.state import State -from statemachine.statemachine import StateMachine +from statemachine.statemachine import StateChart def _take_first_callable(iterable): @@ -150,7 +150,9 @@ class StrangeObject: def can_change_to_start(self): return False - class StartMachine(StateMachine): + class StartMachine(StateChart): + error_on_execution = False + created = State(initial=True) started = State(final=True) diff --git a/tests/test_error_execution.py b/tests/test_error_execution.py new file mode 100644 index 00000000..e99f2eec --- /dev/null +++ b/tests/test_error_execution.py @@ -0,0 +1,1132 @@ +import pytest +from statemachine.exceptions import InvalidDefinition + +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") + + +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") + + +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") + + +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") + + +class ErrorInGuardSM(StateChart): + """StateChart subclass with error_on_execution=False: exceptions should propagate.""" + + error_on_execution = False + + initial = State("initial", initial=True) + + go = initial.to(initial, cond="bad_guard") | initial.to(initial) + + def bad_guard(self): + raise RuntimeError("guard failed") + + +class ErrorInActionSMWithFlag(StateChart): + """StateChart subclass (error_on_execution = 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") + + +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") + + +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 error_on_execution=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 error_on_execution.""" + + 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_error_on_execution_true(): + """StateChart (error_on_execution=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 --- + + +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") + + +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") + + +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 (error_on_execution=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 error_on_execution=False propagates errors even with convention.""" + + class AragornSword(StateChart): + error_on_execution = 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_error_on_execution_propagates(self): + """RuntimeError in after callback without error_on_execution raises.""" + + class SM(StateChart): + error_on_execution = 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_error_on_execution_handled(self): + """RuntimeError in after callback with error_on_execution 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_error_on_execution(self): + """RuntimeError in microstep without error_on_execution raises.""" + + class SM(StateChart): + error_on_execution = 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): + error_on_execution = 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): + error_on_execution = 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_error_on_execution(): + """RuntimeError in internal event propagates when error_on_execution is False.""" + + class SM(StateChart): + error_on_execution = 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") diff --git a/tests/test_events.py b/tests/test_events.py index 04ff50df..cf7c5b2d 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -3,11 +3,11 @@ from statemachine.exceptions import InvalidDefinition from statemachine import State -from statemachine import StateMachine +from statemachine import StateChart def test_assign_events_on_transitions(): - class TrafficLightMachine(StateMachine): + class TrafficLightMachine(StateChart): "A traffic light machine" green = State(initial=True) @@ -34,7 +34,7 @@ def on_cycle(self, event_data, event: str): class TestExplicitEvent: def test_accept_event_instance(self): - class StartMachine(StateMachine): + class StartMachine(StateChart): created = State(initial=True) started = State(final=True) @@ -46,10 +46,10 @@ class StartMachine(StateMachine): sm = StartMachine() sm.send("start") - assert sm.current_state == sm.started + assert sm.started.is_active def test_accept_event_name(self): - class StartMachine(StateMachine): + class StartMachine(StateChart): created = State(initial=True) started = State(final=True) @@ -60,7 +60,7 @@ class StartMachine(StateMachine): assert StartMachine.start.name == "Start the machine" def test_derive_name_from_id(self): - class StartMachine(StateMachine): + class StartMachine(StateChart): created = State(initial=True) started = State(final=True) @@ -74,7 +74,7 @@ class StartMachine(StateMachine): assert StartMachine.launch_the_machine == StartMachine.launch_the_machine.id def test_not_derive_name_from_id_if_not_event_class(self): - class StartMachine(StateMachine): + class StartMachine(StateChart): created = State(initial=True) started = State(final=True) @@ -90,7 +90,7 @@ class StartMachine(StateMachine): def test_raise_invalid_definition_if_event_name_cannot_be_derived(self): with pytest.raises(InvalidDefinition, match="has no id"): - class StartMachine(StateMachine): + class StartMachine(StateChart): created = State(initial=True) started = State() @@ -99,16 +99,16 @@ class StartMachine(StateMachine): started.to.itself(event=Event()) # event id not defined def test_derive_from_id(self): - class StartMachine(StateMachine): + class StartMachine(StateChart): created = State(initial=True) - started = State() + 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(StateMachine): + class TrafficLightMachine(StateChart): "A traffic light machine" green = State(initial=True) @@ -142,7 +142,7 @@ def on_cycle(self, event_data, event: str): assert sm.go.name == "Go! Go! Go!" def test_mixing_event_and_parameters(self): - class TrafficLightMachine(StateMachine): + class TrafficLightMachine(StateChart): "A traffic light machine" green = State(initial=True) @@ -174,7 +174,7 @@ def on_cycle(self, event_data, event: str): assert sm.go.name == "Go! Go! Go!" def test_name_derived_from_identifier(self): - class TrafficLightMachine(StateMachine): + class TrafficLightMachine(StateChart): "A traffic light machine" green = State(initial=True) @@ -205,7 +205,7 @@ def on_cycle(self, event_data, event: str): assert sm.go.name == "go" def test_multiple_ids_from_the_same_event_will_be_converted_to_multiple_events(self): - class TrafficLightMachine(StateMachine): + class TrafficLightMachine(StateChart): "A traffic light machine" green = State(initial=True) @@ -234,7 +234,7 @@ def on_cycle(self, event_data, event: str): assert sm.send("cycle") == "Running cycle from red to green" def test_allow_registering_callbacks_using_decorator(self): - class TrafficLightMachine(StateMachine): + class TrafficLightMachine(StateChart): "A traffic light machine" green = State(initial=True) @@ -263,7 +263,7 @@ def do_cycle(self, event_data, event: str): def test_raise_registering_callbacks_using_decorator_if_no_transitions(self): with pytest.raises(InvalidDefinition, match="event with no transitions"): - class TrafficLightMachine(StateMachine): + class TrafficLightMachine(StateChart): "A traffic light machine" green = State(initial=True) @@ -285,9 +285,9 @@ def do_cycle(self, event_data, event: str): ) def test_allow_using_events_as_commands(self): - class StartMachine(StateMachine): + class StartMachine(StateChart): created = State(initial=True) - started = State() + started = State(final=True) created.to(started, event=Event("launch_rocket")) @@ -299,12 +299,34 @@ class StartMachine(StateMachine): assert sm.started.is_active def test_event_commands_fail_when_unbound_to_instance(self): - class StartMachine(StateMachine): + class StartMachine(StateChart): created = State(initial=True) - started = State() + started = State(final=True) created.to(started, event=Event("launch_rocket")) event = next(iter(StartMachine.events)) - with pytest.raises(RuntimeError): + 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 diff --git a/tests/test_fellowship_quest.py b/tests/test_fellowship_quest.py new file mode 100644 index 00000000..2e8964f5 --- /dev/null +++ b/tests/test_fellowship_quest.py @@ -0,0 +1,452 @@ +"""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} diff --git a/tests/test_invoke.py b/tests/test_invoke.py new file mode 100644 index 00000000..54d35249 --- /dev/null +++ b/tests/test_invoke.py @@ -0,0 +1,1072 @@ +"""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__() diff --git a/tests/test_io.py b/tests/test_io.py new file mode 100644 index 00000000..8171ac39 --- /dev/null +++ b/tests/test_io.py @@ -0,0 +1,46 @@ +"""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 diff --git a/tests/test_listener.py b/tests/test_listener.py index d1ca3f2c..8a5af2fa 100644 --- a/tests/test_listener.py +++ b/tests/test_listener.py @@ -1,6 +1,5 @@ -import pytest from statemachine.state import State -from statemachine.statemachine import StateMachine +from statemachine.statemachine import StateChart EXPECTED_LOG_ADD = """Frodo on: draft--(add_job)-->draft Frodo enter: draft from add_job @@ -57,37 +56,13 @@ def on_enter_state(self, target, event): captured = capsys.readouterr() assert captured.out == EXPECTED_LOG_CREATION - def test_deprecated_api(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() - - with pytest.warns( - DeprecationWarning, match="Method `add_observer` has been renamed to `add_listener`." - ): - sm.add_observer(LogObserver("Frodo")) - - sm.add_job() - sm.produce() - - captured = capsys.readouterr() - assert captured.out == EXPECTED_LOG_ADD - def test_regression_456(): class TestListener: def __init__(self): pass - class MyMachine(StateMachine): + class MyMachine(StateChart): first = State("FIRST", initial=True) second = State("SECOND") diff --git a/tests/test_mixins.py b/tests/test_mixins.py index 85f25751..938c710e 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -12,7 +12,7 @@ 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.current_state == model.statemachine.draft + assert model.statemachine.draft.is_active def test_mixin_should_raise_exception_if_machine_class_does_not_exist(): diff --git a/tests/test_mock_compatibility.py b/tests/test_mock_compatibility.py index 03ae417d..874b9eda 100644 --- a/tests/test_mock_compatibility.py +++ b/tests/test_mock_compatibility.py @@ -1,5 +1,5 @@ from statemachine import State -from statemachine import StateMachine +from statemachine import StateChart def test_minimal(mocker): @@ -9,7 +9,7 @@ def on_enter_state(self, event, model, source, target, state): ... obs = Observer() on_enter_state = mocker.spy(obs, "on_enter_state") - class Machine(StateMachine): + class Machine(StateChart): a = State("Init", initial=True) b = State("Fin") diff --git a/tests/test_multiple_destinations.py b/tests/test_multiple_destinations.py index 0ae60f64..fc2515bd 100644 --- a/tests/test_multiple_destinations.py +++ b/tests/test_multiple_destinations.py @@ -1,7 +1,7 @@ import pytest from statemachine import State -from statemachine import StateMachine +from statemachine import StateChart from statemachine import exceptions @@ -49,7 +49,7 @@ def test_transition_should_choose_final_state_on_multiple_possibilities( def test_transition_to_first_that_executes_if_multiple_targets(): - class ApprovalMachine(StateMachine): + class ApprovalMachine(StateChart): "A workflow" requested = State(initial=True) @@ -68,9 +68,12 @@ def test_do_not_transition_if_multiple_targets_with_guard(): def never_will_pass(event_data): return False - class ApprovalMachine(StateMachine): + class ApprovalMachine(StateChart): "A workflow" + allow_event_without_transition = False + error_on_execution = False + requested = State(initial=True) accepted = State(final=True) rejected = State(final=True) @@ -96,9 +99,11 @@ def this_also_never_will_pass(self, event_data): def test_check_invalid_reference_to_conditions(): - class ApprovalMachine(StateMachine): + class ApprovalMachine(StateChart): "A workflow" + error_on_execution = False + requested = State(initial=True) accepted = State(final=True) rejected = State(final=True) @@ -110,9 +115,12 @@ class ApprovalMachine(StateMachine): def test_should_change_to_returned_state_on_multiple_target_with_combined_transitions(): - class ApprovalMachine(StateMachine): + class ApprovalMachine(StateChart): "A workflow" + allow_event_without_transition = False + error_on_execution = False + requested = State(initial=True) accepted = State() rejected = State() @@ -123,8 +131,8 @@ class ApprovalMachine(StateMachine): ) retry = rejected.to(requested) - def on_validate(self): - if self.accepted.is_active and self.model.is_ok(): + def on_validate(self, previous_configuration): + if self.accepted in previous_configuration and self.model.is_ok(): return "congrats!" # given @@ -153,6 +161,8 @@ def on_validate(self): # 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() @@ -172,7 +182,7 @@ def test_transition_on_execute_should_be_called_with_run_syntax(approval_machine def test_multiple_values_returned_with_multiple_targets(): - class ApprovalMachine(StateMachine): + class ApprovalMachine(StateChart): "A workflow" requested = State(initial=True) @@ -199,7 +209,9 @@ def validate(self): ], ) def test_multiple_targets_using_or_starting_from_same_origin(payment_failed, expected_state): - class InvoiceStateMachine(StateMachine): + class InvoiceStateMachine(StateChart): + error_on_execution = False + unpaid = State(initial=True) paid = State(final=True) failed = State() @@ -211,7 +223,7 @@ def payment_success(self, event_data): invoice_fsm = InvoiceStateMachine() invoice_fsm.pay() - assert invoice_fsm.current_state.id == expected_state + assert invoice_fsm.current_state_value == expected_state def test_order_control(OrderControl): diff --git a/tests/test_profiling.py b/tests/test_profiling.py index 1fa31b37..e6f748d7 100644 --- a/tests/test_profiling.py +++ b/tests/test_profiling.py @@ -3,10 +3,13 @@ import pytest from statemachine import State -from statemachine import StateMachine +from statemachine import StateChart -class OrderControl(StateMachine): +class OrderControl(StateChart): + allow_event_without_transition = False + error_on_execution = False + waiting_for_payment = State(initial=True) processing = State() shipping = State() diff --git a/tests/test_registry.py b/tests/test_registry.py index 01b4a571..7b69a849 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -25,11 +25,8 @@ class CampaignMachine(StateMachine): add_job = draft.to(draft) | producing.to(producing) produce = draft.to(producing) - assert "CampaignMachine" in registry._REGISTRY assert registry.get_machine_cls("tests.test_registry.CampaignMachine") == CampaignMachine - - with pytest.warns(DeprecationWarning, match="fully qualified names"): - assert registry.get_machine_cls("CampaignMachine") == CampaignMachine + assert "CampaignMachine" not in registry._REGISTRY def test_load_modules_should_call_autodiscover_modules(django_autodiscover_modules): diff --git a/tests/test_rtc.py b/tests/test_rtc.py index b05a45b0..b5125820 100644 --- a/tests/test_rtc.py +++ b/tests/test_rtc.py @@ -2,16 +2,14 @@ from unittest import mock import pytest -from statemachine.exceptions import InvalidDefinition -from statemachine.exceptions import TransitionNotAllowed from statemachine import State -from statemachine import StateMachine +from statemachine import StateChart @pytest.fixture() def chained_after_sm_class(): # noqa: C901 - class ChainedSM(StateMachine): + class ChainedSM(StateChart): a = State(initial=True) b = State() c = State(final=True) @@ -47,7 +45,7 @@ def on_exit_state(self, state: State, source: State, value: int = 0): @pytest.fixture() def chained_on_sm_class(): # noqa: C901 - class ChainedSM(StateMachine): + class ChainedSM(StateChart): s1 = State(initial=True) s2 = State() s3 = State() @@ -58,9 +56,9 @@ class ChainedSM(StateMachine): t2b = s2.to(s3) t3 = s3.to(s4) - def __init__(self, rtc=True): + def __init__(self): self.spy = mock.Mock() - super().__init__(rtc=rtc) + super().__init__() def on_t1(self): return [self.t2a(), self.t2b(), self.send("t3")] @@ -88,46 +86,27 @@ def after_transition(self, event: str, source: State, target: State): class TestChainedTransition: @pytest.mark.parametrize( - ("rtc", "expected_calls"), + ("expected_calls"), [ - ( - False, - [ - 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("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), - mock.call("after_t1", source="a", value=42), - ], - ), - ( - True, - [ - 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), - ], - ), + [ + 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, rtc, expected_calls + self, chained_after_sm_class, expected_calls ): - sm = chained_after_sm_class(rtc=rtc) + sm = chained_after_sm_class() sm.t1(value=42) assert sm.c.is_active @@ -135,38 +114,31 @@ def test_should_allow_chaining_transitions_using_actions( assert sm.spy.call_args_list == expected_calls @pytest.mark.parametrize( - ("rtc", "expected"), + ("expected"), [ - ( - True, - [ - 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"), - ], - ), - ( - False, - TransitionNotAllowed, - ), + [ + 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, rtc, expected): - sm = chained_on_sm_class(rtc=rtc) + 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): @@ -177,24 +149,6 @@ def test_should_preserve_event_order(self, chained_on_sm_class, rtc, expected): class TestAsyncEngineRTC: - async def test_no_rtc_in_async_is_not_supported(self, chained_on_sm_class): - class AsyncStateMachine(StateMachine): - 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" - - with pytest.raises(InvalidDefinition, match="Only RTC is supported on async engine"): - AsyncStateMachine(rtc=False) - @pytest.mark.parametrize( ("expected"), [ @@ -220,7 +174,7 @@ async def on_finish(self): ], ) def test_should_preserve_event_order(self, expected): # noqa: C901 - class ChainedSM(StateMachine): + class ChainedSM(StateChart): s1 = State(initial=True) s2 = State() s3 = State() @@ -231,9 +185,9 @@ class ChainedSM(StateMachine): t2b = s2.to(s3) t3 = s3.to(s4) - def __init__(self, rtc=True): + def __init__(self): self.spy = mock.Mock() - super().__init__(rtc=rtc) + super().__init__() async def on_t1(self): return [await self.t2a(), await self.t2b(), await self.send("t3")] diff --git a/tests/test_scxml_units.py b/tests/test_scxml_units.py new file mode 100644 index 00000000..c3a467d1 --- /dev/null +++ b/tests/test_scxml_units.py @@ -0,0 +1,1040 @@ +"""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 error_on_execution + 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 error_on_execution is False.""" + + def test_if_condition_error_propagates_without_error_on_execution(self): + """ with failing condition raises when machine.error_on_execution 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.error_on_execution = 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}" diff --git a/tests/test_spec_parser.py b/tests/test_spec_parser.py index 569090d9..51115bd7 100644 --- a/tests/test_spec_parser.py +++ b/tests/test_spec_parser.py @@ -1,15 +1,19 @@ import asyncio -import logging +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 -logger = logging.getLogger(__name__) -DEBUG = logging.DEBUG +if TYPE_CHECKING: + from collections.abc import Callable -def variable_hook(var_name): +def variable_hook( + var_name: str, + spy: "Callable[[str], None] | None" = None, +) -> "Callable": values = { "frodo_has_ring": True, "sauron_alive": False, @@ -29,7 +33,8 @@ def variable_hook(var_name): } def decorated(*args, **kwargs): - logger.debug(f"variable_hook({var_name})") + if spy is not None: + spy(var_name) return values.get(var_name, False) decorated.__name__ = var_name @@ -41,7 +46,11 @@ def decorated(*args, **kwargs): [ ("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"]), + ( + "frodo_has_ring and gandalf_present", + True, + ["frodo_has_ring", "gandalf_present"], + ), ("sauron_alive", False, ["sauron_alive"]), ("not sauron_alive", True, ["sauron_alive"]), ( @@ -49,8 +58,16 @@ def decorated(*args, **kwargs): 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"]), + ( + "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, @@ -63,10 +80,26 @@ def decorated(*args, **kwargs): ), ("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 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, @@ -89,7 +122,11 @@ def decorated(*args, **kwargs): 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)", + False, + ["orc_army_ready"], + ), ( "!orc_army_ready and (frodo_has_ring and gandalf_present)", True, @@ -138,16 +175,15 @@ def decorated(*args, **kwargs): ("height > 1 and height < 2", True, ["height", "height"]), ], ) -def test_expressions(expression, expected, caplog, hooks_called): - caplog.set_level(logging.DEBUG, logger="tests") +def test_expressions(expression, expected, hooks_called): + calls: list[str] = [] - parsed_expr = parse_boolean_expr(expression, variable_hook, operator_mapping) - assert parsed_expr() is expected, expression + def hook(name): + return variable_hook(name, spy=calls.append) - if hooks_called: - assert caplog.record_tuples == [ - ("tests.test_spec_parser", DEBUG, f"variable_hook({hook})") for hook in hooks_called - ] + 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(): @@ -220,34 +256,6 @@ def variable_hook(var_name): assert parsed_expr is original_callback -@pytest.mark.parametrize( - ("expression", "expected", "hooks_called"), - [ - ("49 < frodo_age < 51", True, ["frodo_age"]), - ("49 < frodo_age > 50", False, ["frodo_age"]), - ( - "aragorn_age < legolas_age < gimli_age", - False, - ["aragorn_age", "legolas_age", "gimli_age"], - ), # 87 < 2931 and 2931 < 139 - ( - "gimli_age > aragorn_age < legolas_age", - True, - ["gimli_age", "aragorn_age", "legolas_age"], - ), # 139 > 87 and 87 < 2931 - ( - "sword_power < ring_power > bow_power", - True, - ["sword_power", "ring_power", "bow_power"], - ), # 80 < 100 and 100 > 75 - ( - "axe_power > sword_power == bow_power", - False, - ["axe_power", "sword_power", "bow_power"], - ), # 85 > 80 and 80 == 75 - ("height > 1 and height < 2", True, ["height"]), - ], -) def async_variable_hook(var_name): """Variable hook that returns async callables, for testing issue #535.""" values = { @@ -343,14 +351,7 @@ def test_mixed_sync_async_expressions(expression, expected): assert result is expected, expression -@pytest.mark.xfail(reason="TODO: Optimize so that expressios are evaluated only once") -def test_should_evaluate_values_only_once(expression, expected, caplog, hooks_called): - caplog.set_level(logging.DEBUG, logger="tests") - - parsed_expr = parse_boolean_expr(expression, variable_hook, operator_mapping) - assert parsed_expr() is expected, expression - - if hooks_called: - assert caplog.record_tuples == [ - ("tests.test_spec_parser", DEBUG, f"variable_hook({hook})") for hook in hooks_called - ] +def test_functions_get_unknown_raises(): + """Functions.get raises ValueError for unknown functions.""" + with pytest.raises(ValueError, match="Unsupported function"): + Functions.get("nonexistent_function") diff --git a/tests/test_state.py b/tests/test_state.py index ba6ff46a..c2f97c67 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -1,12 +1,13 @@ import pytest +from statemachine.orderedset import OrderedSet from statemachine import State -from statemachine import StateMachine +from statemachine import StateChart @pytest.fixture() def sm_class(): - class SM(StateMachine): + class SM(StateChart): pending = State(initial=True) waiting_approval = State() approved = State(final=True) @@ -39,3 +40,31 @@ def test_state_knows_if_its_final(self, 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] diff --git a/tests/test_state_callbacks.py b/tests/test_state_callbacks.py index cb6fb556..dfe196d7 100644 --- a/tests/test_state_callbacks.py +++ b/tests/test_state_callbacks.py @@ -11,9 +11,9 @@ def event_mock(): @pytest.fixture() def traffic_light_machine(event_mock): # noqa: C901 from statemachine import State - from statemachine import StateMachine + from statemachine import StateChart - class TrafficLightMachineStateEvents(StateMachine): + class TrafficLightMachineStateEvents(StateChart): "A traffic light machine" green = State(initial=True) diff --git a/tests/test_statechart_compound.py b/tests/test_statechart_compound.py new file mode 100644 index 00000000..49757ae9 --- /dev/null +++ b/tests/test_statechart_compound.py @@ -0,0 +1,341 @@ +"""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 + + +@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.""" + + 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) + + 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.""" + + 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) + + 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.""" + + 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) + + 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.""" + + 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) + + 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.""" + + class MoriaExpedition(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) + + sm = await sm_runner.start(MoriaExpedition) + 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.""" + + 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) + + 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.""" + + 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) + + march_to_moria = rivendell.to(moria) + + sm = await sm_runner.start(MiddleEarthJourney) + 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}.""" + + 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) + + 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.""" + + class MiddleEarthJourney(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) + + sm = await sm_runner.start(MiddleEarthJourney) + 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" diff --git a/tests/test_statechart_delayed.py b/tests/test_statechart_delayed.py new file mode 100644 index 00000000..5451895c --- /dev/null +++ b/tests/test_statechart_delayed.py @@ -0,0 +1,100 @@ +"""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 diff --git a/tests/test_statechart_donedata.py b/tests/test_statechart_donedata.py new file mode 100644 index 00000000..ca191361 --- /dev/null +++ b/tests/test_statechart_donedata.py @@ -0,0 +1,198 @@ +"""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 + + +@pytest.mark.timeout(5) +class TestDoneData: + async def test_donedata_callable_returns_dict(self, sm_runner): + """Handler receives donedata as kwargs.""" + received = {} + + 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")) + + def capture_result(self, ring_destroyed=None, hero=None, **kwargs): + received["ring_destroyed"] = ring_destroyed + received["hero"] = hero + + sm = await sm_runner.start(DestroyTheRing) + await sm_runner.send(sm, "finish") + assert received["ring_destroyed"] is True + assert received["hero"] == "frodo" + + async def test_donedata_fires_done_state_with_data(self, sm_runner): + """done.state event fires and triggers a transition.""" + + class DestroyTheRing(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)) + + sm = await sm_runner.start(DestroyTheRing) + 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.""" + + 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)) + + final = State(final=True) + done_state_outer = Event(outer.to(final)) + + 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 DestroyTheRing(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(DestroyTheRing, 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.""" + + class QuestForErebor(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 = await sm_runner.start(QuestForErebor) + 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.""" + + class QuestForErebor(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)) + + sm = await sm_runner.start(QuestForErebor) + 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.""" + + class QuestForErebor(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") + + sm = await sm_runner.start(QuestForErebor) + 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.""" + + 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) + + sm = await sm_runner.start(QuestForErebor) + await sm_runner.send(sm, "enter_mountain") + assert {"victory"} == set(sm.configuration_values) diff --git a/tests/test_statechart_error.py b/tests/test_statechart_error.py new file mode 100644 index 00000000..ea96755f --- /dev/null +++ b/tests/test_statechart_error.py @@ -0,0 +1,83 @@ +"""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 diff --git a/tests/test_statechart_eventless.py b/tests/test_statechart_eventless.py new file mode 100644 index 00000000..4e69eb68 --- /dev/null +++ b/tests/test_statechart_eventless.py @@ -0,0 +1,174 @@ +"""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 statemachine import State +from statemachine import StateChart + + +@pytest.mark.timeout(5) +class TestEventlessTransitions: + async def test_eventless_fires_when_condition_met(self, sm_runner): + """Eventless transition fires when guard is True.""" + + 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 + + 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.""" + + class RingCorruption(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 + + sm = await sm_runner.start(RingCorruption) + 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.""" + + 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) + + 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.""" + + class RingCorruption(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 + + sm = await sm_runner.start(RingCorruption) + 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.""" + + 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) + + 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').""" + + 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')") + + 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.""" + + 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) + + sm = await sm_runner.start(BeaconChain) + assert {"signal_received"} == set(sm.configuration_values) diff --git a/tests/test_statechart_history.py b/tests/test_statechart_history.py new file mode 100644 index 00000000..3ed35fe0 --- /dev/null +++ b/tests/test_statechart_history.py @@ -0,0 +1,224 @@ +"""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 statemachine import HistoryState +from statemachine import State +from statemachine import StateChart + + +@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.""" + + 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 = 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.""" + + class GollumPersonality(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) + + sm = await sm_runner.start(GollumPersonality) + 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.""" + + 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) + + 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.""" + + 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 = 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.""" + + class GollumPersonality(StateChart): + class personality(State.Compound): + smeagol = State(initial=True) + gollum = State() + h = HistoryState() + + dark_side = smeagol.to(gollum) + + outside = State() + leave = personality.to(outside) + return_via_history = outside.to(personality.h) + + 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.""" + + 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) + + 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.""" + + class GollumPersonality(StateChart): + class personality(State.Compound): + smeagol = State(initial=True) + gollum = State() + h = HistoryState() + + dark_side = smeagol.to(gollum) + + outside = State() + leave = personality.to(outside) + return_via_history = outside.to(personality.h) + + 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.""" + + class GollumPersonality(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) + + sm = await sm_runner.start(GollumPersonality) + await sm_runner.send(sm, "enter_via_history") + assert "gollum" in sm.configuration_values diff --git a/tests/test_statechart_in_condition.py b/tests/test_statechart_in_condition.py new file mode 100644 index 00000000..593ea6c4 --- /dev/null +++ b/tests/test_statechart_in_condition.py @@ -0,0 +1,162 @@ +"""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 statemachine import State +from statemachine import StateChart + + +@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.""" + + 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')") + + 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.""" + + 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) + + 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.""" + + 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')") + + 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.""" + + 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) + + 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.""" + + 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')") + + 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.""" + + 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')") + + 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 diff --git a/tests/test_statechart_parallel.py b/tests/test_statechart_parallel.py new file mode 100644 index 00000000..6e87d42e --- /dev/null +++ b/tests/test_statechart_parallel.py @@ -0,0 +1,275 @@ +"""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 statemachine import State +from statemachine import StateChart + + +@pytest.mark.timeout(5) +class TestParallelStates: + @pytest.fixture() + def war_of_the_ring_cls(self): + 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) + + return WarOfTheRing + + async def test_parallel_activates_all_regions(self, sm_runner, war_of_the_ring_cls): + """Entering a parallel state activates the initial child of every region.""" + sm = await sm_runner.start(war_of_the_ring_cls) + 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, war_of_the_ring_cls): + """An event in one region does not affect others.""" + sm = await sm_runner.start(war_of_the_ring_cls) + 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, war_of_the_ring_cls): + """Configuration set includes all active states across regions.""" + sm = await sm_runner.start(war_of_the_ring_cls) + 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.""" + + 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) + + 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, war_of_the_ring_cls): + """Region isolation: events affect only the targeted region.""" + sm = await sm_runner.start(war_of_the_ring_cls) + 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, war_of_the_ring_cls): + """Mixed hierarchy: parallel with compound regions verified.""" + sm = await sm_runner.start(war_of_the_ring_cls) + 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, war_of_the_ring_cls): + """configuration_values supports set comparison for parallel states.""" + sm = await sm_runner.start(war_of_the_ring_cls) + 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.""" + + 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) + + 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.""" + + 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) + + 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, war_of_the_ring_cls + ): + """Deep transition within a compound region of a parallel state.""" + sm = await sm_runner.start(war_of_the_ring_cls) + 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.""" + + 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) + + 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.""" + + 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) + + finished = State(final=True) + done_state_session = session.to(finished) + + sm = await sm_runner.start(Session) + 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.""" + + 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) + + 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 diff --git a/tests/test_statemachine.py b/tests/test_statemachine.py index ea1531f7..5bc7f85c 100644 --- a/tests/test_statemachine.py +++ b/tests/test_statemachine.py @@ -1,7 +1,9 @@ import pytest +from statemachine.orderedset import OrderedSet +from statemachine import HistoryState from statemachine import State -from statemachine import StateMachine +from statemachine import StateChart from statemachine import exceptions from tests.models import MyModel @@ -11,7 +13,7 @@ def test_machine_repr(campaign_machine): machine = campaign_machine(model) assert ( repr(machine) == "CampaignMachine(model=MyModel({'state': 'draft'}), " - "state_field='state', current_state='draft')" + "state_field='state', configuration=['draft'])" ) @@ -31,13 +33,13 @@ def test_machine_should_be_at_start_state(campaign_machine): ] assert model.state == "draft" - assert machine.current_state == machine.draft + assert machine.draft.is_active def test_machine_should_only_allow_only_one_initial_state(): with pytest.raises(exceptions.InvalidDefinition): - class CampaignMachine(StateMachine): + class CampaignMachine(StateChart): "A workflow machine" draft = State(initial=True) @@ -54,7 +56,7 @@ class CampaignMachine(StateMachine): def test_machine_should_activate_initial_state(mocker): spy = mocker.Mock() - class CampaignMachine(StateMachine): + class CampaignMachine(StateChart): "A workflow machine" draft = State(initial=True) @@ -72,7 +74,7 @@ def on_enter_draft(self): sm = CampaignMachine() spy.assert_called_once_with("draft") - assert sm.current_state == sm.draft + assert sm.draft.is_active assert sm.draft.is_active spy.reset_mock() @@ -80,14 +82,14 @@ def on_enter_draft(self): assert sm.activate_initial_state() is None spy.assert_not_called() - assert sm.current_state == sm.draft + 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(StateMachine): + class CampaignMachine(StateChart): "A workflow machine" draft = State(initial=True) @@ -104,12 +106,12 @@ def test_should_change_state(campaign_machine): machine = campaign_machine(model) assert model.state == "draft" - assert machine.current_state == machine.draft + assert machine.draft.is_active machine.produce() assert model.state == "producing" - assert machine.current_state == machine.producing + assert machine.producing.is_active def test_should_run_a_transition_that_keeps_the_state(campaign_machine): @@ -117,19 +119,19 @@ def test_should_run_a_transition_that_keeps_the_state(campaign_machine): machine = campaign_machine(model) assert model.state == "draft" - assert machine.current_state == machine.draft + assert machine.draft.is_active machine.add_job() assert model.state == "draft" - assert machine.current_state == machine.draft + assert machine.draft.is_active machine.produce() assert model.state == "producing" - assert machine.current_state == machine.producing + assert machine.producing.is_active machine.add_job() assert model.state == "producing" - assert machine.current_state == machine.producing + assert machine.producing.is_active def test_should_change_state_with_multiple_machine_instances(campaign_machine): @@ -138,38 +140,19 @@ def test_should_change_state_with_multiple_machine_instances(campaign_machine): machine1 = campaign_machine(model1) machine2 = campaign_machine(model2) - assert machine1.current_state == campaign_machine.draft - assert machine2.current_state == campaign_machine.draft + assert machine1.draft.is_active + assert machine2.draft.is_active p1 = machine1.produce p2 = machine2.produce p2() - assert machine1.current_state == campaign_machine.draft - assert machine2.current_state == campaign_machine.producing + assert machine1.draft.is_active + assert machine2.producing.is_active p1() - assert machine1.current_state == campaign_machine.producing - assert machine2.current_state == campaign_machine.producing - - -@pytest.mark.parametrize( - ("current_state", "transition"), - [ - ("draft", "deliver"), - ("closed", "add_job"), - ], -) -def test_call_to_transition_that_is_not_in_the_current_state_should_raise_exception( - campaign_machine, current_state, transition -): - model = MyModel(state=current_state) - machine = campaign_machine(model) - - assert machine.current_state.value == current_state - - with pytest.raises(exceptions.TransitionNotAllowed): - machine.send(transition) + assert machine1.producing.is_active + assert machine2.producing.is_active def test_machine_should_list_allowed_events_in_the_current_state(campaign_machine): @@ -198,23 +181,11 @@ def test_machine_should_run_a_transition_by_his_key(campaign_machine): machine.send("add_job") assert model.state == "draft" - assert machine.current_state == machine.draft + assert machine.draft.is_active machine.send("produce") assert model.state == "producing" - assert machine.current_state == machine.producing - - -def test_machine_should_raise_an_exception_if_a_transition_by_his_key_is_not_found( - campaign_machine, -): - model = MyModel() - machine = campaign_machine(model) - - assert model.state == "draft" - - with pytest.raises(exceptions.TransitionNotAllowed): - machine.send("go_horse") + assert machine.producing.is_active def test_machine_should_use_and_model_attr_other_than_state(campaign_machine): @@ -223,12 +194,12 @@ def test_machine_should_use_and_model_attr_other_than_state(campaign_machine): assert getattr(model, "state", None) is None assert model.status == "producing" - assert machine.current_state == machine.producing + assert machine.producing.is_active machine.deliver() assert model.status == "closed" - assert machine.current_state == machine.closed + assert machine.closed.is_active def test_cant_assign_an_invalid_state_directly(campaign_machine): @@ -331,14 +302,12 @@ def test_state_machine_with_a_invalid_model_state_value(request, campaign_machin model = MyModel(state="tapioca") sm = machine_cls(model) - with pytest.raises( - exceptions.InvalidStateValue, match="'tapioca' is not a valid state value." - ): - assert sm.current_state == sm.draft + with pytest.raises(KeyError): + sm.configuration # noqa: B018 def test_should_not_create_instance_of_abstract_machine(): - class EmptyMachine(StateMachine): + class EmptyMachine(StateChart): "An empty machine" pass @@ -349,16 +318,18 @@ class EmptyMachine(StateMachine): def test_should_not_create_instance_of_machine_without_states(): s1 = State() - with pytest.raises(exceptions.InvalidDefinition): - class OnlyTransitionMachine(StateMachine): - t1 = s1.to.itself() + 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(StateMachine): + class NoTransitionsMachine(StateChart): "A machine without transitions" initial = State(initial=True) @@ -371,7 +342,7 @@ def test_should_not_create_disconnected_machine(): ) with pytest.raises(exceptions.InvalidDefinition, match=expected): - class BrokenTrafficLightMachine(StateMachine): + class BrokenTrafficLightMachine(StateChart): "A broken traffic light machine" green = State(initial=True) @@ -388,7 +359,7 @@ def test_should_not_create_big_disconnected_machine(): ) with pytest.raises(exceptions.InvalidDefinition, match=expected): - class BrokenTrafficLightMachine(StateMachine): + class BrokenTrafficLightMachine(StateChart): "A broken traffic light machine" green = State(initial=True) @@ -403,11 +374,81 @@ class BrokenTrafficLightMachine(StateMachine): 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(StateMachine, strict_states=False): + class ValueTestModel(StateChart): new = State(STATE_NEW, value=STATE_NEW, initial=True) draft = State(STATE_DRAFT, value=STATE_DRAFT, final=True) @@ -462,25 +503,25 @@ def produce(self): model.deliver() assert sm.current_state_value == "closed" - def test_should_warn_if_thereis_a_trap_state(self, capsys): - with pytest.warns( - UserWarning, + def test_should_raise_if_thereis_a_trap_state(self): + with pytest.raises( + exceptions.InvalidDefinition, match=r"have no outgoing transition: \['state_without_outgoing_transition'\]", ): - class TrapStateMachine(StateMachine): + class TrapStateMachine(StateChart): initial = State(initial=True) state_without_outgoing_transition = State() t = initial.to(state_without_outgoing_transition) - def test_should_warn_if_no_path_to_a_final_state(self, capsys): - with pytest.warns( - UserWarning, + def test_should_raise_if_no_path_to_a_final_state(self): + with pytest.raises( + exceptions.InvalidDefinition, match=r"have no path to a final state: \['producing'\]", ): - class TrapStateMachine(StateMachine): + class TrapStateMachine(StateChart): started = State(initial=True) closed = State(final=True) producing = State() @@ -505,6 +546,90 @@ def __bool__(self): assert model.state == "producing" +def test_abstract_sm_no_states(): + """A state machine class with no states is abstract.""" + + class AbstractSM(StateChart): + pass + + assert AbstractSM._abstract is True + + +def test_raise_sends_internal_event(): + """raise_ sends an internal event.""" + + class SM(StateChart): + s1 = State(initial=True) + s2 = State(final=True) + + internal_event = s1.to(s2) + + sm = SM() + sm.raise_("internal_event") + assert sm.s2.is_active + + +def test_configuration_values_returns_ordered_set(): + """configuration_values returns OrderedSet.""" + + class SM(StateChart): + s1 = State(initial=True) + s2 = State(final=True) + + go = s1.to(s2) + + sm = SM() + vals = sm.configuration_values + assert isinstance(vals, OrderedSet) + + +def test_states_getitem(): + """States supports index access.""" + + class SM(StateChart): + s1 = State(initial=True) + s2 = State(final=True) + + go = s1.to(s2) + + assert SM.states[0].id == "s1" + assert SM.states[1].id == "s2" + + +def test_multiple_initial_states_raises(): + """Multiple initial states raise InvalidDefinition.""" + with pytest.raises(exceptions.InvalidDefinition, match="one and only one initial state"): + + class BadSM(StateChart): + s1 = State(initial=True) + s2 = State(initial=True) + + go = s1.to(s2) + + +def test_configuration_values_returns_orderedset_when_compound_state(): + """configuration_values returns the OrderedSet directly when it is already one.""" + from statemachine import StateChart + + 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) + + sm = SM() + sm.send("enter") + vals = sm.configuration_values + assert isinstance(vals, OrderedSet) + + class TestEnabledEvents: def test_no_conditions_same_as_allowed_events(self, campaign_machine): """Without conditions, enabled_events should match allowed_events.""" @@ -512,7 +637,7 @@ def test_no_conditions_same_as_allowed_events(self, campaign_machine): assert [e.id for e in sm.enabled_events()] == [e.id for e in sm.allowed_events] def test_passing_condition_returns_event(self): - class MyMachine(StateMachine): + class MyMachine(StateChart): s0 = State(initial=True) s1 = State(final=True) @@ -525,7 +650,7 @@ def is_ready(self): assert [e.id for e in sm.enabled_events()] == ["go"] def test_failing_condition_excludes_event(self): - class MyMachine(StateMachine): + class MyMachine(StateChart): s0 = State(initial=True) s1 = State(final=True) @@ -540,9 +665,9 @@ def is_ready(self): def test_multiple_transitions_one_passes(self): """Same event with multiple transitions: included if at least one passes.""" - class MyMachine(StateMachine): + class MyMachine(StateChart): s0 = State(initial=True) - s1 = State() + s1 = State(final=True) s2 = State(final=True) go = s0.to(s1, cond="cond_false") | s0.to(s2, cond="cond_true") @@ -556,6 +681,27 @@ def cond_true(self): sm = MyMachine() assert [e.id for e in sm.enabled_events()] == ["go"] + 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") + + def cond_a(self): + return True + + def cond_b(self): + return True + + sm = MyMachine() + ids = [e.id for e in sm.enabled_events()] + assert ids == ["go"] + assert len(ids) == 1 + def test_final_state_returns_empty(self, campaign_machine): sm = campaign_machine() sm.produce() @@ -563,7 +709,7 @@ def test_final_state_returns_empty(self, campaign_machine): assert sm.enabled_events() == [] def test_kwargs_forwarded_to_conditions(self): - class MyMachine(StateMachine): + class MyMachine(StateChart): s0 = State(initial=True) s1 = State(final=True) @@ -579,7 +725,7 @@ def check_value(self, value=0): def test_condition_exception_treated_as_enabled(self): """If a condition raises, the event is treated as enabled (permissive).""" - class MyMachine(StateMachine): + class MyMachine(StateChart): s0 = State(initial=True) s1 = State(final=True) @@ -592,9 +738,9 @@ def bad_cond(self): assert [e.id for e in sm.enabled_events()] == ["go"] def test_mixed_enabled_and_disabled(self): - class MyMachine(StateMachine): + class MyMachine(StateChart): s0 = State(initial=True) - s1 = State() + s1 = State(final=True) s2 = State(final=True) go = s0.to(s1, cond="cond_true") @@ -610,7 +756,7 @@ def cond_false(self): assert [e.id for e in sm.enabled_events()] == ["go"] def test_unless_condition(self): - class MyMachine(StateMachine): + class MyMachine(StateChart): s0 = State(initial=True) s1 = State(final=True) @@ -623,7 +769,7 @@ def is_blocked(self): assert sm.enabled_events() == [] def test_unless_condition_passes(self): - class MyMachine(StateMachine): + class MyMachine(StateChart): s0 = State(initial=True) s1 = State(final=True) @@ -634,3 +780,101 @@ def is_blocked(self): 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" diff --git a/tests/test_statemachine_bounded_transitions.py b/tests/test_statemachine_bounded_transitions.py index 00c99106..d473745b 100644 --- a/tests/test_statemachine_bounded_transitions.py +++ b/tests/test_statemachine_bounded_transitions.py @@ -3,7 +3,7 @@ import pytest from statemachine import State -from statemachine import StateMachine +from statemachine import StateChart from .models import MyModel @@ -15,7 +15,7 @@ def event_mock(): @pytest.fixture() def state_machine(event_mock): - class CampaignMachine(StateMachine): + class CampaignMachine(StateChart): draft = State(initial=True) producing = State() closed = State(final=True) diff --git a/tests/test_statemachine_compat.py b/tests/test_statemachine_compat.py new file mode 100644 index 00000000..a2e2ca60 --- /dev/null +++ b/tests/test_statemachine_compat.py @@ -0,0 +1,374 @@ +"""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`` +- ``error_on_execution = 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_error_on_execution(self): + assert StateMachine.error_on_execution 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() + + +# --------------------------------------------------------------------------- +# error_on_execution = False (exceptions propagate directly) +# --------------------------------------------------------------------------- + + +class TestErrorOnExecutionFalse: + """With error_on_execution=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 + + def test_current_state_with_list_value(self): + """current_state handles list current_state_value (backward compat).""" + + class SM(StateMachine): + s1 = State(initial=True) + s2 = State(final=True) + + go = s1.to(s2) + + sm = SM() + setattr(sm.model, sm.state_field, [sm.s1.value]) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + config = sm.current_state + assert sm.s1 in config diff --git a/tests/test_statemachine_inheritance.py b/tests/test_statemachine_inheritance.py index f66a12a3..4372c50a 100644 --- a/tests/test_statemachine_inheritance.py +++ b/tests/test_statemachine_inheritance.py @@ -6,9 +6,9 @@ @pytest.fixture() def BaseMachine(): from statemachine import State - from statemachine import StateMachine + from statemachine import StateChart - class BaseMachine(StateMachine, strict_states=False): + class BaseMachine(StateChart): state_1 = State(initial=True) state_2 = State() trans_1_2 = state_1.to(state_2) @@ -19,7 +19,7 @@ class BaseMachine(StateMachine, strict_states=False): @pytest.fixture() def InheritedClass(BaseMachine): - class InheritedClass(BaseMachine, strict_states=False): + class InheritedClass(BaseMachine): pass return InheritedClass diff --git a/tests/test_threading.py b/tests/test_threading.py index 9c2a2d6b..5f6721a7 100644 --- a/tests/test_threading.py +++ b/tests/test_threading.py @@ -2,7 +2,7 @@ import time from statemachine.state import State -from statemachine.statemachine import StateMachine +from statemachine.statemachine import StateChart def test_machine_should_allow_multi_thread_event_changes(): @@ -10,7 +10,7 @@ def test_machine_should_allow_multi_thread_event_changes(): Test for https://github.com/fgmacedo/python-statemachine/issues/443 """ - class CampaignMachine(StateMachine): + class CampaignMachine(StateChart): "A workflow machine" draft = State(initial=True) @@ -27,18 +27,17 @@ def off_thread_change_state(): thread = threading.Thread(target=off_thread_change_state) thread.start() thread.join() - assert machine.current_state.id == "producing" + assert machine.current_state_value == "producing" def test_regression_443(): """ Test for https://github.com/fgmacedo/python-statemachine/issues/443 """ - time_collecting = 0.2 - time_to_send = 0.125 - time_sampling_current_state = 0.05 + total_iterations = 4 + send_at_iteration = 3 # 0-indexed: send before the 4th sample - class TrafficLightMachine(StateMachine): + class TrafficLightMachine(StateChart): "A traffic light machine" green = State(initial=True) @@ -52,25 +51,20 @@ def __init__(self): self.statuses_history = [] self.fsm = TrafficLightMachine() # set up thread - t = threading.Thread(target=self.recv_cmds) - t.start() + self.thread = threading.Thread(target=self.recv_cmds) + self.thread.start() def recv_cmds(self): - """Pretend we receive a command triggering a state change after Xs.""" - waiting_time = 0 - sent = False - while waiting_time < time_collecting: - if waiting_time >= time_to_send and not sent: + """Pretend we receive a command triggering a state change.""" + for i in range(total_iterations): + if i == send_at_iteration: self.fsm.cycle() - sent = True - - waiting_time += time_sampling_current_state - self.statuses_history.append(self.fsm.current_state.id) - time.sleep(time_sampling_current_state) + self.statuses_history.append(self.fsm.current_state_value) c1 = Controller() c2 = Controller() - time.sleep(time_collecting + 0.01) + c1.thread.join() + c2.thread.join() assert c1.statuses_history == ["green", "green", "green", "yellow"] assert c2.statuses_history == ["green", "green", "green", "yellow"] @@ -79,11 +73,10 @@ def test_regression_443_with_modifications(): """ Test for https://github.com/fgmacedo/python-statemachine/issues/443 """ - time_collecting = 0.2 - time_to_send = 0.125 - time_sampling_current_state = 0.05 + total_iterations = 4 + send_at_iteration = 3 # 0-indexed: send before the 4th sample - class TrafficLightMachine(StateMachine): + class TrafficLightMachine(StateChart): "A traffic light machine" green = State(initial=True) @@ -98,44 +91,38 @@ def __init__(self, name): super().__init__() def beat(self): - waiting_time = 0 - sent = False - while waiting_time < time_collecting: - if waiting_time >= time_to_send and not sent: + for i in range(total_iterations): + if i == send_at_iteration: self.cycle() - sent = True - - self.statuses_history.append(f"{self.name}.{self.current_state.id}") - - time.sleep(time_sampling_current_state) - waiting_time += time_sampling_current_state + self.statuses_history.append(f"{self.name}.{self.current_state_value}") class Controller: def __init__(self, name): self.fsm = TrafficLightMachine(name) # set up thread - t = threading.Thread(target=self.fsm.beat) - t.start() + self.thread = threading.Thread(target=self.fsm.beat) + self.thread.start() c1 = Controller("c1") c2 = Controller("c2") c3 = Controller("c3") - time.sleep(time_collecting + 0.01) + 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"] -async def test_regression_443_with_modifications_for_async_engine(): # noqa: C901 +async def test_regression_443_with_modifications_for_async_engine(): """ Test for https://github.com/fgmacedo/python-statemachine/issues/443 """ - time_collecting = 0.2 - time_to_send = 0.125 - time_sampling_current_state = 0.05 + total_iterations = 4 + send_at_iteration = 3 # 0-indexed: send before the 4th sample - class TrafficLightMachine(StateMachine): + class TrafficLightMachine(StateChart): "A traffic light machine" green = State(initial=True) @@ -153,17 +140,10 @@ def __init__(self, name): super().__init__() def beat(self): - waiting_time = 0 - sent = False - while waiting_time < time_collecting: - if waiting_time >= time_to_send and not sent: + for i in range(total_iterations): + if i == send_at_iteration: self.cycle() - sent = True - - self.statuses_history.append(f"{self.name}.{self.current_state.id}") - - time.sleep(time_sampling_current_state) - waiting_time += time_sampling_current_state + self.statuses_history.append(f"{self.name}.{self.current_state_value}") class Controller: def __init__(self, name): @@ -172,8 +152,8 @@ def __init__(self, name): async def start(self): # set up thread await self.fsm.activate_initial_state() - t = threading.Thread(target=self.fsm.beat) - t.start() + self.thread = threading.Thread(target=self.fsm.beat) + self.thread.start() c1 = Controller("c1") c2 = Controller("c2") @@ -181,7 +161,9 @@ async def start(self): await c1.start() await c2.start() await c3.start() - time.sleep(time_collecting + 0.01) + 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"] diff --git a/tests/test_transition_list.py b/tests/test_transition_list.py index 2089c61c..03c3f61f 100644 --- a/tests/test_transition_list.py +++ b/tests/test_transition_list.py @@ -1,6 +1,8 @@ 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 @@ -61,3 +63,43 @@ def my_callback(): 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") diff --git a/tests/test_transitions.py b/tests/test_transitions.py index 03b0ac57..019ab7aa 100644 --- a/tests/test_transitions.py +++ b/tests/test_transitions.py @@ -3,7 +3,7 @@ from statemachine.transition import Transition from statemachine import State -from statemachine import StateMachine +from statemachine import StateChart from .models import MyModel @@ -11,10 +11,8 @@ def test_transition_representation(campaign_machine): s = repr([t for t in campaign_machine.draft.transitions if t.event == "produce"][0]) assert s == ( - "Transition(" - "State('Draft', id='draft', value='draft', initial=True, final=False), " - "State('Being produced', id='producing', value='producing', " - "initial=False, final=False), event='produce', internal=False)" + "Transition('Draft', 'Being produced', event=[" + "Event('produce', delay=0, internal=False)], internal=False, initial=False)" ) @@ -32,16 +30,16 @@ def test_list_state_transitions(classic_traffic_light_machine): def test_transition_should_accept_decorator_syntax(traffic_light_machine): machine = traffic_light_machine() - assert machine.current_state == machine.green + 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.current_state == machine.green + assert machine.green.is_active machine.cycle(1, 2, number=3, text="x") - assert machine.current_state == machine.yellow + assert machine.yellow.is_active captured = capsys.readouterr() assert captured.out == "Running cycle from green to yellow\n" @@ -59,7 +57,7 @@ def test_cycle_transitions(request, machine_name): machine = machine_class() expected_states = ["green", "yellow", "red"] * 2 for expected_state in expected_states: - assert machine.current_state.id == expected_state + assert machine.current_state_value == expected_state machine.cycle() @@ -89,7 +87,7 @@ def test_transition_list_call_can_only_be_used_as_decorator(): def transition_callback_machine(request): if request.param == "bounded": - class ApprovalMachine(StateMachine): + class ApprovalMachine(StateChart): "A workflow" requested = State(initial=True) @@ -103,7 +101,7 @@ def on_validate(self): elif request.param == "unbounded": - class ApprovalMachine(StateMachine): + class ApprovalMachine(StateChart): "A workflow" requested = State(initial=True) @@ -128,7 +126,7 @@ def test_statemachine_transition_callback(transition_callback_machine): def test_can_run_combined_transitions(): - class CampaignMachine(StateMachine): + class CampaignMachine(StateChart): "A workflow machine" draft = State(initial=True) @@ -151,7 +149,7 @@ def test_can_detect_stuck_states(): match="All non-final states should have at least one outgoing transition.", ): - class CampaignMachine(StateMachine, strict_states=True): + class CampaignMachine(StateChart): "A workflow machine" draft = State(initial=True) @@ -164,13 +162,29 @@ class CampaignMachine(StateMachine, strict_states=True): 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(StateMachine, strict_states=True): + class CampaignMachine(StateChart): "A workflow machine" draft = State(initial=True) @@ -183,8 +197,24 @@ class CampaignMachine(StateMachine, strict_states=True): 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(StateMachine): + class CampaignMachine(StateChart): "A workflow machine" draft = State(initial=True) @@ -213,7 +243,7 @@ class TestReverseTransition: ) def test_reverse_transition(self, reverse_traffic_light_machine, initial_state): machine = reverse_traffic_light_machine(start_value=initial_state) - assert machine.current_state.id == initial_state + assert machine.current_state_value == initial_state machine.stop() @@ -229,7 +259,7 @@ def test_should_transition_with_a_dict_as_return(): "c": 3, } - class ApprovalMachine(StateMachine): + class ApprovalMachine(StateChart): "A workflow" requested = State(initial=True) @@ -249,25 +279,16 @@ def on_accept(self): class TestInternalTransition: - @pytest.mark.parametrize( - ("internal", "expected_calls"), - [ - (False, ["on_exit_initial", "on_enter_initial"]), - (True, []), - ], - ) - def test_should_not_execute_state_actions_on_internal_transitions( - self, internal, expected_calls, engine - ): + def test_external_self_transition_executes_state_actions(self, engine): calls = [] - class TestStateMachine(StateMachine): + class TestStateMachine(StateChart): initial = State(initial=True) - loop = initial.to.itself(internal=internal) + loop = initial.to.itself(internal=False) - def _get_engine(self, rtc: bool): - return engine(self, rtc) + def _get_engine(self): + return engine(self) def on_exit_initial(self): calls.append("on_exit_initial") @@ -280,14 +301,40 @@ def on_enter_initial(self): calls.clear() sm.loop() - assert calls == expected_calls + 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="Internal transitions should be self-transitions." + InvalidDefinition, match="Not a valid internal transition from source." ): - class TestStateMachine(StateMachine): + class TestStateMachine(StateChart): initial = State(initial=True) final = State(final=True) @@ -295,16 +342,18 @@ class TestStateMachine(StateMachine): class TestAllowEventWithoutTransition: - def test_send_unknown_event(self, classic_traffic_light_machine): - sm = classic_traffic_light_machine(allow_event_without_transition=True) + 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): - sm = classic_traffic_light_machine(allow_event_without_transition=True) + 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 @@ -315,7 +364,10 @@ def test_send_not_valid_for_the_current_state_event(self, classic_traffic_light_ class TestTransitionFromAny: @pytest.fixture() def account_sm(self): - class AccountStateMachine(StateMachine): + class AccountStateMachine(StateChart): + allow_event_without_transition = False + error_on_execution = False + active = State("Active", initial=True) suspended = State("Suspended") overdrawn = State("Overdrawn") @@ -361,7 +413,9 @@ def test_transition_from_any_with_cond(self, account_sm): assert sm.active.is_active def test_any_can_be_used_as_decorator(self): - class AccountStateMachine(StateMachine): + class AccountStateMachine(StateChart): + error_on_execution = False + active = State("Active", initial=True) suspended = State("Suspended") overdrawn = State("Overdrawn") @@ -385,3 +439,19 @@ def do_close_account(self): 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") diff --git a/tests/test_weighted_transitions.py b/tests/test_weighted_transitions.py new file mode 100644 index 00000000..d2a612cb --- /dev/null +++ b/tests/test_weighted_transitions.py @@ -0,0 +1,458 @@ +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) diff --git a/tests/testcases/__init__.py b/tests/testcases/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/testcases/issue308.md b/tests/testcases/issue308.md index 7ecc30de..105a3528 100644 --- a/tests/testcases/issue308.md +++ b/tests/testcases/issue308.md @@ -6,9 +6,9 @@ A StateMachine that exercises the example given on issue In this example, we share the transition list between events. ```py ->>> from statemachine import StateMachine, State +>>> from statemachine import StateChart, State ->>> class TestSM(StateMachine): +>>> class TestSM(StateChart): ... state1 = State('s1', initial=True) ... state2 = State('s2') ... state3 = State('s3') @@ -91,31 +91,37 @@ Example given: >>> m = TestSM() enter state1 ->>> m.state1.is_active, m.state2.is_active, m.state3.is_active, m.state4.is_active, m.current_state ; _ = m.cycle() -(True, False, False, False, State('s1', id='state1', value='state1', initial=True, final=False)) +>>> 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, m.current_state ; _ = m.cycle() -(False, True, False, False, State('s2', id='state2', value='state2', initial=False, final=False)) +>>> 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, m.current_state ; _ = m.cycle() -(False, False, True, False, State('s3', id='state3', value='state3', initial=False, final=False)) +>>> 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, m.current_state -(False, False, False, True, State('s4', id='state4', value='state4', initial=False, final=True)) +>>> 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']) ``` diff --git a/tests/testcases/issue384_multiple_observers.md b/tests/testcases/issue384_multiple_observers.md index bff0c20d..1c5ec25c 100644 --- a/tests/testcases/issue384_multiple_observers.md +++ b/tests/testcases/issue384_multiple_observers.md @@ -9,7 +9,7 @@ This works also as a regression test. ```py >>> from statemachine import State ->>> from statemachine import StateMachine +>>> from statemachine import StateChart >>> class MyObs: ... def on_move_car(self): @@ -21,7 +21,7 @@ This works also as a regression test. ... ->>> class Car(StateMachine): +>>> class Car(StateChart): ... stopped = State(initial=True) ... moving = State() ... @@ -40,13 +40,13 @@ Running: >>> obs = MyObs() >>> obs2 = MyObs2() >>> car.add_listener(obs) -Car(model=Model(state=stopped), state_field='state', current_state='stopped') +Car(model=Model(state=stopped), state_field='state', configuration=['stopped']) >>> car.add_listener(obs2) -Car(model=Model(state=stopped), state_field='state', current_state='stopped') +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', current_state='stopped') +Car(model=Model(state=stopped), state_field='state', configuration=['stopped']) >>> car.move_car() I'm moving diff --git a/tests/testcases/issue434.md b/tests/testcases/issue434.md deleted file mode 100644 index 3e029121..00000000 --- a/tests/testcases/issue434.md +++ /dev/null @@ -1,87 +0,0 @@ -### Issue 434 - -A StateMachine that exercises the example given on issue -#[434](https://github.com/fgmacedo/python-statemachine/issues/434). - - -```py ->>> from time import sleep ->>> from statemachine import StateMachine, State - ->>> class Model: -... def __init__(self, data: dict): -... self.data = data - ->>> class DataCheckerMachine(StateMachine): -... 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 -... - -``` - -Run until we reach the max cycle without success: - -```py ->>> data = {"value": 1} ->>> sm1 = DataCheckerMachine(Model(data)) ->>> cycle_rate = 0.1 ->>> while not sm1.current_state.final: -... sm1.cycle() -... sleep(cycle_rate) -Running cycle 0 from Check data to Check data. -Running cycle 1 from Check data to Check data. -Running cycle 2 from Check data to Check data. -Running cycle 3 from Check data to Check data. -Running cycle 4 from Check data to Check data. -Running cycle 5 from Check data to Check data. -Running cycle 6 from Check data to Check data. -Running cycle 7 from Check data to Check data. -Running cycle 8 from Check data to Check data. -Running cycle 9 from Check data to Check data. -Running cycle 10 from Check data to Check data. -Running cycle 11 from Check data to Data bad. - -``` - - -Run simulating that the data turns good on the 5th iteration: - -```py ->>> data = {"value": 1} ->>> sm2 = DataCheckerMachine(Model(data)) ->>> cycle_rate = 0.1 ->>> while not sm2.current_state.final: -... sm2.cycle() -... if sm2.cycle_count == 5: -... print("Now data looks good!") -... data["value"] = 20 -... sleep(cycle_rate) -Running cycle 0 from Check data to Check data. -Running cycle 1 from Check data to Check data. -Running cycle 2 from Check data to Check data. -Running cycle 3 from Check data to Check data. -Running cycle 4 from Check data to Check data. -Now data looks good! -Running cycle 5 from Check data to Data good. - -``` diff --git a/tests/testcases/issue449.md b/tests/testcases/issue449.md index f1295646..18716387 100644 --- a/tests/testcases/issue449.md +++ b/tests/testcases/issue449.md @@ -7,9 +7,9 @@ A StateMachine that exercises the example given on issue ```py ->>> from statemachine import StateMachine, State +>>> from statemachine import StateChart, State ->>> class ExampleStateMachine(StateMachine): +>>> class ExampleStateMachine(StateChart): ... initial = State(initial=True) ... second = State() ... third = State() @@ -40,8 +40,8 @@ Exercise: >>> example = ExampleStateMachine() Entering state initial. Event: __initial__ ->>> print(example.current_state) -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 @@ -49,7 +49,7 @@ 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", example.current_state) -My current state is Fourth +>>> print("My current state is", list(example.configuration_values)) +My current state is ['fourth'] ``` diff --git a/tests/testcases/issue480.md b/tests/testcases/issue480.md deleted file mode 100644 index 71b78d37..00000000 --- a/tests/testcases/issue480.md +++ /dev/null @@ -1,43 +0,0 @@ - - -### 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. - -```py ->>> from statemachine import StateMachine, State ->>> ->>> class MyStateMachine(StateMachine): -... State_1 = State(initial=True) -... State_2 = State(final=True) -... Trans_1 = State_1.to(State_2) -... -... def __init__(self): -... super(MyStateMachine, self).__init__() -... -... def on_enter_State_1(self): -... print("Entering State_1 state") -... self.long_running_task() -... -... def on_exit_State_1(self): -... print("Exiting State_1 state") -... -... def on_enter_State_2(self): -... print("Entering State_2 state") -... -... def long_running_task(self): -... print("long running task process started") -... self.Trans_1() -... print("long running task process ended") -... ->>> sm = MyStateMachine() -Entering State_1 state -long running task process started -long running task process ended -Exiting State_1 state -Entering State_2 state - -``` diff --git a/tests/testcases/test_issue434.py b/tests/testcases/test_issue434.py new file mode 100644 index 00000000..ac19ba65 --- /dev/null +++ b/tests/testcases/test_issue434.py @@ -0,0 +1,75 @@ +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): + error_on_execution = 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 diff --git a/tests/testcases/test_issue480.py b/tests/testcases/test_issue480.py new file mode 100644 index 00000000..c2d0c107 --- /dev/null +++ b/tests/testcases/test_issue480.py @@ -0,0 +1,56 @@ +""" + +### 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 diff --git a/tests/testcases/test_issue509.py b/tests/testcases/test_issue509.py new file mode 100644 index 00000000..591dc1c5 --- /dev/null +++ b/tests/testcases/test_issue509.py @@ -0,0 +1,65 @@ +""" + +### 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): + error_on_execution = False + + 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" diff --git a/uv.lock b/uv.lock index 7aa93d7c..496ec66d 100644 --- a/uv.lock +++ b/uv.lock @@ -1,11 +1,8 @@ version = 1 -requires-python = ">=3.7" +requires-python = ">=3.9" resolution-markers = [ - "python_full_version == '3.8.*'", - "python_full_version < '3.8'", - "python_full_version == '3.9.*'", - "python_full_version == '3.10.*'", - "python_full_version >= '3.11'", + "python_full_version >= '3.10'", + "python_full_version < '3.10'", ] [[package]] @@ -22,10 +19,10 @@ name = "anyio" version = "4.6.2.post1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, - { name = "idna", marker = "python_full_version >= '3.9'" }, - { name = "sniffio", marker = "python_full_version >= '3.9'" }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/09/45b9b7a6d4e45c6bcb5bf61d19e3ab87df68e0601fa8c5293de3542546cc/anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c", size = 173422 } wheels = [ @@ -37,7 +34,7 @@ name = "asgiref" version = "3.8.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "typing-extensions", marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186 } wheels = [ @@ -48,20 +45,26 @@ wheels = [ name = "babel" version = "2.16.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytz", marker = "python_full_version == '3.8.*'" }, -] sdist = { url = "https://files.pythonhosted.org/packages/2a/74/f1bc80f23eeba13393b7222b11d95ca3af2c1e28edca18af487137eefed9/babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316", size = 9348104 } wheels = [ { url = "https://files.pythonhosted.org/packages/ed/20/bc79bc575ba2e2a7f70e8a1155618bb1301eaa5132a8271373a6903f73f8/babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b", size = 9587599 }, ] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313 }, +] + [[package]] name = "beautifulsoup4" version = "4.12.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "soupsieve", marker = "python_full_version >= '3.9'" }, + { name = "soupsieve" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/ca/824b1195773ce6166d388573fc106ce56d4a805bd7427b624e063596ec58/beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051", size = 581181 } wheels = [ @@ -152,34 +155,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/90/6af4cd042066a4adad58ae25648a12c09c879efa4849c705719ba1b23d8c/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482", size = 144970 }, { url = "https://files.pythonhosted.org/packages/cc/67/e5e7e0cbfefc4ca79025238b43cdf8a2037854195b37d6417f3d0895c4c2/charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", size = 94973 }, { url = "https://files.pythonhosted.org/packages/65/97/fc9bbc54ee13d33dc54a7fcf17b26368b18505500fc01e228c27b5222d80/charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", size = 102308 }, - { url = "https://files.pythonhosted.org/packages/28/9b/64f11b42d34a9f1fcd05827dd695e91e0b30ac35a9a7aaeb93e84d9f8c76/charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2", size = 121758 }, - { url = "https://files.pythonhosted.org/packages/1f/18/0fc3d61a244ffdee01374b751387f9154ecb8a4d5f931227ecfd31ab46f0/charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7", size = 134949 }, - { url = "https://files.pythonhosted.org/packages/3f/b5/354d544f60614aeb6bf73d3b6c955933e0af5eeaf2eec8c0637293a995bc/charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51", size = 144221 }, - { url = "https://files.pythonhosted.org/packages/93/5f/a2acc6e2a47d053760caece2d7b7194e9949945091eff452019765b87146/charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574", size = 137301 }, - { url = "https://files.pythonhosted.org/packages/27/9f/68c828438af904d830e680a8d2f6182be97f3205d6d62edfeb4a029b4192/charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf", size = 138257 }, - { url = "https://files.pythonhosted.org/packages/27/4b/522e1c868960b6be2f88cd407a284f99801421a6c5ae214f0c33de131fde/charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455", size = 140933 }, - { url = "https://files.pythonhosted.org/packages/03/f8/f9b90f5aed190d63e620d9f61db1cef5f30dbb19075e5c114329483d069a/charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6", size = 134954 }, - { url = "https://files.pythonhosted.org/packages/0f/8e/44cde4d583038cbe37867efe7af4699212e6104fca0e7fc010e5f3d4a5c9/charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748", size = 142158 }, - { url = "https://files.pythonhosted.org/packages/28/2c/c6c5b3d70a9e09fcde6b0901789d8c3c2f44ef12abae070a33a342d239a9/charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62", size = 145701 }, - { url = "https://files.pythonhosted.org/packages/1c/9d/fb7f6b68f88e8becca86eb7cba1d5a5429fbfaaa6dd7a3a9f62adaee44d3/charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4", size = 144846 }, - { url = "https://files.pythonhosted.org/packages/b2/8d/fb3d3d3d5a09202d7ef1983848b41a5928b0c907e5692a8d13b1c07a10de/charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621", size = 138254 }, - { url = "https://files.pythonhosted.org/packages/28/d3/efc854ab04626167ad1709e527c4f2a01f5e5cd9c1d5691094d1b7d49154/charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149", size = 93163 }, - { url = "https://files.pythonhosted.org/packages/b6/33/cf8f2602715863219804c1790374b611f08be515176229de078f807d71e3/charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee", size = 99861 }, - { url = "https://files.pythonhosted.org/packages/86/f4/ccab93e631e7293cca82f9f7ba39783c967f823a0000df2d8dd743cad74f/charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578", size = 193961 }, - { url = "https://files.pythonhosted.org/packages/94/d4/2b21cb277bac9605026d2d91a4a8872bc82199ed11072d035dc674c27223/charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6", size = 124507 }, - { url = "https://files.pythonhosted.org/packages/9a/e0/a7c1fcdff20d9c667342e0391cfeb33ab01468d7d276b2c7914b371667cc/charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417", size = 119298 }, - { url = "https://files.pythonhosted.org/packages/70/de/1538bb2f84ac9940f7fa39945a5dd1d22b295a89c98240b262fc4b9fcfe0/charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51", size = 139328 }, - { url = "https://files.pythonhosted.org/packages/e9/ca/288bb1a6bc2b74fb3990bdc515012b47c4bc5925c8304fc915d03f94b027/charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41", size = 149368 }, - { url = "https://files.pythonhosted.org/packages/aa/75/58374fdaaf8406f373e508dab3486a31091f760f99f832d3951ee93313e8/charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f", size = 141944 }, - { url = "https://files.pythonhosted.org/packages/32/c8/0bc558f7260db6ffca991ed7166494a7da4fda5983ee0b0bfc8ed2ac6ff9/charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8", size = 143326 }, - { url = "https://files.pythonhosted.org/packages/0e/dd/7f6fec09a1686446cee713f38cf7d5e0669e0bcc8288c8e2924e998cf87d/charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab", size = 146171 }, - { url = "https://files.pythonhosted.org/packages/4c/a8/440f1926d6d8740c34d3ca388fbd718191ec97d3d457a0677eb3aa718fce/charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12", size = 139711 }, - { url = "https://files.pythonhosted.org/packages/e9/7f/4b71e350a3377ddd70b980bea1e2cc0983faf45ba43032b24b2578c14314/charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19", size = 148348 }, - { url = "https://files.pythonhosted.org/packages/1e/70/17b1b9202531a33ed7ef41885f0d2575ae42a1e330c67fddda5d99ad1208/charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea", size = 151290 }, - { url = "https://files.pythonhosted.org/packages/44/30/574b5b5933d77ecb015550aafe1c7d14a8cd41e7e6c4dcea5ae9e8d496c3/charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858", size = 149114 }, - { url = "https://files.pythonhosted.org/packages/0b/11/ca7786f7e13708687443082af20d8341c02e01024275a28bc75032c5ce5d/charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654", size = 143856 }, - { url = "https://files.pythonhosted.org/packages/f9/c2/1727c1438256c71ed32753b23ec2e6fe7b6dff66a598f6566cfe8139305e/charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613", size = 94333 }, - { url = "https://files.pythonhosted.org/packages/09/c8/0e17270496a05839f8b500c1166e3261d1226e39b698a735805ec206967b/charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade", size = 101454 }, { url = "https://files.pythonhosted.org/packages/54/2f/28659eee7f5d003e0f5a3b572765bf76d6e0fe6601ab1f1b1dd4cba7e4f1/charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa", size = 196326 }, { url = "https://files.pythonhosted.org/packages/d1/18/92869d5c0057baa973a3ee2af71573be7b084b3c3d428fe6463ce71167f8/charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a", size = 125614 }, { url = "https://files.pythonhosted.org/packages/d6/27/327904c5a54a7796bb9f36810ec4173d2df5d88b401d2b95ef53111d214e/charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0", size = 120450 }, @@ -203,7 +178,7 @@ name = "click" version = "8.1.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.9' and sys_platform == 'win32'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } wheels = [ @@ -219,177 +194,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] -[[package]] -name = "coverage" -version = "7.2.7" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.8'", -] -sdist = { url = "https://files.pythonhosted.org/packages/45/8b/421f30467e69ac0e414214856798d4bc32da1336df745e49e49ae5c1e2a8/coverage-7.2.7.tar.gz", hash = "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59", size = 762575 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/24/be01e62a7bce89bcffe04729c540382caa5a06bee45ae42136c93e2499f5/coverage-7.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8", size = 200724 }, - { url = "https://files.pythonhosted.org/packages/3d/80/7060a445e1d2c9744b683dc935248613355657809d6c6b2716cdf4ca4766/coverage-7.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb", size = 201024 }, - { url = "https://files.pythonhosted.org/packages/b8/9d/926fce7e03dbfc653104c2d981c0fa71f0572a9ebd344d24c573bd6f7c4f/coverage-7.2.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6", size = 229528 }, - { url = "https://files.pythonhosted.org/packages/d1/3a/67f5d18f911abf96857f6f7e4df37ca840e38179e2cc9ab6c0b9c3380f19/coverage-7.2.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2", size = 227842 }, - { url = "https://files.pythonhosted.org/packages/b4/bd/1b2331e3a04f4cc9b7b332b1dd0f3a1261dfc4114f8479bebfcc2afee9e8/coverage-7.2.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063", size = 228717 }, - { url = "https://files.pythonhosted.org/packages/2b/86/3dbf9be43f8bf6a5ca28790a713e18902b2d884bc5fa9512823a81dff601/coverage-7.2.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1", size = 234632 }, - { url = "https://files.pythonhosted.org/packages/91/e8/469ed808a782b9e8305a08bad8c6fa5f8e73e093bda6546c5aec68275bff/coverage-7.2.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353", size = 232875 }, - { url = "https://files.pythonhosted.org/packages/29/8f/4fad1c2ba98104425009efd7eaa19af9a7c797e92d40cd2ec026fa1f58cb/coverage-7.2.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495", size = 234094 }, - { url = "https://files.pythonhosted.org/packages/94/4e/d4e46a214ae857be3d7dc5de248ba43765f60daeb1ab077cb6c1536c7fba/coverage-7.2.7-cp310-cp310-win32.whl", hash = "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818", size = 203184 }, - { url = "https://files.pythonhosted.org/packages/1f/e9/d6730247d8dec2a3dddc520ebe11e2e860f0f98cee3639e23de6cf920255/coverage-7.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850", size = 204096 }, - { url = "https://files.pythonhosted.org/packages/c6/fa/529f55c9a1029c840bcc9109d5a15ff00478b7ff550a1ae361f8745f8ad5/coverage-7.2.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f", size = 200895 }, - { url = "https://files.pythonhosted.org/packages/67/d7/cd8fe689b5743fffac516597a1222834c42b80686b99f5b44ef43ccc2a43/coverage-7.2.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe", size = 201120 }, - { url = "https://files.pythonhosted.org/packages/8c/95/16eed713202406ca0a37f8ac259bbf144c9d24f9b8097a8e6ead61da2dbb/coverage-7.2.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3", size = 233178 }, - { url = "https://files.pythonhosted.org/packages/c1/49/4d487e2ad5d54ed82ac1101e467e8994c09d6123c91b2a962145f3d262c2/coverage-7.2.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f", size = 230754 }, - { url = "https://files.pythonhosted.org/packages/a7/cd/3ce94ad9d407a052dc2a74fbeb1c7947f442155b28264eb467ee78dea812/coverage-7.2.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb", size = 232558 }, - { url = "https://files.pythonhosted.org/packages/8f/a8/12cc7b261f3082cc299ab61f677f7e48d93e35ca5c3c2f7241ed5525ccea/coverage-7.2.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833", size = 241509 }, - { url = "https://files.pythonhosted.org/packages/04/fa/43b55101f75a5e9115259e8be70ff9279921cb6b17f04c34a5702ff9b1f7/coverage-7.2.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97", size = 239924 }, - { url = "https://files.pythonhosted.org/packages/68/5f/d2bd0f02aa3c3e0311986e625ccf97fdc511b52f4f1a063e4f37b624772f/coverage-7.2.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a", size = 240977 }, - { url = "https://files.pythonhosted.org/packages/ba/92/69c0722882643df4257ecc5437b83f4c17ba9e67f15dc6b77bad89b6982e/coverage-7.2.7-cp311-cp311-win32.whl", hash = "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a", size = 203168 }, - { url = "https://files.pythonhosted.org/packages/b1/96/c12ed0dfd4ec587f3739f53eb677b9007853fd486ccb0e7d5512a27bab2e/coverage-7.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562", size = 204185 }, - { url = "https://files.pythonhosted.org/packages/ff/d5/52fa1891d1802ab2e1b346d37d349cb41cdd4fd03f724ebbf94e80577687/coverage-7.2.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4", size = 201020 }, - { url = "https://files.pythonhosted.org/packages/24/df/6765898d54ea20e3197a26d26bb65b084deefadd77ce7de946b9c96dfdc5/coverage-7.2.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4", size = 233994 }, - { url = "https://files.pythonhosted.org/packages/15/81/b108a60bc758b448c151e5abceed027ed77a9523ecbc6b8a390938301841/coverage-7.2.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01", size = 231358 }, - { url = "https://files.pythonhosted.org/packages/61/90/c76b9462f39897ebd8714faf21bc985b65c4e1ea6dff428ea9dc711ed0dd/coverage-7.2.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6", size = 233316 }, - { url = "https://files.pythonhosted.org/packages/04/d6/8cba3bf346e8b1a4fb3f084df7d8cea25a6b6c56aaca1f2e53829be17e9e/coverage-7.2.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d", size = 240159 }, - { url = "https://files.pythonhosted.org/packages/6e/ea/4a252dc77ca0605b23d477729d139915e753ee89e4c9507630e12ad64a80/coverage-7.2.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de", size = 238127 }, - { url = "https://files.pythonhosted.org/packages/9f/5c/d9760ac497c41f9c4841f5972d0edf05d50cad7814e86ee7d133ec4a0ac8/coverage-7.2.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d", size = 239833 }, - { url = "https://files.pythonhosted.org/packages/69/8c/26a95b08059db1cbb01e4b0e6d40f2e9debb628c6ca86b78f625ceaf9bab/coverage-7.2.7-cp312-cp312-win32.whl", hash = "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511", size = 203463 }, - { url = "https://files.pythonhosted.org/packages/b7/00/14b00a0748e9eda26e97be07a63cc911108844004687321ddcc213be956c/coverage-7.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3", size = 204347 }, - { url = "https://files.pythonhosted.org/packages/80/d7/67937c80b8fd4c909fdac29292bc8b35d9505312cff6bcab41c53c5b1df6/coverage-7.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f", size = 200580 }, - { url = "https://files.pythonhosted.org/packages/7a/05/084864fa4bbf8106f44fb72a56e67e0cd372d3bf9d893be818338c81af5d/coverage-7.2.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb", size = 226237 }, - { url = "https://files.pythonhosted.org/packages/67/a2/6fa66a50e6e894286d79a3564f42bd54a9bd27049dc0a63b26d9924f0aa3/coverage-7.2.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9", size = 224256 }, - { url = "https://files.pythonhosted.org/packages/e2/c0/73f139794c742840b9ab88e2e17fe14a3d4668a166ff95d812ac66c0829d/coverage-7.2.7-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd", size = 225550 }, - { url = "https://files.pythonhosted.org/packages/03/ec/6f30b4e0c96ce03b0e64aec46b4af2a8c49b70d1b5d0d69577add757b946/coverage-7.2.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a", size = 232440 }, - { url = "https://files.pythonhosted.org/packages/22/c1/2f6c1b6f01a0996c9e067a9c780e1824351dbe17faae54388a4477e6d86f/coverage-7.2.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959", size = 230897 }, - { url = "https://files.pythonhosted.org/packages/8d/d6/53e999ec1bf7498ca4bc5f3b8227eb61db39068d2de5dcc359dec5601b5a/coverage-7.2.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02", size = 232024 }, - { url = "https://files.pythonhosted.org/packages/e9/40/383305500d24122dbed73e505a4d6828f8f3356d1f68ab6d32c781754b81/coverage-7.2.7-cp37-cp37m-win32.whl", hash = "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f", size = 203293 }, - { url = "https://files.pythonhosted.org/packages/0e/bc/7e3a31534fabb043269f14fb64e2bb2733f85d4cf39e5bbc71357c57553a/coverage-7.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0", size = 204040 }, - { url = "https://files.pythonhosted.org/packages/c6/fc/be19131010930a6cf271da48202c8cc1d3f971f68c02fb2d3a78247f43dc/coverage-7.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5", size = 200689 }, - { url = "https://files.pythonhosted.org/packages/28/d7/9a8de57d87f4bbc6f9a6a5ded1eaac88a89bf71369bb935dac3c0cf2893e/coverage-7.2.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5", size = 200986 }, - { url = "https://files.pythonhosted.org/packages/c8/e4/e6182e4697665fb594a7f4e4f27cb3a4dd00c2e3d35c5c706765de8c7866/coverage-7.2.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9", size = 230648 }, - { url = "https://files.pythonhosted.org/packages/7b/e3/f552d5871943f747165b92a924055c5d6daa164ae659a13f9018e22f3990/coverage-7.2.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6", size = 228511 }, - { url = "https://files.pythonhosted.org/packages/44/55/49f65ccdd4dfd6d5528e966b28c37caec64170c725af32ab312889d2f857/coverage-7.2.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e", size = 229852 }, - { url = "https://files.pythonhosted.org/packages/0d/31/340428c238eb506feb96d4fb5c9ea614db1149517f22cc7ab8c6035ef6d9/coverage-7.2.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050", size = 235578 }, - { url = "https://files.pythonhosted.org/packages/dd/ce/97c1dd6592c908425622fe7f31c017d11cf0421729b09101d4de75bcadc8/coverage-7.2.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5", size = 234079 }, - { url = "https://files.pythonhosted.org/packages/de/a3/5a98dc9e239d0dc5f243ef5053d5b1bdcaa1dee27a691dfc12befeccf878/coverage-7.2.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f", size = 234991 }, - { url = "https://files.pythonhosted.org/packages/4a/fb/78986d3022e5ccf2d4370bc43a5fef8374f092b3c21d32499dee8e30b7b6/coverage-7.2.7-cp38-cp38-win32.whl", hash = "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e", size = 203160 }, - { url = "https://files.pythonhosted.org/packages/c3/1c/6b3c9c363fb1433c79128e0d692863deb761b1b78162494abb9e5c328bc0/coverage-7.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c", size = 204085 }, - { url = "https://files.pythonhosted.org/packages/88/da/495944ebf0ad246235a6bd523810d9f81981f9b81c6059ba1f56e943abe0/coverage-7.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9", size = 200725 }, - { url = "https://files.pythonhosted.org/packages/ca/0c/3dfeeb1006c44b911ee0ed915350db30325d01808525ae7cc8d57643a2ce/coverage-7.2.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2", size = 201022 }, - { url = "https://files.pythonhosted.org/packages/61/af/5964b8d7d9a5c767785644d9a5a63cacba9a9c45cc42ba06d25895ec87be/coverage-7.2.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7", size = 229102 }, - { url = "https://files.pythonhosted.org/packages/d9/1d/cd467fceb62c371f9adb1d739c92a05d4e550246daa90412e711226bd320/coverage-7.2.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e", size = 227441 }, - { url = "https://files.pythonhosted.org/packages/fe/57/e4f8ad64d84ca9e759d783a052795f62a9f9111585e46068845b1cb52c2b/coverage-7.2.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1", size = 228265 }, - { url = "https://files.pythonhosted.org/packages/88/8b/b0d9fe727acae907fa7f1c8194ccb6fe9d02e1c3e9001ecf74c741f86110/coverage-7.2.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9", size = 234217 }, - { url = "https://files.pythonhosted.org/packages/66/2e/c99fe1f6396d93551aa352c75410686e726cd4ea104479b9af1af22367ce/coverage-7.2.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250", size = 232466 }, - { url = "https://files.pythonhosted.org/packages/bb/e9/88747b40c8fb4a783b40222510ce6d66170217eb05d7f46462c36b4fa8cc/coverage-7.2.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2", size = 233669 }, - { url = "https://files.pythonhosted.org/packages/b1/d5/a8e276bc005e42114468d4fe03e0a9555786bc51cbfe0d20827a46c1565a/coverage-7.2.7-cp39-cp39-win32.whl", hash = "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb", size = 203199 }, - { url = "https://files.pythonhosted.org/packages/a9/0c/4a848ae663b47f1195abcb09a951751dd61f80b503303b9b9d768e0fd321/coverage-7.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27", size = 204109 }, - { url = "https://files.pythonhosted.org/packages/67/fb/b3b1d7887e1ea25a9608b0776e480e4bbc303ca95a31fd585555ec4fff5a/coverage-7.2.7-pp37.pp38.pp39-none-any.whl", hash = "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d", size = 193207 }, -] - -[package.optional-dependencies] -toml = [ - { name = "tomli", marker = "python_full_version < '3.8'" }, -] - -[[package]] -name = "coverage" -version = "7.6.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.8.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/f7/08/7e37f82e4d1aead42a7443ff06a1e406aabf7302c4f00a546e4b320b994c/coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d", size = 798791 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/61/eb7ce5ed62bacf21beca4937a90fe32545c91a3c8a42a30c6616d48fc70d/coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16", size = 206690 }, - { url = "https://files.pythonhosted.org/packages/7d/73/041928e434442bd3afde5584bdc3f932fb4562b1597629f537387cec6f3d/coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36", size = 207127 }, - { url = "https://files.pythonhosted.org/packages/c7/c8/6ca52b5147828e45ad0242388477fdb90df2c6cbb9a441701a12b3c71bc8/coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02", size = 235654 }, - { url = "https://files.pythonhosted.org/packages/d5/da/9ac2b62557f4340270942011d6efeab9833648380109e897d48ab7c1035d/coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc", size = 233598 }, - { url = "https://files.pythonhosted.org/packages/53/23/9e2c114d0178abc42b6d8d5281f651a8e6519abfa0ef460a00a91f80879d/coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23", size = 234732 }, - { url = "https://files.pythonhosted.org/packages/0f/7e/a0230756fb133343a52716e8b855045f13342b70e48e8ad41d8a0d60ab98/coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34", size = 233816 }, - { url = "https://files.pythonhosted.org/packages/28/7c/3753c8b40d232b1e5eeaed798c875537cf3cb183fb5041017c1fdb7ec14e/coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c", size = 232325 }, - { url = "https://files.pythonhosted.org/packages/57/e3/818a2b2af5b7573b4b82cf3e9f137ab158c90ea750a8f053716a32f20f06/coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959", size = 233418 }, - { url = "https://files.pythonhosted.org/packages/c8/fb/4532b0b0cefb3f06d201648715e03b0feb822907edab3935112b61b885e2/coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232", size = 209343 }, - { url = "https://files.pythonhosted.org/packages/5a/25/af337cc7421eca1c187cc9c315f0a755d48e755d2853715bfe8c418a45fa/coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0", size = 210136 }, - { url = "https://files.pythonhosted.org/packages/ad/5f/67af7d60d7e8ce61a4e2ddcd1bd5fb787180c8d0ae0fbd073f903b3dd95d/coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93", size = 206796 }, - { url = "https://files.pythonhosted.org/packages/e1/0e/e52332389e057daa2e03be1fbfef25bb4d626b37d12ed42ae6281d0a274c/coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3", size = 207244 }, - { url = "https://files.pythonhosted.org/packages/aa/cd/766b45fb6e090f20f8927d9c7cb34237d41c73a939358bc881883fd3a40d/coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff", size = 239279 }, - { url = "https://files.pythonhosted.org/packages/70/6c/a9ccd6fe50ddaf13442a1e2dd519ca805cbe0f1fcd377fba6d8339b98ccb/coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d", size = 236859 }, - { url = "https://files.pythonhosted.org/packages/14/6f/8351b465febb4dbc1ca9929505202db909c5a635c6fdf33e089bbc3d7d85/coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6", size = 238549 }, - { url = "https://files.pythonhosted.org/packages/68/3c/289b81fa18ad72138e6d78c4c11a82b5378a312c0e467e2f6b495c260907/coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56", size = 237477 }, - { url = "https://files.pythonhosted.org/packages/ed/1c/aa1efa6459d822bd72c4abc0b9418cf268de3f60eeccd65dc4988553bd8d/coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234", size = 236134 }, - { url = "https://files.pythonhosted.org/packages/fb/c8/521c698f2d2796565fe9c789c2ee1ccdae610b3aa20b9b2ef980cc253640/coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133", size = 236910 }, - { url = "https://files.pythonhosted.org/packages/7d/30/033e663399ff17dca90d793ee8a2ea2890e7fdf085da58d82468b4220bf7/coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c", size = 209348 }, - { url = "https://files.pythonhosted.org/packages/20/05/0d1ccbb52727ccdadaa3ff37e4d2dc1cd4d47f0c3df9eb58d9ec8508ca88/coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6", size = 210230 }, - { url = "https://files.pythonhosted.org/packages/7e/d4/300fc921dff243cd518c7db3a4c614b7e4b2431b0d1145c1e274fd99bd70/coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778", size = 206983 }, - { url = "https://files.pythonhosted.org/packages/e1/ab/6bf00de5327ecb8db205f9ae596885417a31535eeda6e7b99463108782e1/coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391", size = 207221 }, - { url = "https://files.pythonhosted.org/packages/92/8f/2ead05e735022d1a7f3a0a683ac7f737de14850395a826192f0288703472/coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8", size = 240342 }, - { url = "https://files.pythonhosted.org/packages/0f/ef/94043e478201ffa85b8ae2d2c79b4081e5a1b73438aafafccf3e9bafb6b5/coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d", size = 237371 }, - { url = "https://files.pythonhosted.org/packages/1f/0f/c890339dd605f3ebc269543247bdd43b703cce6825b5ed42ff5f2d6122c7/coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca", size = 239455 }, - { url = "https://files.pythonhosted.org/packages/d1/04/7fd7b39ec7372a04efb0f70c70e35857a99b6a9188b5205efb4c77d6a57a/coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163", size = 238924 }, - { url = "https://files.pythonhosted.org/packages/ed/bf/73ce346a9d32a09cf369f14d2a06651329c984e106f5992c89579d25b27e/coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a", size = 237252 }, - { url = "https://files.pythonhosted.org/packages/86/74/1dc7a20969725e917b1e07fe71a955eb34bc606b938316bcc799f228374b/coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d", size = 238897 }, - { url = "https://files.pythonhosted.org/packages/b6/e9/d9cc3deceb361c491b81005c668578b0dfa51eed02cd081620e9a62f24ec/coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5", size = 209606 }, - { url = "https://files.pythonhosted.org/packages/47/c8/5a2e41922ea6740f77d555c4d47544acd7dc3f251fe14199c09c0f5958d3/coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb", size = 210373 }, - { url = "https://files.pythonhosted.org/packages/8c/f9/9aa4dfb751cb01c949c990d136a0f92027fbcc5781c6e921df1cb1563f20/coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106", size = 207007 }, - { url = "https://files.pythonhosted.org/packages/b9/67/e1413d5a8591622a46dd04ff80873b04c849268831ed5c304c16433e7e30/coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9", size = 207269 }, - { url = "https://files.pythonhosted.org/packages/14/5b/9dec847b305e44a5634d0fb8498d135ab1d88330482b74065fcec0622224/coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c", size = 239886 }, - { url = "https://files.pythonhosted.org/packages/7b/b7/35760a67c168e29f454928f51f970342d23cf75a2bb0323e0f07334c85f3/coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a", size = 237037 }, - { url = "https://files.pythonhosted.org/packages/f7/95/d2fd31f1d638df806cae59d7daea5abf2b15b5234016a5ebb502c2f3f7ee/coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060", size = 239038 }, - { url = "https://files.pythonhosted.org/packages/6e/bd/110689ff5752b67924efd5e2aedf5190cbbe245fc81b8dec1abaffba619d/coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862", size = 238690 }, - { url = "https://files.pythonhosted.org/packages/d3/a8/08d7b38e6ff8df52331c83130d0ab92d9c9a8b5462f9e99c9f051a4ae206/coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388", size = 236765 }, - { url = "https://files.pythonhosted.org/packages/d6/6a/9cf96839d3147d55ae713eb2d877f4d777e7dc5ba2bce227167d0118dfe8/coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155", size = 238611 }, - { url = "https://files.pythonhosted.org/packages/74/e4/7ff20d6a0b59eeaab40b3140a71e38cf52547ba21dbcf1d79c5a32bba61b/coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a", size = 209671 }, - { url = "https://files.pythonhosted.org/packages/35/59/1812f08a85b57c9fdb6d0b383d779e47b6f643bc278ed682859512517e83/coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129", size = 210368 }, - { url = "https://files.pythonhosted.org/packages/9c/15/08913be1c59d7562a3e39fce20661a98c0a3f59d5754312899acc6cb8a2d/coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e", size = 207758 }, - { url = "https://files.pythonhosted.org/packages/c4/ae/b5d58dff26cade02ada6ca612a76447acd69dccdbb3a478e9e088eb3d4b9/coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962", size = 208035 }, - { url = "https://files.pythonhosted.org/packages/b8/d7/62095e355ec0613b08dfb19206ce3033a0eedb6f4a67af5ed267a8800642/coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb", size = 250839 }, - { url = "https://files.pythonhosted.org/packages/7c/1e/c2967cb7991b112ba3766df0d9c21de46b476d103e32bb401b1b2adf3380/coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704", size = 246569 }, - { url = "https://files.pythonhosted.org/packages/8b/61/a7a6a55dd266007ed3b1df7a3386a0d760d014542d72f7c2c6938483b7bd/coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b", size = 248927 }, - { url = "https://files.pythonhosted.org/packages/c8/fa/13a6f56d72b429f56ef612eb3bc5ce1b75b7ee12864b3bd12526ab794847/coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f", size = 248401 }, - { url = "https://files.pythonhosted.org/packages/75/06/0429c652aa0fb761fc60e8c6b291338c9173c6aa0f4e40e1902345b42830/coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223", size = 246301 }, - { url = "https://files.pythonhosted.org/packages/52/76/1766bb8b803a88f93c3a2d07e30ffa359467810e5cbc68e375ebe6906efb/coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3", size = 247598 }, - { url = "https://files.pythonhosted.org/packages/66/8b/f54f8db2ae17188be9566e8166ac6df105c1c611e25da755738025708d54/coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f", size = 210307 }, - { url = "https://files.pythonhosted.org/packages/9f/b0/e0dca6da9170aefc07515cce067b97178cefafb512d00a87a1c717d2efd5/coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657", size = 211453 }, - { url = "https://files.pythonhosted.org/packages/81/d0/d9e3d554e38beea5a2e22178ddb16587dbcbe9a1ef3211f55733924bf7fa/coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0", size = 206674 }, - { url = "https://files.pythonhosted.org/packages/38/ea/cab2dc248d9f45b2b7f9f1f596a4d75a435cb364437c61b51d2eb33ceb0e/coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a", size = 207101 }, - { url = "https://files.pythonhosted.org/packages/ca/6f/f82f9a500c7c5722368978a5390c418d2a4d083ef955309a8748ecaa8920/coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b", size = 236554 }, - { url = "https://files.pythonhosted.org/packages/a6/94/d3055aa33d4e7e733d8fa309d9adf147b4b06a82c1346366fc15a2b1d5fa/coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3", size = 234440 }, - { url = "https://files.pythonhosted.org/packages/e4/6e/885bcd787d9dd674de4a7d8ec83faf729534c63d05d51d45d4fa168f7102/coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de", size = 235889 }, - { url = "https://files.pythonhosted.org/packages/f4/63/df50120a7744492710854860783d6819ff23e482dee15462c9a833cc428a/coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6", size = 235142 }, - { url = "https://files.pythonhosted.org/packages/3a/5d/9d0acfcded2b3e9ce1c7923ca52ccc00c78a74e112fc2aee661125b7843b/coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569", size = 233805 }, - { url = "https://files.pythonhosted.org/packages/c4/56/50abf070cb3cd9b1dd32f2c88f083aab561ecbffbcd783275cb51c17f11d/coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989", size = 234655 }, - { url = "https://files.pythonhosted.org/packages/25/ee/b4c246048b8485f85a2426ef4abab88e48c6e80c74e964bea5cd4cd4b115/coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7", size = 209296 }, - { url = "https://files.pythonhosted.org/packages/5c/1c/96cf86b70b69ea2b12924cdf7cabb8ad10e6130eab8d767a1099fbd2a44f/coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8", size = 210137 }, - { url = "https://files.pythonhosted.org/packages/19/d3/d54c5aa83268779d54c86deb39c1c4566e5d45c155369ca152765f8db413/coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255", size = 206688 }, - { url = "https://files.pythonhosted.org/packages/a5/fe/137d5dca72e4a258b1bc17bb04f2e0196898fe495843402ce826a7419fe3/coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8", size = 207120 }, - { url = "https://files.pythonhosted.org/packages/78/5b/a0a796983f3201ff5485323b225d7c8b74ce30c11f456017e23d8e8d1945/coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2", size = 235249 }, - { url = "https://files.pythonhosted.org/packages/4e/e1/76089d6a5ef9d68f018f65411fcdaaeb0141b504587b901d74e8587606ad/coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a", size = 233237 }, - { url = "https://files.pythonhosted.org/packages/9a/6f/eef79b779a540326fee9520e5542a8b428cc3bfa8b7c8f1022c1ee4fc66c/coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc", size = 234311 }, - { url = "https://files.pythonhosted.org/packages/75/e1/656d65fb126c29a494ef964005702b012f3498db1a30dd562958e85a4049/coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004", size = 233453 }, - { url = "https://files.pythonhosted.org/packages/68/6a/45f108f137941a4a1238c85f28fd9d048cc46b5466d6b8dda3aba1bb9d4f/coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb", size = 231958 }, - { url = "https://files.pythonhosted.org/packages/9b/e7/47b809099168b8b8c72ae311efc3e88c8d8a1162b3ba4b8da3cfcdb85743/coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36", size = 232938 }, - { url = "https://files.pythonhosted.org/packages/52/80/052222ba7058071f905435bad0ba392cc12006380731c37afaf3fe749b88/coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c", size = 209352 }, - { url = "https://files.pythonhosted.org/packages/b8/d8/1b92e0b3adcf384e98770a00ca095da1b5f7b483e6563ae4eb5e935d24a1/coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca", size = 210153 }, - { url = "https://files.pythonhosted.org/packages/a5/2b/0354ed096bca64dc8e32a7cbcae28b34cb5ad0b1fe2125d6d99583313ac0/coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df", size = 198926 }, -] - -[package.optional-dependencies] -toml = [ - { name = "tomli", marker = "python_full_version == '3.8.*'" }, -] - [[package]] name = "coverage" version = "7.10.7" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.9.*'", - "python_full_version == '3.10.*'", - "python_full_version >= '3.11'", -] sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704 } wheels = [ { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987 }, @@ -499,7 +307,7 @@ wheels = [ [package.optional-dependencies] toml = [ - { name = "tomli", marker = "python_full_version >= '3.9' and python_full_version <= '3.11'" }, + { name = "tomli", marker = "python_full_version <= '3.11'" }, ] [[package]] @@ -544,28 +352,24 @@ wheels = [ ] [[package]] -name = "filelock" -version = "3.12.2" +name = "execnet" +version = "2.1.2" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.8'", -] -sdist = { url = "https://files.pythonhosted.org/packages/00/0b/c506e9e44e4c4b6c89fcecda23dc115bf8e7ff7eb127e0cb9c114cbc9a15/filelock-3.12.2.tar.gz", hash = "sha256:002740518d8aa59a26b0c76e10fb8c6e15eae825d34b6fdf670333fd7b938d81", size = 12441 } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622 } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/45/ec3407adf6f6b5bf867a4462b2b0af27597a26bd3cd6e2534cb6ab029938/filelock-3.12.2-py3-none-any.whl", hash = "sha256:cbb791cdea2a72f23da6ac5b5269ab0a0d161e9ef0100e653b69049a7706d1ec", size = 10923 }, + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708 }, ] [[package]] name = "filelock" -version = "3.16.1" +version = "3.12.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.8.*'", - "python_full_version == '3.9.*'", + "python_full_version < '3.10'", ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/db/3ef5bb276dae18d6ec2124224403d1d67bccdbefc17af4cc8f553e341ab1/filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435", size = 18037 } +sdist = { url = "https://files.pythonhosted.org/packages/00/0b/c506e9e44e4c4b6c89fcecda23dc115bf8e7ff7eb127e0cb9c114cbc9a15/filelock-3.12.2.tar.gz", hash = "sha256:002740518d8aa59a26b0c76e10fb8c6e15eae825d34b6fdf670333fd7b938d81", size = 12441 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163 }, + { url = "https://files.pythonhosted.org/packages/00/45/ec3407adf6f6b5bf867a4462b2b0af27597a26bd3cd6e2534cb6ab029938/filelock-3.12.2-py3-none-any.whl", hash = "sha256:cbb791cdea2a72f23da6ac5b5269ab0a0d161e9ef0100e653b69049a7706d1ec", size = 10923 }, ] [[package]] @@ -573,8 +377,7 @@ name = "filelock" version = "3.20.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.10.*'", - "python_full_version >= '3.11'", + "python_full_version >= '3.10'", ] sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485 } wheels = [ @@ -586,10 +389,10 @@ name = "furo" version = "2024.8.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "beautifulsoup4", marker = "python_full_version >= '3.9'" }, - { name = "pygments", marker = "python_full_version >= '3.9'" }, - { name = "sphinx", marker = "python_full_version >= '3.9'" }, - { name = "sphinx-basic-ng", marker = "python_full_version >= '3.9'" }, + { name = "beautifulsoup4" }, + { name = "pygments" }, + { name = "sphinx" }, + { name = "sphinx-basic-ng" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a0/e2/d351d69a9a9e4badb4a5be062c2d0e87bd9e6c23b5e57337fef14bef34c8/furo-2024.8.6.tar.gz", hash = "sha256:b63e4cee8abfc3136d3bc03a3d45a76a850bada4d6374d24c1716b0e01394a01", size = 1661506 } wheels = [ @@ -637,8 +440,7 @@ name = "importlib-metadata" version = "6.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", version = "4.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, - { name = "zipp", marker = "python_full_version < '3.8' or python_full_version == '3.9.*'" }, + { name = "zipp", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/82/f6e29c8d5c098b6be61460371c2c5591f4a335923639edec43b3830650a4/importlib_metadata-6.7.0.tar.gz", hash = "sha256:1aaf550d4f73e5d6783e7acb77aec43d49da8017410afae93822cc9cca98c4d4", size = 53569 } wheels = [ @@ -659,7 +461,7 @@ name = "jinja2" version = "3.1.6" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markupsafe", marker = "python_full_version >= '3.9'" }, + { name = "markupsafe" }, ] sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } wheels = [ @@ -671,7 +473,7 @@ name = "markdown-it-py" version = "3.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mdurl", marker = "python_full_version >= '3.9'" }, + { name = "mdurl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } wheels = [ @@ -751,7 +553,7 @@ name = "mdit-py-plugins" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py", marker = "python_full_version >= '3.9'" }, + { name = "markdown-it-py" }, ] sdist = { url = "https://files.pythonhosted.org/packages/19/03/a2ecab526543b152300717cf232bb4bb8605b6edb946c845016fa9c9c9fd/mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5", size = 43542 } wheels = [ @@ -772,13 +574,12 @@ name = "mypy" version = "1.4.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.8'", + "python_full_version < '3.10'", ] dependencies = [ - { name = "mypy-extensions", marker = "python_full_version < '3.8'" }, - { name = "tomli", marker = "python_full_version < '3.8'" }, - { name = "typed-ast", marker = "python_full_version < '3.8'" }, - { name = "typing-extensions", version = "4.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, + { name = "mypy-extensions", marker = "python_full_version < '3.10'" }, + { name = "tomli", marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/28/d8a8233ff167d06108e53b7aefb4a8d7350adbbf9d7abd980f17fdb7a3a6/mypy-1.4.1.tar.gz", hash = "sha256:9bbcd9ab8ea1f2e1c8031c21445b511442cc45c89951e49bbf852cbb70755b1b", size = 2855162 } wheels = [ @@ -792,15 +593,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/72/dfc0b46e6905eafd598e7c48c0c4f2e232647e4e36547425c64e6c850495/mypy-1.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141dedfdbfe8a04142881ff30ce6e6653c9685b354876b12e4fe6c78598b45e2", size = 11855450 }, { url = "https://files.pythonhosted.org/packages/66/f4/60739a2d336f3adf5628e7c9b920d16e8af6dc078550d615e4ba2a1d7759/mypy-1.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8207b7105829eca6f3d774f64a904190bb2231de91b8b186d21ffd98005f14a7", size = 11928679 }, { url = "https://files.pythonhosted.org/packages/8c/26/6ff2b55bf8b605a4cc898883654c2ca4dd4feedf0bb04ecaacf60d165cde/mypy-1.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:16f0db5b641ba159eff72cff08edc3875f2b62b2fa2bc24f68c1e7a4e8232d01", size = 8831134 }, - { url = "https://files.pythonhosted.org/packages/95/47/fb69dad9634af9f1dab69f8b4031d674592384b59c7171852b1fbed6de15/mypy-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:470c969bb3f9a9efcedbadcd19a74ffb34a25f8e6b0e02dae7c0e71f8372f97b", size = 10101278 }, - { url = "https://files.pythonhosted.org/packages/65/f7/77339904a3415cadca5551f2ea0c74feefc9b7187636a292690788f4d4b3/mypy-1.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5952d2d18b79f7dc25e62e014fe5a23eb1a3d2bc66318df8988a01b1a037c5b", size = 11643877 }, - { url = "https://files.pythonhosted.org/packages/f5/93/ae39163ae84266d24d1fcf8ee1e2db1e0346e09de97570dd101a07ccf876/mypy-1.4.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:190b6bab0302cec4e9e6767d3eb66085aef2a1cc98fe04936d8a42ed2ba77bb7", size = 11702718 }, - { url = "https://files.pythonhosted.org/packages/13/3b/3b7de921626547b36c34b91c74cfbda260210df7c49bd3d315015cfd6005/mypy-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9d40652cc4fe33871ad3338581dca3297ff5f2213d0df345bcfbde5162abf0c9", size = 8551181 }, - { url = "https://files.pythonhosted.org/packages/49/7d/63bab763e4d44e1a7c341fb64496ddf20970780935596ffed9ed2d85eae7/mypy-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:01fd2e9f85622d981fd9063bfaef1aed6e336eaacca00892cd2d82801ab7c042", size = 10390236 }, - { url = "https://files.pythonhosted.org/packages/23/3f/54a87d933440416a1efd7a42b45f8cf22e353efe889eb3903cc34177ab44/mypy-1.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2460a58faeea905aeb1b9b36f5065f2dc9a9c6e4c992a6499a2360c6c74ceca3", size = 9496760 }, - { url = "https://files.pythonhosted.org/packages/4e/89/26230b46e27724bd54f76cd73a2759eaaf35292b32ba64f36c7c47836d4b/mypy-1.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2746d69a8196698146a3dbe29104f9eb6a2a4d8a27878d92169a6c0b74435b6", size = 11927489 }, - { url = "https://files.pythonhosted.org/packages/64/7d/156e721376951c449554942eedf4d53e9ca2a57e94bf0833ad2821d59bfa/mypy-1.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ae704dcfaa180ff7c4cfbad23e74321a2b774f92ca77fd94ce1049175a21c97f", size = 11990009 }, - { url = "https://files.pythonhosted.org/packages/27/ab/21230851e8137c9ef9a095cc8cb70d8ff8cac21014e4b249ac7a9eae7df9/mypy-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:43d24f6437925ce50139a310a64b2ab048cb2d3694c84c71c3f2a1626d8101dc", size = 8816535 }, { url = "https://files.pythonhosted.org/packages/1d/1b/9050b5c444ef82c3d59bdbf21f91b259cf20b2ac1df37d55bc6b91d609a1/mypy-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c482e1246726616088532b5e964e39765b6d1520791348e6c9dc3af25b233828", size = 10447897 }, { url = "https://files.pythonhosted.org/packages/da/00/ac2b58b321d85cac25be0dcd1bc2427dfc6cf403283fc205a0031576f14b/mypy-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:43b592511672017f5b1a483527fd2684347fdffc041c9ef53428c8dc530f79a3", size = 9534091 }, { url = "https://files.pythonhosted.org/packages/c4/10/26240f14e854a95af87d577b288d607ebe0ccb75cb37052f6386402f022d/mypy-1.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34a9239d5b3502c17f07fd7c0b2ae6b7dd7d7f6af35fbb5072c6208e76295816", size = 11970165 }, @@ -814,15 +606,12 @@ name = "mypy" version = "1.14.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.8.*'", - "python_full_version == '3.9.*'", - "python_full_version == '3.10.*'", - "python_full_version >= '3.11'", + "python_full_version >= '3.10'", ] dependencies = [ - { name = "mypy-extensions", marker = "python_full_version >= '3.8'" }, - { name = "tomli", marker = "python_full_version >= '3.8' and python_full_version < '3.11'" }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8'" }, + { name = "mypy-extensions", marker = "python_full_version >= '3.10'" }, + { name = "tomli", marker = "python_full_version == '3.10.*'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/eb/2c92d8ea1e684440f54fa49ac5d9a5f19967b7b472a281f419e69a8d228e/mypy-1.14.1.tar.gz", hash = "sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6", size = 3216051 } wheels = [ @@ -850,12 +639,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/9d/4a236b9c57f5d8f08ed346914b3f091a62dd7e19336b2b2a0d85485f82ff/mypy-1.14.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4b19b03fdf54f3c5b2fa474c56b4c13c9dbfb9a2db4370ede7ec11a2c5927d9", size = 12867660 }, { url = "https://files.pythonhosted.org/packages/40/88/a61a5497e2f68d9027de2bb139c7bb9abaeb1be1584649fa9d807f80a338/mypy-1.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c911fde686394753fff899c409fd4e16e9b294c24bfd5e1ea4675deae1ac6fd", size = 12969198 }, { url = "https://files.pythonhosted.org/packages/54/da/3d6fc5d92d324701b0c23fb413c853892bfe0e1dbe06c9138037d459756b/mypy-1.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:8b21525cb51671219f5307be85f7e646a153e5acc656e5cebf64bfa076c50107", size = 9885276 }, - { url = "https://files.pythonhosted.org/packages/39/02/1817328c1372be57c16148ce7d2bfcfa4a796bedaed897381b1aad9b267c/mypy-1.14.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7084fb8f1128c76cd9cf68fe5971b37072598e7c31b2f9f95586b65c741a9d31", size = 11143050 }, - { url = "https://files.pythonhosted.org/packages/b9/07/99db9a95ece5e58eee1dd87ca456a7e7b5ced6798fd78182c59c35a7587b/mypy-1.14.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8f845a00b4f420f693f870eaee5f3e2692fa84cc8514496114649cfa8fd5e2c6", size = 10321087 }, - { url = "https://files.pythonhosted.org/packages/9a/eb/85ea6086227b84bce79b3baf7f465b4732e0785830726ce4a51528173b71/mypy-1.14.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44bf464499f0e3a2d14d58b54674dee25c031703b2ffc35064bd0df2e0fac319", size = 12066766 }, - { url = "https://files.pythonhosted.org/packages/4b/bb/f01bebf76811475d66359c259eabe40766d2f8ac8b8250d4e224bb6df379/mypy-1.14.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c99f27732c0b7dc847adb21c9d47ce57eb48fa33a17bc6d7d5c5e9f9e7ae5bac", size = 12787111 }, - { url = "https://files.pythonhosted.org/packages/2f/c9/84837ff891edcb6dcc3c27d85ea52aab0c4a34740ff5f0ccc0eb87c56139/mypy-1.14.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:bce23c7377b43602baa0bd22ea3265c49b9ff0b76eb315d6c34721af4cdf1d9b", size = 12974331 }, - { url = "https://files.pythonhosted.org/packages/84/5f/901e18464e6a13f8949b4909535be3fa7f823291b8ab4e4b36cfe57d6769/mypy-1.14.1-cp38-cp38-win_amd64.whl", hash = "sha256:8edc07eeade7ebc771ff9cf6b211b9a7d93687ff892150cb5692e4f4272b0837", size = 9763210 }, { url = "https://files.pythonhosted.org/packages/ca/1f/186d133ae2514633f8558e78cd658070ba686c0e9275c5a5c24a1e1f0d67/mypy-1.14.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3888a1816d69f7ab92092f785a462944b3ca16d7c470d564165fe703b0970c35", size = 11200493 }, { url = "https://files.pythonhosted.org/packages/af/fc/4842485d034e38a4646cccd1369f6b1ccd7bc86989c52770d75d719a9941/mypy-1.14.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:46c756a444117c43ee984bd055db99e498bc613a70bbbc120272bd13ca579fbc", size = 10357702 }, { url = "https://files.pythonhosted.org/packages/b4/e6/457b83f2d701e23869cfec013a48a12638f75b9d37612a9ddf99072c1051/mypy-1.14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:27fc248022907e72abfd8e22ab1f10e903915ff69961174784a3900a8cba9ad9", size = 12091104 }, @@ -879,12 +662,12 @@ name = "myst-parser" version = "3.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "docutils", marker = "python_full_version >= '3.9'" }, - { name = "jinja2", marker = "python_full_version >= '3.9'" }, - { name = "markdown-it-py", marker = "python_full_version >= '3.9'" }, - { name = "mdit-py-plugins", marker = "python_full_version >= '3.9'" }, - { name = "pyyaml", marker = "python_full_version >= '3.9'" }, - { name = "sphinx", marker = "python_full_version >= '3.9'" }, + { name = "docutils" }, + { name = "jinja2" }, + { name = "markdown-it-py" }, + { name = "mdit-py-plugins" }, + { name = "pyyaml" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/49/64/e2f13dac02f599980798c01156393b781aec983b52a6e4057ee58f07c43a/myst_parser-3.0.1.tar.gz", hash = "sha256:88f0cb406cb363b077d176b51c476f62d60604d68a8dcdf4832e080441301a87", size = 92392 } wheels = [ @@ -914,8 +697,8 @@ name = "pdbr" version = "0.8.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyreadline3", marker = "python_full_version >= '3.9' and sys_platform == 'win32'" }, - { name = "rich", marker = "python_full_version >= '3.9'" }, + { name = "pyreadline3", marker = "sys_platform == 'win32'" }, + { name = "rich" }, ] sdist = { url = "https://files.pythonhosted.org/packages/29/1d/40420fda7c53fd071d8f62dcdb550c9f82fee54c2fda6842337890d87334/pdbr-0.8.9.tar.gz", hash = "sha256:3e0e1fb78761402bcfc0713a9c73acc2f639406b1b8da7233c442b965eee009d", size = 15942 } wheels = [ @@ -1039,39 +822,15 @@ wheels = [ name = "platformdirs" version = "4.0.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", version = "4.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, -] sdist = { url = "https://files.pythonhosted.org/packages/31/28/e40d24d2e2eb23135f8533ad33d582359c7825623b1e022f9d460def7c05/platformdirs-4.0.0.tar.gz", hash = "sha256:cb633b2bcf10c51af60beb0ab06d2f1d69064b43abf4c185ca6b28865f3f9731", size = 19914 } wheels = [ { url = "https://files.pythonhosted.org/packages/31/16/70be3b725073035aa5fc3229321d06e22e73e3e09f6af78dcfdf16c7636c/platformdirs-4.0.0-py3-none-any.whl", hash = "sha256:118c954d7e949b35437270383a3f2531e99dd93cf7ce4dc8340d3356d30f173b", size = 17562 }, ] -[[package]] -name = "pluggy" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.8.*'", - "python_full_version < '3.8'", - "python_full_version == '3.9.*'", -] -dependencies = [ - { name = "importlib-metadata", marker = "python_full_version < '3.8'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8a/42/8f2833655a29c4e9cb52ee8a2be04ceac61bcff4a680fb338cbd3d1e322d/pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3", size = 61613 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/32/4a79112b8b87b21450b066e102d6608907f4c885ed7b04c3fdb085d4d6ae/pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849", size = 17695 }, -] - [[package]] name = "pluggy" version = "1.5.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.10.*'", - "python_full_version >= '3.11'", -] sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } wheels = [ { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, @@ -1084,11 +843,10 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, { name = "identify" }, - { name = "importlib-metadata", marker = "python_full_version < '3.8'" }, { name = "nodeenv" }, { name = "pyyaml" }, - { name = "virtualenv", version = "20.26.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, - { name = "virtualenv", version = "20.36.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8'" }, + { name = "virtualenv", version = "20.26.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "virtualenv", version = "20.36.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6b/00/1637ae945c6e10838ef5c41965f1c864e59301811bb203e979f335608e7c/pre_commit-2.21.0.tar.gz", hash = "sha256:31ef31af7e474a8d8995027fefdfcf509b5c913ff31f2015b4ec4beb26a6f658", size = 174966 } wheels = [ @@ -1144,43 +902,29 @@ wheels = [ ] [[package]] -name = "pytest" -version = "7.4.4" +name = "pyright" +version = "1.1.408" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.8.*'", - "python_full_version < '3.8'", - "python_full_version == '3.9.*'", -] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, - { name = "importlib-metadata", marker = "python_full_version < '3.8'" }, - { name = "iniconfig", marker = "python_full_version < '3.10'" }, - { name = "packaging", marker = "python_full_version < '3.10'" }, - { name = "pluggy", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "tomli", marker = "python_full_version < '3.10'" }, + { name = "nodeenv" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/80/1f/9d8e98e4133ffb16c90f3b405c43e38d3abb715bb5d7a63a5a684f7e46a3/pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280", size = 1357116 } +sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578 } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/ff/f6e8b8f39e08547faece4bd80f89d5a8de68a38b2d179cc1c4490ffa3286/pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8", size = 325287 }, + { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144 }, ] [[package]] name = "pytest" version = "8.3.3" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.10.*'", - "python_full_version >= '3.11'", -] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, - { name = "iniconfig", marker = "python_full_version >= '3.10'" }, - { name = "packaging", marker = "python_full_version >= '3.10'" }, - { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "tomli", marker = "python_full_version == '3.10.*'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8b/6c/62bbd536103af674e227c41a8f3dcd022d591f6eed5facb5a0f31ee33bbc/pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", size = 1442487 } wheels = [ @@ -1189,100 +933,78 @@ wheels = [ [[package]] name = "pytest-asyncio" -version = "0.21.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest", version = "7.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "8.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", version = "4.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ae/53/57663d99acaac2fcdafdc697e52a9b1b7d6fcf36616281ff9768a44e7ff3/pytest_asyncio-0.21.2.tar.gz", hash = "sha256:d67738fc232b94b326b9d060750beb16e0074210b98dd8b58a5239fa2a154f45", size = 30656 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/ce/1e4b53c213dce25d6e8b163697fbce2d43799d76fa08eea6ad270451c370/pytest_asyncio-0.21.2-py3-none-any.whl", hash = "sha256:ab664c88bb7998f711d8039cacd4884da6430886ae8bbd4eded552ed2004f16b", size = 13368 }, -] - -[[package]] -name = "pytest-benchmark" -version = "4.0.0" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.8.*'", - "python_full_version < '3.8'", - "python_full_version == '3.9.*'", + "python_full_version < '3.10'", ] dependencies = [ - { name = "py-cpuinfo", marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "7.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "backports-asyncio-runner", marker = "python_full_version < '3.10'" }, + { name = "pytest", marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/28/08/e6b0067efa9a1f2a1eb3043ecd8a0c48bfeb60d3255006dcc829d72d5da2/pytest-benchmark-4.0.0.tar.gz", hash = "sha256:fb0785b83efe599a6a956361c0691ae1dbb5318018561af10f3e915caa0048d1", size = 334641 } +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119 } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/a1/3b70862b5b3f830f0422844f25a823d0470739d994466be9dbbbb414d85a/pytest_benchmark-4.0.0-py3-none-any.whl", hash = "sha256:fdb7db64e31c8b277dff9850d2a2556d8b60bcb0ea6524e36e28ffd7c87f71d6", size = 43951 }, + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095 }, ] [[package]] -name = "pytest-benchmark" -version = "5.1.0" +name = "pytest-asyncio" +version = "1.3.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.10.*'", - "python_full_version >= '3.11'", + "python_full_version >= '3.10'", ] dependencies = [ - { name = "py-cpuinfo", marker = "python_full_version >= '3.10'" }, - { name = "pytest", version = "8.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "backports-asyncio-runner", marker = "python_full_version == '3.10.*'" }, + { name = "pytest", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/d0/a8bd08d641b393db3be3819b03e2d9bb8760ca8479080a26a5f6e540e99c/pytest-benchmark-5.1.0.tar.gz", hash = "sha256:9ea661cdc292e8231f7cd4c10b0319e56a2118e2c09d9f50e1b3d150d2aca105", size = 337810 } +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087 } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/d6/b41653199ea09d5969d4e385df9bbfd9a100f28ca7e824ce7c0a016e3053/pytest_benchmark-5.1.0-py3-none-any.whl", hash = "sha256:922de2dfa3033c227c96da942d1878191afa135a29485fb942e85dff1c592c89", size = 44259 }, + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075 }, ] [[package]] -name = "pytest-cov" -version = "4.1.0" +name = "pytest-benchmark" +version = "4.0.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.8'", + "python_full_version < '3.10'", ] dependencies = [ - { name = "coverage", version = "7.2.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.8'" }, - { name = "pytest", version = "7.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, + { name = "py-cpuinfo", marker = "python_full_version < '3.10'" }, + { name = "pytest", marker = "python_full_version < '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7a/15/da3df99fd551507694a9b01f512a2f6cf1254f33601605843c3775f39460/pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6", size = 63245 } +sdist = { url = "https://files.pythonhosted.org/packages/28/08/e6b0067efa9a1f2a1eb3043ecd8a0c48bfeb60d3255006dcc829d72d5da2/pytest-benchmark-4.0.0.tar.gz", hash = "sha256:fb0785b83efe599a6a956361c0691ae1dbb5318018561af10f3e915caa0048d1", size = 334641 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/4b/8b78d126e275efa2379b1c2e09dc52cf70df16fc3b90613ef82531499d73/pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a", size = 21949 }, + { url = "https://files.pythonhosted.org/packages/4d/a1/3b70862b5b3f830f0422844f25a823d0470739d994466be9dbbbb414d85a/pytest_benchmark-4.0.0-py3-none-any.whl", hash = "sha256:fdb7db64e31c8b277dff9850d2a2556d8b60bcb0ea6524e36e28ffd7c87f71d6", size = 43951 }, ] [[package]] -name = "pytest-cov" -version = "5.0.0" +name = "pytest-benchmark" +version = "5.1.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.8.*'", + "python_full_version >= '3.10'", ] dependencies = [ - { name = "coverage", version = "7.6.1", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version == '3.8.*'" }, - { name = "pytest", version = "7.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.8.*'" }, + { name = "py-cpuinfo", marker = "python_full_version >= '3.10'" }, + { name = "pytest", marker = "python_full_version >= '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/67/00efc8d11b630c56f15f4ad9c7f9223f1e5ec275aaae3fa9118c6a223ad2/pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857", size = 63042 } +sdist = { url = "https://files.pythonhosted.org/packages/39/d0/a8bd08d641b393db3be3819b03e2d9bb8760ca8479080a26a5f6e540e99c/pytest-benchmark-5.1.0.tar.gz", hash = "sha256:9ea661cdc292e8231f7cd4c10b0319e56a2118e2c09d9f50e1b3d150d2aca105", size = 337810 } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/3a/af5b4fa5961d9a1e6237b530eb87dd04aea6eb83da09d2a4073d81b54ccf/pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652", size = 21990 }, + { url = "https://files.pythonhosted.org/packages/9e/d6/b41653199ea09d5969d4e385df9bbfd9a100f28ca7e824ce7c0a016e3053/pytest_benchmark-5.1.0-py3-none-any.whl", hash = "sha256:922de2dfa3033c227c96da942d1878191afa135a29485fb942e85dff1c592c89", size = 44259 }, ] [[package]] name = "pytest-cov" version = "7.0.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.9.*'", - "python_full_version == '3.10.*'", - "python_full_version >= '3.11'", -] dependencies = [ - { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.9'" }, - { name = "pluggy", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pytest", version = "7.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "pytest", version = "8.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328 } wheels = [ @@ -1294,8 +1016,7 @@ name = "pytest-django" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest", version = "7.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "pytest", version = "8.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/02/c0/43c8b2528c24d7f1a48a47e3f7381f5ab2ae8c64634b0c3f4bd843063955/pytest_django-4.9.0.tar.gz", hash = "sha256:8bf7bc358c9ae6f6fc51b6cebb190fe20212196e6807121f11bd6a3b03428314", size = 84067 } wheels = [ @@ -1304,15 +1025,14 @@ wheels = [ [[package]] name = "pytest-mock" -version = "3.11.1" +version = "3.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest", version = "7.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "8.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/2d/b3a811ec4fa24190a9ec5013e23c89421a7916167c6240c31fdc445f850c/pytest-mock-3.11.1.tar.gz", hash = "sha256:7f6b125602ac6d743e523ae0bfa71e1a697a2f5534064528c6ff84c2f7c2fc7f", size = 31251 } +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036 } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/85/80ae98e019a429445bfb74e153d4cb47c3695e3e908515e95e95c18237e5/pytest_mock-3.11.1-py3-none-any.whl", hash = "sha256:21c279fff83d70763b05f8874cc9cfb3fcacd6d354247a976f9529d19f9acf39", size = 9590 }, + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095 }, ] [[package]] @@ -1321,8 +1041,7 @@ version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, - { name = "pytest", version = "7.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "8.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest" }, { name = "termcolor" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/ac/5754f5edd6d508bc6493bc37d74b928f102a5fff82d9a80347e180998f08/pytest-sugar-1.0.0.tar.gz", hash = "sha256:6422e83258f5b0c04ce7c632176c7732cab5fdb909cb39cca5c9139f81276c0a", size = 14992 } @@ -1330,9 +1049,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/fb/889f1b69da2f13691de09a111c16c4766a433382d44aa0ecf221deded44a/pytest_sugar-1.0.0-py3-none-any.whl", hash = "sha256:70ebcd8fc5795dc457ff8b69d266a4e2e8a74ae0c3edc749381c64b5246c8dfd", size = 10171 }, ] +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382 }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396 }, +] + [[package]] name = "python-statemachine" -version = "2.6.0" +version = "3.0.0" source = { editable = "." } [package.optional-dependencies] @@ -1342,32 +1086,33 @@ diagrams = [ [package.dev-dependencies] dev = [ - { name = "babel", marker = "python_full_version >= '3.8'" }, + { name = "babel" }, { name = "django", marker = "python_full_version >= '3.10'" }, - { name = "furo", marker = "python_full_version >= '3.9'" }, - { name = "mypy", version = "1.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, - { name = "mypy", version = "1.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8'" }, - { name = "myst-parser", marker = "python_full_version >= '3.9'" }, - { name = "pdbr", marker = "python_full_version >= '3.9'" }, - { name = "pillow", marker = "python_full_version >= '3.9'" }, + { name = "furo" }, + { name = "mypy", version = "1.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "mypy", version = "1.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "myst-parser" }, + { name = "pdbr" }, + { name = "pillow" }, { name = "pre-commit" }, { name = "pydot" }, - { name = "pytest", version = "7.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "8.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pytest-asyncio" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-asyncio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest-asyncio", version = "1.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pytest-benchmark", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "pytest-benchmark", version = "5.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pytest-cov", version = "4.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, - { name = "pytest-cov", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.8.*'" }, - { name = "pytest-cov", version = "7.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "pytest-django", marker = "python_full_version >= '3.9'" }, + { name = "pytest-cov" }, + { name = "pytest-django" }, { name = "pytest-mock" }, { name = "pytest-sugar" }, + { name = "pytest-timeout" }, + { name = "pytest-xdist" }, { name = "ruff" }, - { name = "sphinx", marker = "python_full_version >= '3.9'" }, - { name = "sphinx-autobuild", marker = "python_full_version >= '3.9'" }, - { name = "sphinx-copybutton", marker = "python_full_version >= '3.9'" }, - { name = "sphinx-gallery", marker = "python_full_version >= '3.9'" }, + { name = "sphinx" }, + { name = "sphinx-autobuild" }, + { name = "sphinx-copybutton" }, + { name = "sphinx-gallery" }, ] [package.metadata] @@ -1384,30 +1129,24 @@ dev = [ { name = "pillow", marker = "python_full_version >= '3.9'" }, { name = "pre-commit" }, { name = "pydot" }, + { name = "pyright", specifier = ">=1.1.400" }, { name = "pytest" }, - { name = "pytest-asyncio" }, + { name = "pytest-asyncio", specifier = ">=0.25.0" }, { name = "pytest-benchmark", specifier = ">=4.0.0" }, { name = "pytest-cov", marker = "python_full_version < '3.9'" }, { name = "pytest-cov", marker = "python_full_version >= '3.9'", specifier = ">=6.0.0" }, { name = "pytest-django", marker = "python_full_version >= '3.9'", specifier = ">=4.8.0" }, - { name = "pytest-mock", specifier = ">=3.10.0" }, + { name = "pytest-mock", specifier = ">=3.14.0" }, { name = "pytest-sugar", specifier = ">=1.0.0" }, - { name = "ruff", specifier = ">=0.8.1" }, + { name = "pytest-timeout", specifier = ">=2.3.1" }, + { name = "pytest-xdist", specifier = ">=3.6.1" }, + { name = "ruff", specifier = ">=0.15.0" }, { name = "sphinx", marker = "python_full_version >= '3.9'" }, { name = "sphinx-autobuild", marker = "python_full_version >= '3.9'" }, { name = "sphinx-copybutton", marker = "python_full_version >= '3.9'", specifier = ">=0.5.2" }, { name = "sphinx-gallery", marker = "python_full_version >= '3.9'" }, ] -[[package]] -name = "pytz" -version = "2025.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225 }, -] - [[package]] name = "pyyaml" version = "6.0.1" @@ -1437,19 +1176,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/78/77b40157b6cb5f2d3d31a3d9b2efd1ba3505371f76730d267e8b32cf4b7f/PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4", size = 712604 }, { url = "https://files.pythonhosted.org/packages/2e/97/3e0e089ee85e840f4b15bfa00e4e63d84a3691ababbfea92d6f820ea6f21/PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54", size = 126098 }, { url = "https://files.pythonhosted.org/packages/2b/9f/fbade56564ad486809c27b322d0f7e6a89c01f6b4fe208402e90d4443a99/PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df", size = 138675 }, - { url = "https://files.pythonhosted.org/packages/c7/d1/02baa09d39b1bb1ebaf0d850d106d1bdcb47c91958557f471153c49dc03b/PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3", size = 189627 }, - { url = "https://files.pythonhosted.org/packages/e5/31/ba812efa640a264dbefd258986a5e4e786230cb1ee4a9f54eb28ca01e14a/PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27", size = 658438 }, - { url = "https://files.pythonhosted.org/packages/4d/f1/08f06159739254c8947899c9fc901241614195db15ba8802ff142237664c/PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3", size = 680304 }, - { url = "https://files.pythonhosted.org/packages/d7/8f/db62b0df635b9008fe90aa68424e99cee05e68b398740c8a666a98455589/PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c", size = 670140 }, - { url = "https://files.pythonhosted.org/packages/cc/5c/fcabd17918348c7db2eeeb0575705aaf3f7ab1657f6ce29b2e31737dd5d1/PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba", size = 137577 }, - { url = "https://files.pythonhosted.org/packages/1e/ae/964ccb88a938f20ece5754878f182cfbd846924930d02d29d06af8d4c69e/PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867", size = 153248 }, - { url = "https://files.pythonhosted.org/packages/7f/5d/2779ea035ba1e533c32ed4a249b4e0448f583ba10830b21a3cddafe11a4e/PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595", size = 191734 }, - { url = "https://files.pythonhosted.org/packages/e1/a1/27bfac14b90adaaccf8c8289f441e9f76d94795ec1e7a8f134d9f2cb3d0b/PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5", size = 723767 }, - { url = "https://files.pythonhosted.org/packages/c1/39/47ed4d65beec9ce07267b014be85ed9c204fa373515355d3efa62d19d892/PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696", size = 749067 }, - { url = "https://files.pythonhosted.org/packages/c8/6b/6600ac24725c7388255b2f5add93f91e58a5d7efaf4af244fdbcc11a541b/PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735", size = 736569 }, - { url = "https://files.pythonhosted.org/packages/0d/46/62ae77677e532c0af6c81ddd6f3dbc16bdcc1208b077457354442d220bfb/PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6", size = 787738 }, - { url = "https://files.pythonhosted.org/packages/d6/6a/439d1a6f834b9a9db16332ce16c4a96dd0e3970b65fe08cbecd1711eeb77/PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206", size = 139797 }, - { url = "https://files.pythonhosted.org/packages/29/0f/9782fa5b10152abf033aec56a601177ead85ee03b57781f2d9fced09eefc/PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62", size = 157350 }, { url = "https://files.pythonhosted.org/packages/57/c5/5d09b66b41d549914802f482a2118d925d876dc2a35b2d127694c1345c34/PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8", size = 197846 }, { url = "https://files.pythonhosted.org/packages/0e/88/21b2f16cb2123c1e9375f2c93486e35fdc86e63f02e274f0e99c589ef153/PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859", size = 174396 }, { url = "https://files.pythonhosted.org/packages/ac/6c/967d91a8edf98d2b2b01d149bd9e51b8f9fb527c98d80ebb60c6b21d60c4/PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6", size = 731824 }, @@ -1465,10 +1191,10 @@ name = "requests" version = "2.32.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi", marker = "python_full_version >= '3.9'" }, - { name = "charset-normalizer", marker = "python_full_version >= '3.9'" }, - { name = "idna", marker = "python_full_version >= '3.9'" }, - { name = "urllib3", marker = "python_full_version >= '3.9'" }, + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 } wheels = [ @@ -1480,9 +1206,9 @@ name = "rich" version = "13.9.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "markdown-it-py", marker = "python_full_version >= '3.9'" }, - { name = "pygments", marker = "python_full_version >= '3.9'" }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, + { name = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } wheels = [ @@ -1546,24 +1272,24 @@ name = "sphinx" version = "7.4.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "alabaster", marker = "python_full_version >= '3.9'" }, - { name = "babel", marker = "python_full_version >= '3.9'" }, - { name = "colorama", marker = "python_full_version >= '3.9' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version >= '3.9'" }, - { name = "imagesize", marker = "python_full_version >= '3.9'" }, - { name = "importlib-metadata", marker = "python_full_version == '3.9.*'" }, - { name = "jinja2", marker = "python_full_version >= '3.9'" }, - { name = "packaging", marker = "python_full_version >= '3.9'" }, - { name = "pygments", marker = "python_full_version >= '3.9'" }, - { name = "requests", marker = "python_full_version >= '3.9'" }, - { name = "snowballstemmer", marker = "python_full_version >= '3.9'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.9'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.9'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.9'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.9'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.9'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.9'" }, - { name = "tomli", marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/be/50e50cb4f2eff47df05673d361095cafd95521d2a22521b920c67a372dcb/sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe", size = 8067911 } wheels = [ @@ -1575,12 +1301,13 @@ name = "sphinx-autobuild" version = "2024.10.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.9'" }, - { name = "sphinx", marker = "python_full_version >= '3.9'" }, - { name = "starlette", marker = "python_full_version >= '3.9'" }, - { name = "uvicorn", marker = "python_full_version >= '3.9'" }, - { name = "watchfiles", marker = "python_full_version >= '3.9'" }, - { name = "websockets", marker = "python_full_version >= '3.9'" }, + { name = "colorama" }, + { name = "sphinx" }, + { name = "starlette", version = "0.47.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "starlette", version = "0.49.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "uvicorn" }, + { name = "watchfiles" }, + { name = "websockets" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a5/2c/155e1de2c1ba96a72e5dba152c509a8b41e047ee5c2def9e9f0d812f8be7/sphinx_autobuild-2024.10.3.tar.gz", hash = "sha256:248150f8f333e825107b6d4b86113ab28fa51750e5f9ae63b59dc339be951fb1", size = 14023 } wheels = [ @@ -1592,7 +1319,7 @@ name = "sphinx-basic-ng" version = "1.0.0b2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx", marker = "python_full_version >= '3.9'" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/0b/a866924ded68efec7a1759587a4e478aec7559d8165fac8b2ad1c0e774d6/sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9", size = 20736 } wheels = [ @@ -1604,7 +1331,7 @@ name = "sphinx-copybutton" version = "0.5.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sphinx", marker = "python_full_version >= '3.9'" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fc/2b/a964715e7f5295f77509e59309959f4125122d648f86b4fe7d70ca1d882c/sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd", size = 23039 } wheels = [ @@ -1616,8 +1343,8 @@ name = "sphinx-gallery" version = "0.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pillow", marker = "python_full_version >= '3.9'" }, - { name = "sphinx", marker = "python_full_version >= '3.9'" }, + { name = "pillow" }, + { name = "sphinx" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/84/e4b4cde6ea2f3a1dd7d523dcf28260e93999b4882cc352f8bc6a14cbd848/sphinx_gallery-0.18.0.tar.gz", hash = "sha256:4b5b5bc305348c01d00cf66ad852cfd2dd8b67f7f32ae3e2820c01557b3f92f9", size = 466371 } wheels = [ @@ -1687,13 +1414,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/a5/b2860373aa8de1e626b2bdfdd6df4355f0565b47e51f7d0c54fe70faf8fe/sqlparse-0.5.1-py3-none-any.whl", hash = "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4", size = 44156 }, ] +[[package]] +name = "starlette" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "anyio", marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/d0/0332bd8a25779a0e2082b0e179805ad39afad642938b371ae0882e7f880d/starlette-0.47.0.tar.gz", hash = "sha256:1f64887e94a447fed5f23309fb6890ef23349b7e478faa7b24a851cd4eb844af", size = 2582856 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/81/c60b35fe9674f63b38a8feafc414fca0da378a9dbd5fa1e0b8d23fcc7a9b/starlette-0.47.0-py3-none-any.whl", hash = "sha256:9d052d4933683af40ffd47c7465433570b4949dc937e20ad1d73b34e72f10c37", size = 72796 }, +] + [[package]] name = "starlette" version = "0.49.3" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] dependencies = [ - { name = "anyio", marker = "python_full_version >= '3.9'" }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" }, + { name = "anyio", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/de/1a/608df0b10b53b0beb96a37854ee05864d182ddd4b1156a22f1ad3860425a/starlette-0.49.3.tar.gz", hash = "sha256:1c14546f299b5901a1ea0e34410575bc33bbd741377a10484a54445588d00284", size = 2655031 } wheels = [ @@ -1718,70 +1464,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", size = 12757 }, ] -[[package]] -name = "typed-ast" -version = "1.5.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/7e/a424029f350aa8078b75fd0d360a787a273ca753a678d1104c5fa4f3072a/typed_ast-1.5.5.tar.gz", hash = "sha256:94282f7a354f36ef5dbce0ef3467ebf6a258e370ab33d5b40c249fa996e590dd", size = 252841 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/07/5defe18d4fc16281cd18c4374270abc430c3d852d8ac29b5db6599d45cfe/typed_ast-1.5.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4bc1efe0ce3ffb74784e06460f01a223ac1f6ab31c6bc0376a21184bf5aabe3b", size = 223267 }, - { url = "https://files.pythonhosted.org/packages/a0/5c/e379b00028680bfcd267d845cf46b60e76d8ac6f7009fd440d6ce030cc92/typed_ast-1.5.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f7a8c46a8b333f71abd61d7ab9255440d4a588f34a21f126bbfc95f6049e686", size = 208260 }, - { url = "https://files.pythonhosted.org/packages/3b/99/5cc31ef4f3c80e1ceb03ed2690c7085571e3fbf119cbd67a111ec0b6622f/typed_ast-1.5.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:597fc66b4162f959ee6a96b978c0435bd63791e31e4f410622d19f1686d5e769", size = 842272 }, - { url = "https://files.pythonhosted.org/packages/e2/ed/b9b8b794b37b55c9247b1e8d38b0361e8158795c181636d34d6c11b506e7/typed_ast-1.5.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d41b7a686ce653e06c2609075d397ebd5b969d821b9797d029fccd71fdec8e04", size = 824651 }, - { url = "https://files.pythonhosted.org/packages/ca/59/dbbbe5a0e91c15d14a0896b539a5ed01326b0d468e75c1a33274d128d2d1/typed_ast-1.5.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5fe83a9a44c4ce67c796a1b466c270c1272e176603d5e06f6afbc101a572859d", size = 854960 }, - { url = "https://files.pythonhosted.org/packages/90/f0/0956d925f87bd81f6e0f8cf119eac5e5c8f4da50ca25bb9f5904148d4611/typed_ast-1.5.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d5c0c112a74c0e5db2c75882a0adf3133adedcdbfd8cf7c9d6ed77365ab90a1d", size = 839321 }, - { url = "https://files.pythonhosted.org/packages/43/17/4bdece9795da6f3345c4da5667ac64bc25863617f19c28d81f350f515be6/typed_ast-1.5.5-cp310-cp310-win_amd64.whl", hash = "sha256:e1a976ed4cc2d71bb073e1b2a250892a6e968ff02aa14c1f40eba4f365ffec02", size = 139380 }, - { url = "https://files.pythonhosted.org/packages/75/53/b685e10da535c7b3572735f8bea0d4abb35a04722a7d44ca9c163a0cf822/typed_ast-1.5.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c631da9710271cb67b08bd3f3813b7af7f4c69c319b75475436fcab8c3d21bee", size = 223264 }, - { url = "https://files.pythonhosted.org/packages/96/fd/fc8ccf19fc16a40a23e7c7802d0abc78c1f38f1abb6e2447c474f8a076d8/typed_ast-1.5.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b445c2abfecab89a932b20bd8261488d574591173d07827c1eda32c457358b18", size = 208158 }, - { url = "https://files.pythonhosted.org/packages/bf/9a/598e47f2c3ecd19d7f1bb66854d0d3ba23ffd93c846448790a92524b0a8d/typed_ast-1.5.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc95ffaaab2be3b25eb938779e43f513e0e538a84dd14a5d844b8f2932593d88", size = 878366 }, - { url = "https://files.pythonhosted.org/packages/60/ca/765e8bf8b24d0ed7b9fc669f6826c5bc3eb7412fc765691f59b83ae195b2/typed_ast-1.5.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61443214d9b4c660dcf4b5307f15c12cb30bdfe9588ce6158f4a005baeb167b2", size = 860314 }, - { url = "https://files.pythonhosted.org/packages/d9/3c/4af750e6c673a0dd6c7b9f5b5e5ed58ec51a2e4e744081781c664d369dfa/typed_ast-1.5.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6eb936d107e4d474940469e8ec5b380c9b329b5f08b78282d46baeebd3692dc9", size = 898108 }, - { url = "https://files.pythonhosted.org/packages/03/8d/d0a4d1e060e1e8dda2408131a0cc7633fc4bc99fca5941dcb86c461dfe01/typed_ast-1.5.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e48bf27022897577d8479eaed64701ecaf0467182448bd95759883300ca818c8", size = 881971 }, - { url = "https://files.pythonhosted.org/packages/90/83/f28d2c912cd010a09b3677ac69d23181045eb17e358914ab739b7fdee530/typed_ast-1.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:83509f9324011c9a39faaef0922c6f720f9623afe3fe220b6d0b15638247206b", size = 139286 }, - { url = "https://files.pythonhosted.org/packages/d5/00/635353c31b71ed307ab020eff6baed9987da59a1b2ba489f885ecbe293b8/typed_ast-1.5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2188bc33d85951ea4ddad55d2b35598b2709d122c11c75cffd529fbc9965508e", size = 222315 }, - { url = "https://files.pythonhosted.org/packages/01/95/11be104446bb20212a741d30d40eab52a9cfc05ea34efa074ff4f7c16983/typed_ast-1.5.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0635900d16ae133cab3b26c607586131269f88266954eb04ec31535c9a12ef1e", size = 793541 }, - { url = "https://files.pythonhosted.org/packages/32/f1/75bd58fb1410cb72fbc6e8adf163015720db2c38844b46a9149c5ff6bf38/typed_ast-1.5.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57bfc3cf35a0f2fdf0a88a3044aafaec1d2f24d8ae8cd87c4f58d615fb5b6311", size = 778348 }, - { url = "https://files.pythonhosted.org/packages/47/97/0bb4dba688a58ff9c08e63b39653e4bcaa340ce1bb9c1d58163e5c2c66f1/typed_ast-1.5.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:fe58ef6a764de7b4b36edfc8592641f56e69b7163bba9f9c8089838ee596bfb2", size = 809447 }, - { url = "https://files.pythonhosted.org/packages/a8/cd/9a867f5a96d83a9742c43914e10d3a2083d8fe894ab9bf60fd467c6c497f/typed_ast-1.5.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d09d930c2d1d621f717bb217bf1fe2584616febb5138d9b3e8cdd26506c3f6d4", size = 796707 }, - { url = "https://files.pythonhosted.org/packages/eb/06/73ca55ee5303b41d08920de775f02d2a3e1e59430371f5adf7fbb1a21127/typed_ast-1.5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:d40c10326893ecab8a80a53039164a224984339b2c32a6baf55ecbd5b1df6431", size = 138403 }, - { url = "https://files.pythonhosted.org/packages/19/e3/88b65e46643006592f39e0fdef3e29454244a9fdaa52acfb047dc68cae6a/typed_ast-1.5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fd946abf3c31fb50eee07451a6aedbfff912fcd13cf357363f5b4e834cc5e71a", size = 222951 }, - { url = "https://files.pythonhosted.org/packages/15/e0/182bdd9edb6c6a1c068cecaa87f58924a817f2807a0b0d940f578b3328df/typed_ast-1.5.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ed4a1a42df8a3dfb6b40c3d2de109e935949f2f66b19703eafade03173f8f437", size = 208247 }, - { url = "https://files.pythonhosted.org/packages/8d/09/bba083f2c11746288eaf1859e512130420405033de84189375fe65d839ba/typed_ast-1.5.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:045f9930a1550d9352464e5149710d56a2aed23a2ffe78946478f7b5416f1ede", size = 861010 }, - { url = "https://files.pythonhosted.org/packages/31/f3/38839df509b04fb54205e388fc04b47627377e0ad628870112086864a441/typed_ast-1.5.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:381eed9c95484ceef5ced626355fdc0765ab51d8553fec08661dce654a935db4", size = 840026 }, - { url = "https://files.pythonhosted.org/packages/45/1e/aa5f1dae4b92bc665ae9a655787bb2fe007a881fa2866b0408ce548bb24c/typed_ast-1.5.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:bfd39a41c0ef6f31684daff53befddae608f9daf6957140228a08e51f312d7e6", size = 875615 }, - { url = "https://files.pythonhosted.org/packages/94/88/71a1c249c01fbbd66f9f28648f8249e737a7fe19056c1a78e7b3b9250eb1/typed_ast-1.5.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8c524eb3024edcc04e288db9541fe1f438f82d281e591c548903d5b77ad1ddd4", size = 858320 }, - { url = "https://files.pythonhosted.org/packages/12/1e/19f53aad3984e351e6730e4265fde4b949a66c451e10828fdbc4dfb050f1/typed_ast-1.5.5-cp38-cp38-win_amd64.whl", hash = "sha256:7f58fabdde8dcbe764cef5e1a7fcb440f2463c1bbbec1cf2a86ca7bc1f95184b", size = 139414 }, - { url = "https://files.pythonhosted.org/packages/b1/88/6e7f36f5fab6fbf0586a2dd866ac337924b7d4796a4d1b2b04443a864faf/typed_ast-1.5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:042eb665ff6bf020dd2243307d11ed626306b82812aba21836096d229fdc6a10", size = 223329 }, - { url = "https://files.pythonhosted.org/packages/71/30/09d27e13824495547bcc665bd07afc593b22b9484f143b27565eae4ccaac/typed_ast-1.5.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:622e4a006472b05cf6ef7f9f2636edc51bda670b7bbffa18d26b255269d3d814", size = 208314 }, - { url = "https://files.pythonhosted.org/packages/07/3d/564308b7a432acb1f5399933cbb1b376a1a64d2544b90f6ba91894674260/typed_ast-1.5.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1efebbbf4604ad1283e963e8915daa240cb4bf5067053cf2f0baadc4d4fb51b8", size = 840900 }, - { url = "https://files.pythonhosted.org/packages/ea/f4/262512d14f777ea3666a089e2675a9b1500a85b8329a36de85d63433fb0e/typed_ast-1.5.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0aefdd66f1784c58f65b502b6cf8b121544680456d1cebbd300c2c813899274", size = 823435 }, - { url = "https://files.pythonhosted.org/packages/a1/25/b3ccb948166d309ab75296ac9863ebe2ff209fbc063f1122a2d3979e47c3/typed_ast-1.5.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:48074261a842acf825af1968cd912f6f21357316080ebaca5f19abbb11690c8a", size = 853125 }, - { url = "https://files.pythonhosted.org/packages/1c/09/012da182242f168bb5c42284297dcc08dc0a1b3668db5b3852aec467f56f/typed_ast-1.5.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:429ae404f69dc94b9361bb62291885894b7c6fb4640d561179548c849f8492ba", size = 837280 }, - { url = "https://files.pythonhosted.org/packages/30/bd/c815051404c4293265634d9d3e292f04fcf681d0502a9484c38b8f224d04/typed_ast-1.5.5-cp39-cp39-win_amd64.whl", hash = "sha256:335f22ccb244da2b5c296e6f96b06ee9bed46526db0de38d2f0e5a6597b81155", size = 139486 }, -] - -[[package]] -name = "typing-extensions" -version = "4.7.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.8'", -] -sdist = { url = "https://files.pythonhosted.org/packages/3c/8b/0111dd7d6c1478bf83baa1cab85c686426c7a6274119aceb2bd9d35395ad/typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2", size = 72876 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/6b/63cc3df74987c36fe26157ee12e09e8f9db4de771e0f3404263117e75b95/typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36", size = 33232 }, -] - [[package]] name = "typing-extensions" version = "4.13.2" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.8.*'", - "python_full_version == '3.9.*'", - "python_full_version == '3.10.*'", - "python_full_version >= '3.11'", -] sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 } wheels = [ { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 }, @@ -1810,9 +1496,9 @@ name = "uvicorn" version = "0.32.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click", marker = "python_full_version >= '3.9'" }, - { name = "h11", marker = "python_full_version >= '3.9'" }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e0/fc/1d785078eefd6945f3e5bab5c076e4230698046231eb0f3747bc5c8fa992/uvicorn-0.32.0.tar.gz", hash = "sha256:f78b36b143c16f54ccdb8190d0a26b5f1901fe5a3c777e1ab29f26391af8551e", size = 77564 } wheels = [ @@ -1824,13 +1510,12 @@ name = "virtualenv" version = "20.26.6" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.8'", + "python_full_version < '3.10'", ] dependencies = [ - { name = "distlib", marker = "python_full_version < '3.8'" }, - { name = "filelock", version = "3.12.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.8'" }, - { name = "importlib-metadata", marker = "python_full_version < '3.8'" }, - { name = "platformdirs", marker = "python_full_version < '3.8'" }, + { name = "distlib", marker = "python_full_version < '3.10'" }, + { name = "filelock", version = "3.12.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "platformdirs", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3f/40/abc5a766da6b0b2457f819feab8e9203cbeae29327bd241359f866a3da9d/virtualenv-20.26.6.tar.gz", hash = "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48", size = 9372482 } wheels = [ @@ -1842,17 +1527,13 @@ name = "virtualenv" version = "20.36.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.8.*'", - "python_full_version == '3.9.*'", - "python_full_version == '3.10.*'", - "python_full_version >= '3.11'", + "python_full_version >= '3.10'", ] dependencies = [ - { name = "distlib", marker = "python_full_version >= '3.8'" }, - { name = "filelock", version = "3.16.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8' and python_full_version < '3.10'" }, + { name = "distlib", marker = "python_full_version >= '3.10'" }, { name = "filelock", version = "3.20.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "platformdirs", marker = "python_full_version >= '3.8'" }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8' and python_full_version < '3.11'" }, + { name = "platformdirs", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239 } wheels = [ @@ -1864,7 +1545,7 @@ name = "watchfiles" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "python_full_version >= '3.9'" }, + { name = "anyio" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440 } wheels = [ @@ -2028,17 +1709,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a1/6e/66b6b756aebbd680b934c8bdbb6dcb9ce45aad72cde5f8a7208dbb00dd36/websockets-13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:70c5be9f416aa72aab7a2a76c90ae0a4fe2755c1816c153c1a2bcc3333ce4ce6", size = 164732 }, { url = "https://files.pythonhosted.org/packages/35/c6/12e3aab52c11aeb289e3dbbc05929e7a9d90d7a9173958477d3ef4f8ce2d/websockets-13.1-cp313-cp313-win32.whl", hash = "sha256:624459daabeb310d3815b276c1adef475b3e6804abaf2d9d2c061c319f7f187d", size = 158709 }, { url = "https://files.pythonhosted.org/packages/41/d8/63d6194aae711d7263df4498200c690a9c39fb437ede10f3e157a6343e0d/websockets-13.1-cp313-cp313-win_amd64.whl", hash = "sha256:c518e84bb59c2baae725accd355c8dc517b4a3ed8db88b4bc93c78dae2974bf2", size = 159144 }, - { url = "https://files.pythonhosted.org/packages/83/69/59872420e5bce60db166d6fba39ee24c719d339fb0ae48cb2ce580129882/websockets-13.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:c7934fd0e920e70468e676fe7f1b7261c1efa0d6c037c6722278ca0228ad9d0d", size = 157811 }, - { url = "https://files.pythonhosted.org/packages/bb/f7/0610032e0d3981758fdd6ee7c68cc02ebf668a762c5178d3d91748228849/websockets-13.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:149e622dc48c10ccc3d2760e5f36753db9cacf3ad7bc7bbbfd7d9c819e286f23", size = 155471 }, - { url = "https://files.pythonhosted.org/packages/55/2f/c43173a72ea395263a427a36d25bce2675f41c809424466a13c61a9a2d61/websockets-13.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a569eb1b05d72f9bce2ebd28a1ce2054311b66677fcd46cf36204ad23acead8c", size = 155713 }, - { url = "https://files.pythonhosted.org/packages/92/7e/8fa930c6426a56c47910792717787640329e4a0e37cdfda20cf89da67126/websockets-13.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95df24ca1e1bd93bbca51d94dd049a984609687cb2fb08a7f2c56ac84e9816ea", size = 164995 }, - { url = "https://files.pythonhosted.org/packages/27/29/50ed4c68a3f606565a2db4b13948ae7b6f6c53aa9f8f258d92be6698d276/websockets-13.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8dbb1bf0c0a4ae8b40bdc9be7f644e2f3fb4e8a9aca7145bfa510d4a374eeb7", size = 164057 }, - { url = "https://files.pythonhosted.org/packages/3c/0e/60da63b1c53c47f389f79312b3356cb305600ffad1274d7ec473128d4e6b/websockets-13.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:035233b7531fb92a76beefcbf479504db8c72eb3bff41da55aecce3a0f729e54", size = 164340 }, - { url = "https://files.pythonhosted.org/packages/20/ef/d87c5fc0aa7fafad1d584b6459ddfe062edf0d0dd64800a02e67e5de048b/websockets-13.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:e4450fc83a3df53dec45922b576e91e94f5578d06436871dce3a6be38e40f5db", size = 164222 }, - { url = "https://files.pythonhosted.org/packages/f2/c4/7916e1f6b5252d3dcb9121b67d7fdbb2d9bf5067a6d8c88885ba27a9e69c/websockets-13.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:463e1c6ec853202dd3657f156123d6b4dad0c546ea2e2e38be2b3f7c5b8e7295", size = 163647 }, - { url = "https://files.pythonhosted.org/packages/de/df/2ebebb807f10993c35c10cbd3628a7944b66bd5fb6632a561f8666f3a68e/websockets-13.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6d6855bbe70119872c05107e38fbc7f96b1d8cb047d95c2c50869a46c65a8e96", size = 163590 }, - { url = "https://files.pythonhosted.org/packages/b5/82/d48911f56bb993c11099a1ff1d4041d9d1481d50271100e8ee62bc28f365/websockets-13.1-cp38-cp38-win32.whl", hash = "sha256:204e5107f43095012b00f1451374693267adbb832d29966a01ecc4ce1db26faf", size = 158701 }, - { url = "https://files.pythonhosted.org/packages/8b/b3/945aacb21fc89ad150403cbaa974c9e846f098f16d9f39a3dd6094f9beb1/websockets-13.1-cp38-cp38-win_amd64.whl", hash = "sha256:485307243237328c022bc908b90e4457d0daa8b5cf4b3723fd3c4a8012fce4c6", size = 159146 }, { url = "https://files.pythonhosted.org/packages/61/26/5f7a7fb03efedb4f90ed61968338bfe7c389863b0ceda239b94ae61c5ae4/websockets-13.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9b37c184f8b976f0c0a231a5f3d6efe10807d41ccbe4488df8c74174805eea7d", size = 157810 }, { url = "https://files.pythonhosted.org/packages/0e/d4/9b4814a07dffaa7a79d71b4944d10836f9adbd527a113f6675734ef3abed/websockets-13.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:163e7277e1a0bd9fb3c8842a71661ad19c6aa7bb3d6678dc7f89b17fbcc4aeb7", size = 155467 }, { url = "https://files.pythonhosted.org/packages/1a/1a/2abdc7ce3b56429ae39d6bfb48d8c791f5a26bbcb6f44aabcf71ffc3fda2/websockets-13.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4b889dbd1342820cc210ba44307cf75ae5f2f96226c0038094455a96e64fb07a", size = 155714 }, @@ -2056,12 +1726,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/f5/6652fb82440813822022a9301a30afde85e5ff3fb2aebb77f34aabe2b4e8/websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcc03c8b72267e97b49149e4863d57c2d77f13fae12066622dc78fe322490fe6", size = 156701 }, { url = "https://files.pythonhosted.org/packages/67/33/ae82a7b860fa8a08aba68818bdf7ff61f04598aa5ab96df4cd5a3e418ca4/websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:004280a140f220c812e65f36944a9ca92d766b6cc4560be652a0a3883a79ed8a", size = 156654 }, { url = "https://files.pythonhosted.org/packages/63/0b/a1b528d36934f833e20f6da1032b995bf093d55cb416b9f2266f229fb237/websockets-13.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e2620453c075abeb0daa949a292e19f56de518988e079c36478bacf9546ced23", size = 159192 }, - { url = "https://files.pythonhosted.org/packages/5e/a1/5ae6d0ef2e61e2b77b3b4678949a634756544186620a728799acdf5c3482/websockets-13.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9156c45750b37337f7b0b00e6248991a047be4aa44554c9886fe6bdd605aab3b", size = 155433 }, - { url = "https://files.pythonhosted.org/packages/0d/2f/addd33f85600d210a445f817ff0d79d2b4d0eb6f3c95b9f35531ebf8f57c/websockets-13.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:80c421e07973a89fbdd93e6f2003c17d20b69010458d3a8e37fb47874bd67d51", size = 155733 }, - { url = "https://files.pythonhosted.org/packages/74/0b/f8ec74ac3b14a983289a1b42dc2c518a0e2030b486d0549d4f51ca11e7c9/websockets-13.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82d0ba76371769d6a4e56f7e83bb8e81846d17a6190971e38b5de108bde9b0d7", size = 157093 }, - { url = "https://files.pythonhosted.org/packages/ad/4c/aa5cc2f718ee4d797411202f332c8281f04c42d15f55b02f7713320f7a03/websockets-13.1-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e9875a0143f07d74dc5e1ded1c4581f0d9f7ab86c78994e2ed9e95050073c94d", size = 156701 }, - { url = "https://files.pythonhosted.org/packages/1f/4b/7c5b2d0d0f0f1a54f27c60107cf1f201bee1f88c5508f87408b470d09a9c/websockets-13.1-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a11e38ad8922c7961447f35c7b17bffa15de4d17c70abd07bfbe12d6faa3e027", size = 156648 }, - { url = "https://files.pythonhosted.org/packages/f3/63/35f3fb073884a9fd1ce5413b2dcdf0d9198b03dac6274197111259cbde06/websockets-13.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:4059f790b6ae8768471cddb65d3c4fe4792b0ab48e154c9f0a04cefaabcd5978", size = 159188 }, { url = "https://files.pythonhosted.org/packages/59/fd/e4bf9a7159dba6a16c59ae9e670e3e8ad9dcb6791bc0599eb86de32d50a9/websockets-13.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:25c35bf84bf7c7369d247f0b8cfa157f989862c49104c5cf85cb5436a641d93e", size = 155499 }, { url = "https://files.pythonhosted.org/packages/74/42/d48ede93cfe0c343f3b552af08efc60778d234989227b16882eed1b8b189/websockets-13.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:83f91d8a9bb404b8c2c41a707ac7f7f75b9442a0a876df295de27251a856ad09", size = 155731 }, { url = "https://files.pythonhosted.org/packages/f6/f2/2ef6bff1c90a43b80622a17c0852b48c09d3954ab169266ad7b15e17cdcb/websockets-13.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a43cfdcddd07f4ca2b1afb459824dd3c6d53a51410636a2c7fc97b9a8cf4842", size = 157093 },