Skip to content

Commit 33edf3b

Browse files
committed
feat: fix async invoke, add variant-specific fail marks for SCXML tests
- Fix async _enter_states missing states_to_invoke tracking - Add variant-specific fail marks (sync/async) to SCXML test system so tests can pass sync but xfail async independently - Remove fail.md for 12 invoke tests that now pass both sync and async - Convert 6 tests to async-only fail marks (pass sync, timing issue async)
1 parent 0780e4f commit 33edf3b

38 files changed

Lines changed: 622 additions & 622 deletions

INVOKE_PLAN.md

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# SCXML `<invoke>` Implementation Plan
2+
3+
## Overview
4+
5+
Implement SCXML `<invoke>` support (W3C spec §6.4) for spawning child state machine
6+
sessions when entering a state, with automatic cancellation on exit.
7+
8+
**Branch:** `feat/invoke`
9+
**Base:** `develop`
10+
**Worktree:** `../python-statemachine-invoke`
11+
12+
---
13+
14+
## Steps
15+
16+
### 1. Create worktree and branch
17+
- [x] Create git worktree and branch `feat/invoke`
18+
19+
### 2. Schema + Parser SCXML
20+
- [x] Add `InvokeDefinition` dataclass to `schema.py`
21+
- [x] Add `invocations` field to `schema.State`
22+
- [x] Add `parse_invoke()` to `parser.py`
23+
- [x] Call from `parse_state()` for `<invoke>` elements
24+
- [x] **Commit:** `feat: parse SCXML <invoke> elements` (fe19eca → rebased to 6d8067d)
25+
26+
### 3. InvokeConfig and InvokeManager
27+
- [x] Create `statemachine/invoke.py` with:
28+
- `InvokeConfig` — static configuration
29+
- `Invocation` — runtime state
30+
- `ParentBridge` — listener on child for `#_parent` sends
31+
- `InvokeManager` — spawn/cancel/finalize lifecycle
32+
- [x] **Commit:** `feat: add InvokeManager for child session lifecycle` (76b9627 → rebased to 2b94c34)
33+
34+
### 4. Engine integration — spawn and cancel
35+
- [x] Add `invoke_manager` and `states_to_invoke` to `BaseEngine.__init__`
36+
- [x] Track states with invocations in `_enter_states()`
37+
- [x] Cancel invocations in `_exit_states()` and `_handle_final_state()`
38+
- [x] Replace TODO in `sync.py` with actual invoke spawn code
39+
- [x] Replace TODO in `sync.py` with finalize + autoforward
40+
- [x] Mirror changes in `async_.py`
41+
- [ ] **Commit** (pending — code written, needs final test pass)
42+
43+
### 5. Event routing (#_parent, invokeid, done.invoke)
44+
- [x] Add `invokeid` field to `TriggerData` in `event_data.py`
45+
- [x] Update `Event.put()` and `Event.build_trigger()` to accept `invokeid`
46+
- [x] Update `StateChart.send()` to accept `invokeid`
47+
- [x] Update `EventDataWrapper.__init__` to read `invokeid` from trigger_data
48+
- [x] Implement `#_parent` target in `create_send_action_callable()`
49+
- [x] Implement `#_child` target
50+
- [x] Implement `#_<invokeid>` target
51+
- [x] Fix `_eval_send_params` duplicate `machine` kwarg bug
52+
- [x] Fix `#_scxml_` vs `#_invokeid` target ordering (test496/521 regression)
53+
- [ ] **Commit** (pending — code written, needs final test pass)
54+
55+
### 6. done_invoke_ naming convention and State(invoke=...) API
56+
- [x] Add `invoke` parameter to `State.__init__()`
57+
- [x] Add `_normalize_invoke()` static method
58+
- [x] Add `invocations` property to `InstanceState`
59+
- [x] Add `invoke` to `BaseStateKwargs` in `io/__init__.py`
60+
- [ ] Add `done_invoke_` handler in `factory.py` (not yet implemented)
61+
- [ ] **Commit**
62+
63+
### 7. SCXML advanced invoke features
64+
- [x] `src` — load SCXML from external file
65+
- [x] `namelist` / `<param>` — pass initial data to child
66+
- [x] `autoforward` — forward all external events to child
67+
- [x] `<finalize>` — execute before processing child event
68+
- [ ] `typeexpr` — evaluate expression for type (not yet)
69+
- [ ] `srcexpr` — evaluate expression for src (not yet)
70+
- [ ] **Commit**
71+
72+
### 8. Run W3C invoke tests and fix failures
73+
- [ ] Remove `.fail.md` for passing tests
74+
- [ ] Group failures by root cause and fix
75+
- [ ] Current status (post-rebase, post-bug-fixes):
76+
- Tests that now xpass (need .fail.md removed): test191, test207, test220, test223, test228,
77+
test232, test233, test235, test237, test241, test242, test245, test247, test338, test347,
78+
test422, test554
79+
- Tests still expected to fail: test187, test192, test216, test226, test229, test234, test236,
80+
test240, test243, test244, test276, test530
81+
- [ ] **Commits per group**
82+
83+
### 9. Unit tests for Python invoke API
84+
- [ ] Create `tests/test_invoke.py`
85+
- [ ] Test basic: `State(invoke=ChildMachine)`, child terminates, parent gets `done.invoke`
86+
- [ ] Test cancellation: parent exits state, child is cancelled
87+
- [ ] Test cross-engine: sync parent + async child, async parent + sync child
88+
- [ ] Test `done_invoke_` naming convention
89+
- [ ] Test autoforward
90+
- [ ] Test multiple invocations
91+
- [ ] Use `sm_runner` fixture for sync/async coverage
92+
- [ ] **Commit**
93+
94+
### 10. Documentation and release notes
95+
- [ ] Create `docs/invoke.md` (concept, Python API, SCXML, examples)
96+
- [ ] Add to `docs/index.md` toctree
97+
- [ ] Add section in `docs/releases/3.0.0.md`
98+
- [ ] Reference from `docs/statecharts.md`
99+
- [ ] **Commit**
100+
101+
### 11. Final cleanup and verification
102+
- [ ] `uv run ruff check .` — all clean
103+
- [ ] `uv run ruff format .` — all clean
104+
- [ ] `uv run mypy statemachine/` — no errors
105+
- [ ] `uv run pytest -n auto` — full suite passes
106+
- [ ] Verify W3C invoke tests pass (`.fail.md` removed for passing tests)
107+
- [ ] Fix any regressions
108+
109+
---
110+
111+
## Key Files
112+
113+
| File | Status |
114+
|------|--------|
115+
| `statemachine/invoke.py` | Committed + uncommitted changes |
116+
| `statemachine/engines/base.py` | Uncommitted changes |
117+
| `statemachine/engines/sync.py` | Uncommitted changes |
118+
| `statemachine/engines/async_.py` | Uncommitted changes |
119+
| `statemachine/event.py` | Uncommitted changes |
120+
| `statemachine/event_data.py` | Uncommitted changes |
121+
| `statemachine/state.py` | Uncommitted changes |
122+
| `statemachine/statemachine.py` | Uncommitted changes |
123+
| `statemachine/io/__init__.py` | Uncommitted changes |
124+
| `statemachine/io/scxml/actions.py` | Uncommitted changes |
125+
| `statemachine/io/scxml/processor.py` | Uncommitted changes |
126+
| `statemachine/io/scxml/schema.py` | Committed |
127+
| `statemachine/io/scxml/parser.py` | Committed |
128+
129+
## Bugs Fixed
130+
131+
1. **`_eval_send_params` duplicate `machine` kwarg** — function took `machine` as positional
132+
AND received it via `**kwargs`. Fixed by taking only `**kwargs` and extracting `machine` from it.
133+
2. **`#_scxml_` vs `#_invokeid` target ordering**`#_scxml_foo` targets were caught by the
134+
generic `#_` invoke handler before reaching the `#_scxml_``error.communication` handler.
135+
Fixed by checking `#_scxml_` first.
136+
3. **Child `_parent_sm` not set during initial entry** — fixed by setting `_parent_sm` and
137+
`_invokeid` on the child CLASS before instantiation.

