Skip to content

feat: invoke callback group — spawn external work from states#571

Merged
fgmacedo merged 7 commits intodevelopfrom
macedo/invoke-callback-group
Feb 19, 2026
Merged

feat: invoke callback group — spawn external work from states#571
fgmacedo merged 7 commits intodevelopfrom
macedo/invoke-callback-group

Conversation

@fgmacedo
Copy link
Owner

@fgmacedo fgmacedo commented Feb 18, 2026

Summary

  • Adds invoke as a first-class callback group (CallbackGroup.INVOKE), following SCXML <invoke> semantics and UML's do activity (do/) concept
  • States can spawn background work on entry (sync: daemon threads, async: thread executor + asyncio.Task) and cancel it on exit
  • Supports convention naming (on_invoke_<state>), decorators (@state.invoke), inline callables, IInvoke protocol, and child StateChart invocation
  • invoke_group() runs multiple callables concurrently and waits for all results as a single done.invoke event
  • done_invoke_<state> factory prefix maps to done.invoke.<state> event family
  • Adds visit()/async_visit() visitor pattern and __contains__ to CallbacksRegistry
  • Full test suite (39 tests, sync + async) and documentation with practical file I/O examples

Example

import json
from pathlib import Path
from statemachine import State, StateChart

def load_config():
    return json.loads(Path("config.json").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()
# load_config() runs in a background thread.
# When it returns, done.invoke.loading fires automatically,
# transitioning to "ready" with the parsed config as `data`.

For concurrent work with grouped results:

from statemachine.invoke import invoke_group

class BatchLoader(StateChart):
    loading = State(
        initial=True,
        invoke=invoke_group(
            lambda: Path("file_a.txt").read_text(),
            lambda: Path("file_b.txt").read_text(),
        ),
    )
    ready = State(final=True)
    done_invoke_loading = loading.to(ready)

    def on_enter_ready(self, data=None, **kwargs):
        self.results = data  # ["contents of file_a", "contents of file_b"]

Closes #521

Add invoke as a first-class callback group (CallbackGroup.INVOKE), following
SCXML <invoke> semantics. States can spawn background work (API calls, file I/O,
child state machines) on entry and cancel it on exit.

- New CallbackGroup.INVOKE with convention naming (on_invoke_<state>),
  decorator (@state.invoke), and inline callables
- IInvoke protocol for advanced handlers with InvokeContext (cancellation,
  send events to parent, machine reference)
- StateChartInvoker adapter for child state machine invocation
- invoke_group() for running multiple callables concurrently and waiting
  for all results as a single done.invoke event
- InvokeManager lifecycle management integrated into both sync and async engines
  (sync: daemon threads, async: thread executor wrapped in asyncio.Task)
- done_invoke_<state> factory prefix maps to done.invoke.<state> event family
- visitor pattern (visit/async_visit) on CallbacksExecutor and CallbacksRegistry
- __contains__ on CallbacksRegistry to avoid direct _registry access
- Full test suite and documentation with practical file I/O examples
@codecov
Copy link

codecov bot commented Feb 18, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (3b5ef35) to head (4d29187).
⚠️ Report is 1 commits behind head on develop.

Additional details and impacted files
@@             Coverage Diff              @@
##           develop      #571      +/-   ##
============================================
+ Coverage    99.94%   100.00%   +0.05%     
============================================
  Files           32        33       +1     
  Lines         3768      4044     +276     
  Branches       583       635      +52     
============================================
+ Hits          3766      4044     +278     
+ Misses           1         0       -1     
+ Partials         1         0       -1     
Flag Coverage Δ
unittests 100.00% <100.00%> (+0.05%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

- Fix _InvokeCallableWrapper.on_cancel() to handle class handlers that
  haven't been instantiated yet (early return instead of calling unbound method)
- Replace blocking threading.Event.wait() with sm_runner.sleep() in async
  tests to avoid freezing the event loop
- Add tests for cancel_all(), cancel of terminated invocations, on_cancel
  exception suppression, StateChartInvoker.on_cancel(), normalize_invoke_callbacks
  edge cases, and _resolve_handler paths
- Coverage: 90% → 96%
- Prefix unused ctx parameter in StateChartInvoker.run() with underscore
- Remove unnecessary async from _spawn_one_async (no await in body)
- Add explicit return after swallowing CancelledError with rationale comment
- Add comment to empty on_cancel() in test to explain intent
- Test done_invoke_ prefix with Event() objects (factory.py L290-291)
- Test visit/async_visit early return for missing registry keys (callbacks.py)
- Test async_visit with awaitable visitor function (callbacks.py L378)
- Remove dead code: _needs_wrapping hasattr(item, "run") branch was
  unreachable because IInvoke protocol check catches it first
- Add test for _InvokeCallableWrapper.__call__ (L74)
- Add test for non-IInvoke class passing through normalize_invoke_callbacks
- Add test for InvokeGroup.on_cancel() before run()
- invoke.py: 0 missing lines, 4 remaining branch partials are all
  structurally unreachable (Protocol body, async race conditions)
Forward keyword arguments from the triggering event to invoke handlers:
- Plain callables receive them via SignatureAdapter dependency injection
- IInvoke handlers receive them via ctx.kwargs

This allows patterns like sm.send("start", file_name="config.json")
where the invoke handler reads file_name as a parameter.
…machine

Move visit condition tests to test_callbacks.py and invalid state value
test to test_statemachine.py. Add tests for async cancelled-during-execution,
sync error-after-cancel, and spawn_pending_async empty paths.

Add pragma: no branch to IInvoke Protocol method body (coverage.py
limitation with Protocol classes).
@sonarqubecloud
Copy link

@fgmacedo fgmacedo merged commit f1cbfbb into develop Feb 19, 2026
12 checks passed
@fgmacedo fgmacedo deleted the macedo/invoke-callback-group branch February 19, 2026 00:23
@rodrigobnogueira
Copy link
Contributor

👏👏👏 great

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add do_behavior

2 participants

Comments