Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ The critical execution order is: `main_event_loop` → `microstep` → `main_eve
query the snapshot with `state.has_tag("loading")` / `state.hasTag(...)` or read the aggregated
`state.tags` frozenset. Tags union across the whole active configuration (compound ancestors +
parallel regions) and are recomputed from the machine definition, so snapshots stay tag-free
- **`stateIn` guard** (0.7.0, `from xstate import stateIn`) — first-class guard over the current
configuration: `stateIn("#id")`, `stateIn("parent.child")`, or `stateIn({parent: child})`.
Composes with `and_`/`or_`/`not_` and can be registered as a named guard; it reuses the same
matcher as the internal transition `in` guard (`algorithm._matches_in_state`)

Handler-signature note: guards/assigners are invoked arity-aware by
`algorithm._invoke`, which supports four calling conventions: `()`, `(context)`,
Expand Down
4 changes: 3 additions & 1 deletion src/xstate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
UnregisteredImplementationError,
XStateError,
)
from xstate.guards import and_, not_, or_ # noqa
from xstate.guards import and_, not_, or_, stateIn # noqa
from xstate.handlers import HandlerArgs # noqa
from xstate.interpreter import Interpreter, interpret # noqa
from xstate.machine import Machine # noqa
Expand Down Expand Up @@ -78,6 +78,8 @@
"and_",
"or_",
"not_",
# stateIn guard (0.7.0)
"stateIn",
# Snapshot serialization (0.6.0)
"serialize_snapshot",
"deserialize_snapshot",
Expand Down
2 changes: 1 addition & 1 deletion src/xstate/algorithm.py
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,7 @@ def condition_match(
inner = cond.fn if isinstance(cond, HandlerAdapter) else cond
if isinstance(inner, _ComposableGuard):
guards = getattr(transition.source.machine, "guards", {}) or {}
if not inner._call(context, event, guards):
if not inner._call(context, event, guards, configuration):
return False
elif not bool(invoke_handler(cond, context, event, params=params)):
return False
Expand Down
101 changes: 81 additions & 20 deletions src/xstate/guards.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,30 @@
"""Composable guard combinators (0.6.0).
"""Composable guard combinators (0.6.0) + ``stateIn`` guard (0.7.0).

``and_``, ``or_``, and ``not_`` compose guards from smaller pieces without
writing wrapper lambdas. Sub-guards can be callables or strings that reference
other named guards in the machine's ``guards`` registry.
writing wrapper lambdas. Sub-guards can be callables, strings that reference
other named guards in the machine's ``guards`` registry, or other composable
guards (including :func:`stateIn`).

``stateIn`` is a guard over the *current configuration* — it passes when the
machine is in the given state — and composes with the combinators above.

Usage::

from xstate import Machine, and_, not_, or_
from xstate import Machine, and_, not_, or_, stateIn

machine = Machine(config, guards={
"isLoggedIn": lambda ctx, evt: ctx.get("logged_in"),
"hasPermission": lambda ctx, evt: ctx.get("permission"),
"canGo": and_("isLoggedIn", "hasPermission"),
"canGo": and_("isLoggedIn", "hasPermission", stateIn("#ready")),
})

Or via the ``setup()`` builder (recommended)::

from xstate import setup, and_
from xstate import setup, and_, stateIn

machine = setup(guards={
"isLoggedIn": lambda ctx, evt: ctx.get("logged_in"),
"canGo": and_("isLoggedIn", lambda ctx, evt: ctx.get("value") > 0),
"canGo": and_("isLoggedIn", stateIn("ready")),
}).create_machine(config)

String sub-guard names are resolved lazily from the machine's ``guards``
Expand All @@ -33,13 +37,21 @@


class _ComposableGuard:
"""Base class for ``and_``, ``or_``, and ``not_`` guard combinators.
"""Base class for ``and_``, ``or_``, ``not_``, and ``stateIn`` guards.

Instances are callable (``guard(context, event)``) and can be registered
directly in the machine's ``guards`` dict.
directly in the machine's ``guards`` dict. Evaluation threads the active
``configuration`` through so nested ``stateIn`` sub-guards can see it.
"""

def _eval(self, guard: Any, context: Any, event: Any, registry: dict) -> bool:
def _eval(
self,
guard: Any,
context: Any,
event: Any,
registry: dict,
configuration: Any = None,
) -> bool:
if isinstance(guard, str):
fn = registry.get(guard)
if fn is None:
Expand All @@ -49,17 +61,23 @@ def _eval(self, guard: Any, context: Any, event: Any, registry: dict) -> bool:
)
guard = fn
if isinstance(guard, _ComposableGuard):
return guard._call(context, event, registry)
return guard._call(context, event, registry, configuration)
Comment on lines 63 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Unwrap named stateIn guards before invoking

When a composable guard references a named stateIn guard, the registry value has already been wrapped in HandlerAdapter, so this isinstance(..., _ComposableGuard) check misses it and the code falls through to invoke_handler. That calls stateIn.__call__ without the current configuration, making and_("isReady"), or_("isReady", ...), or not_("isReady") evaluate as if the machine is never in that state even when guards={"isReady": stateIn("ready")} is registered. This breaks the advertised composition of named stateIn guards; unwrap HandlerAdapter here like condition_match() does before calling _call(..., configuration).

Useful? React with 👍 / 👎.

from xstate.handlers import invoke_handler

return bool(invoke_handler(guard, context, event))
Comment on lines 63 to 67

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There is a bug when a composable guard (like stateIn) is registered in the machine's guards registry and then referenced by its string name inside another composable guard (e.g., and_("switchIsOn")).

Because all registered guards are wrapped in a HandlerAdapter by the machine, isinstance(guard, _ComposableGuard) evaluates to False in _eval. This causes the guard to be invoked via invoke_handler without the active configuration, leading stateIn to always return False.

To fix this, we should unwrap the HandlerAdapter to check if the underlying guard is a _ComposableGuard before calling _call.

        from xstate.handlers import HandlerAdapter, invoke_handler

        inner = guard.fn if isinstance(guard, HandlerAdapter) else guard
        if isinstance(inner, _ComposableGuard):
            return inner._call(context, event, registry, configuration)

        return bool(invoke_handler(guard, context, event))


def _call(self, context: Any, event: Any, registry: dict) -> bool:
def _call(
self,
context: Any,
event: Any,
registry: dict,
configuration: Any = None,
) -> bool:
raise NotImplementedError

def __call__(self, context: Any = None, event: Any = None) -> bool:
"""Direct call (no registry — string sub-guards must not be used)."""
return self._call(context, event, {})
"""Direct call (no registry/configurationfor standalone use)."""
return self._call(context, event, {}, None)


class _AndGuard(_ComposableGuard):
Expand All @@ -68,8 +86,13 @@ class _AndGuard(_ComposableGuard):
def __init__(self, guards: tuple[Any, ...]):
self._guards = guards

def _call(self, context: Any, event: Any, registry: dict) -> bool:
return all(self._eval(g, context, event, registry) for g in self._guards)
def _call(
self, context: Any, event: Any, registry: dict, configuration: Any = None
) -> bool:
return all(
self._eval(g, context, event, registry, configuration)
for g in self._guards
)


class _OrGuard(_ComposableGuard):
Expand All @@ -78,8 +101,13 @@ class _OrGuard(_ComposableGuard):
def __init__(self, guards: tuple[Any, ...]):
self._guards = guards

def _call(self, context: Any, event: Any, registry: dict) -> bool:
return any(self._eval(g, context, event, registry) for g in self._guards)
def _call(
self, context: Any, event: Any, registry: dict, configuration: Any = None
) -> bool:
return any(
self._eval(g, context, event, registry, configuration)
for g in self._guards
)


class _NotGuard(_ComposableGuard):
Expand All @@ -88,8 +116,32 @@ class _NotGuard(_ComposableGuard):
def __init__(self, guard: Any):
self._guard = guard

def _call(self, context: Any, event: Any, registry: dict) -> bool:
return not self._eval(self._guard, context, event, registry)
def _call(
self, context: Any, event: Any, registry: dict, configuration: Any = None
) -> bool:
return not self._eval(self._guard, context, event, registry, configuration)


class _StateInGuard(_ComposableGuard):
"""Guard that passes when the machine is in the given state (v5 ``stateIn``).

The *spec* uses the same syntax as a transition ``in`` guard: ``"#id"`` for
an explicit id, a dotted ``"parent.child"`` path, or a ``{parent: child}``
dict. Evaluation needs the active configuration; when called without one
(e.g. directly, outside a transition) it returns ``False``.
"""

def __init__(self, spec: Any):
self._spec = spec

def _call(
self, context: Any, event: Any, registry: dict, configuration: Any = None
) -> bool:
if configuration is None:
return False
from xstate.algorithm import _matches_in_state

return _matches_in_state(self._spec, configuration)
Comment on lines +140 to +144


def and_(*guards: Any) -> _AndGuard:
Expand All @@ -105,3 +157,12 @@ def or_(*guards: Any) -> _OrGuard:
def not_(guard: Any) -> _NotGuard:
"""Return a guard that passes when *guard* does NOT pass."""
return _NotGuard(guard)


def stateIn(spec: Any) -> _StateInGuard:
"""Return a guard that passes when the machine is currently in *spec*.

*spec* is ``"#id"``, a dotted ``"parent.child"`` path, or a ``{parent: child}``
dict — the same shape accepted by a transition ``in`` guard.
Comment on lines +165 to +166
"""
return _StateInGuard(spec)
2 changes: 1 addition & 1 deletion src/xstate/machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ def _get_actions(
result.append(
self._bind_action(
action,
self.actions[action.type],
self.actions[action.type], # type: ignore[index]
context,
event,
)
Expand Down
Loading
Loading