statemachine/engines/async_.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,10 @@ async def _enter_states( # noqa: C901
249249
new_configuration=new_configuration,
250250
)
251251

252+
# Track states with invocations for post-macrostep spawning
253+
if getattr(target, "invocations", None):
254+
self.states_to_invoke.add(target)
255+
252256
# Handle final states
253257
if target.final:
254258
self._handle_final_state(target, on_entry_result)

tests/scxml/conftest.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,24 @@ def processor(testcase_path: Path):
2424
return processor
2525

2626

27-
def compute_testcase_marks(testcase_path: Path) -> list[pytest.MarkDecorator]:
27+
def compute_testcase_marks(
28+
testcase_path: Path, variant: str = "",
29+
) -> list[pytest.MarkDecorator]:
2830
marks = [pytest.mark.scxml]
29-
if testcase_path.with_name(f"{testcase_path.stem}.fail.md").exists():
31+
32+
# Check variant-specific fail/skip first (e.g., test191.async.fail.md),
33+
# then fall back to the generic one (e.g., test191.fail.md).
34+
stem = testcase_path.stem
35+
parent = testcase_path.parent
36+
37+
def _has_mark(suffix: str) -> bool:
38+
if variant and (parent / f"{stem}.{variant}.{suffix}").exists():
39+
return True
40+
return (parent / f"{stem}.{suffix}").exists()
41+
42+
if _has_mark("fail.md"):
3043
marks.append(pytest.mark.xfail)
31-
if testcase_path.with_name(f"{testcase_path.stem}.skip.md").exists():
44+
if _has_mark("skip.md"):
3245
marks.append(pytest.mark.skip)
3346
return marks
3447

@@ -37,13 +50,22 @@ def pytest_generate_tests(metafunc):
3750
if "testcase_path" not in metafunc.fixturenames:
3851
return
3952

53+
# Determine variant from the test function name
54+
func_name = metafunc.function.__name__
55+
if func_name.endswith("_async"):
56+
variant = "async"
57+
elif func_name.endswith("_sync"):
58+
variant = "sync"
59+
else:
60+
variant = ""
61+
4062
metafunc.parametrize(
4163
"testcase_path",
4264
[
4365
pytest.param(
4466
testcase_path,
4567
id=str(testcase_path.relative_to(TESTCASES_DIR)),
46-
marks=compute_testcase_marks(testcase_path),
68+
marks=compute_testcase_marks(testcase_path, variant),
4769
)
4870
for testcase_path in TESTCASES_DIR.glob("**/*.scxml")
4971
if "sub" not in testcase_path.name

tests/scxml/test_scxml_cases.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,9 @@ def _get_header(report: str) -> str:
8383
header_end_index = report.find("---")
8484
return report[:header_end_index]
8585

86-
def write_fail_markdown(self, testcase_path: Path):
87-
fail_file_path = testcase_path.with_suffix(".fail.md")
86+
def write_fail_markdown(self, testcase_path: Path, variant: str = ""):
87+
suffix = f".{variant}.fail.md" if variant else ".fail.md"
88+
fail_file_path = testcase_path.with_name(f"{testcase_path.stem}{suffix}")
8889
if not self.is_assertion_error:
8990
exception_traceback = "".join(
9091
traceback.format_exception(
@@ -142,6 +143,7 @@ def _run_scxml_testcase(
142143
caplog,
143144
*,
144145
async_mode: bool = False,
146+
variant: str = "",
145147
) -> StateChart:
146148
"""Shared logic for sync and async SCXML test variants.
147149
@@ -178,7 +180,7 @@ def _run_scxml_testcase(
178180
logs=caplog.text,
179181
configuration=[s.id for s in sm.configuration] if sm else [],
180182
)
181-
fail_mark.write_fail_markdown(testcase_path)
183+
fail_mark.write_fail_markdown(testcase_path, variant=variant)
182184
raise
183185

184186

@@ -196,6 +198,7 @@ def test_scxml_usecase_sync(
196198
should_generate_debug_diagram,
197199
caplog,
198200
async_mode=False,
201+
variant="sync",
199202
)
200203
_assert_passed(sm)
201204

@@ -210,6 +213,7 @@ async def test_scxml_usecase_async(
210213
should_generate_debug_diagram,
211214
caplog,
212215
async_mode=True,
216+
variant="async",
213217
)
214218
# In async context, the engine only queued __initial__ during __init__.
215219
# Activate now within the running event loop.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Testcase: test187
2+
3+
Invoke test failure.

tests/scxml/w3c/mandatory/test191.fail.md

Lines changed: 0 additions & 31 deletions
This file was deleted.

tests/scxml/w3c/mandatory/test207.fail.md

Lines changed: 0 additions & 31 deletions
This file was deleted.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Testcase: test215 (async)
2+
3+
Async invoke timing: child events not reaching parent before timeout.

tests/scxml/w3c/mandatory/test215.fail.md

Lines changed: 0 additions & 32 deletions
This file was deleted.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Testcase: test220 (async)
2+
3+
Async invoke timing: child events not reaching parent before timeout.

0 commit comments

Comments
 (0)