Skip to content

Commit b52d534

Browse files
authored
fix: reject callback names colliding between nested state bodies (#674)
1 parent dbf8eb0 commit b52d534

8 files changed

Lines changed: 880 additions & 8 deletions

File tree

‎docs/releases/3.2.2.md‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,83 @@ Reported by [@Dolecor](https://github.com/Dolecor).
7676

7777
[#643](https://github.com/fgmacedo/python-statemachine/issues/643).
7878

79+
### Callback names colliding between nested state bodies
80+
81+
Callbacks declared inside a `State.Compound` or `State.Parallel` body are attached to the
82+
statechart class under their own names, with no reference to the owning state. Two sibling
83+
regions declaring the same callback name collided, and the last body silently won: with a
84+
`@start.to(end) def go` in each region of a parallel state, `send("go")` moved both regions but
85+
ran only the second body. The same happened to a plain callback such as `on_enter_<state>`
86+
declared in two bodies, and to a nested body overriding a method of the class body.
87+
88+
A callback attached through a decorator, or by passing the function itself (`cond=ready`), now
89+
runs in every body that declares it:
90+
91+
```py
92+
>>> from statemachine import State, StateChart
93+
>>> from statemachine.exceptions import InvalidDefinition
94+
95+
>>> class War(StateChart):
96+
... class war(State.Parallel):
97+
... class quest(State.Compound):
98+
... start = State(initial=True)
99+
... end = State(final=True)
100+
...
101+
... @start.to(end)
102+
... def go(self):
103+
... print("quest")
104+
...
105+
... class battle(State.Compound):
106+
... fighting = State(initial=True)
107+
... won = State(final=True)
108+
...
109+
... @fighting.to(won)
110+
... def go(self):
111+
... print("battle")
112+
113+
>>> sm = War()
114+
>>> _ = sm.send("go")
115+
quest
116+
battle
117+
118+
```
119+
120+
A name reached by name, through a naming convention, a string reference or a plain method,
121+
cannot tell the bodies apart. Such a name declared in two nested bodies, or in a nested body and
122+
the class body, is now rejected at class creation:
123+
124+
```py
125+
>>> try:
126+
... class War(StateChart):
127+
... class war(State.Parallel):
128+
... class quest(State.Compound):
129+
... start = State(initial=True)
130+
... end = State(final=True)
131+
... go = start.to(end)
132+
...
133+
... def on_enter_end(self):
134+
... print("quest")
135+
...
136+
... class battle(State.Compound):
137+
... fighting = State(initial=True)
138+
... won = State(final=True)
139+
... go = fighting.to(won)
140+
...
141+
... def on_enter_end(self):
142+
... print("battle")
143+
... except InvalidDefinition as e:
144+
... print(e)
145+
Callback 'on_enter_end' is declared in both the 'quest' and the 'battle' state bodies. Nested state bodies share the class namespace. Rename one of them.
146+
147+
```
148+
149+
Assigning the same callable under one name in several bodies is still allowed, and so is
150+
overriding a method inherited from a base class. Giving
151+
each nested body its own namespace for names, so that they can be reused across regions, is
152+
tracked by [#657](https://github.com/fgmacedo/python-statemachine/issues/657).
153+
154+
[#653](https://github.com/fgmacedo/python-statemachine/issues/653).
155+
79156
### Subclassing a chart with callbacks in a `State.Compound` body
80157

81158
Subclassing a `StateChart` whose compound (or parallel) body declared any callback, as a plain

‎docs/states.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,11 @@ True
162162

163163
```
164164

165+
Regions run independently, but their class bodies share one namespace. Callbacks attached
166+
through a decorator, or by passing the function itself, may reuse a name across bodies and each
167+
runs in its own body. Any other reuse of a name, such as a naming-convention callback, a string
168+
reference or a plain method, raises `InvalidDefinition` (see {ref}`callback-name-collision`).
169+
165170
```{seealso}
166171
See {ref}`done-state-events` for how `done.state` events work with parallel
167172
states (all regions must reach a final state).

‎docs/validations.md‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,47 @@ The `donedata` parameter can only be used on states marked as `final=True`:
183183
```
184184

185185

186+
(callback-name-collision)=
187+
188+
### Callback names across nested state bodies
189+
190+
Callbacks declared inside a `State.Compound` or `State.Parallel` body are attached to the
191+
statechart class under their own names, so nested bodies share one namespace with each other
192+
and with the class body. A name reached by name (a naming convention such as
193+
`on_enter_<state>`, a string reference such as `cond="ready"`, or a plain method called as
194+
`self.ready()`) declared twice would keep only the last body:
195+
196+
```py
197+
>>> try:
198+
... class Bad(StateChart):
199+
... class war(State.Parallel):
200+
... class quest(State.Compound):
201+
... start = State(initial=True)
202+
... end = State(final=True)
203+
... go = start.to(end)
204+
...
205+
... def on_enter_end(self):
206+
... print("quest")
207+
...
208+
... class battle(State.Compound):
209+
... fighting = State(initial=True)
210+
... won = State(final=True)
211+
... go = fighting.to(won)
212+
...
213+
... def on_enter_end(self):
214+
... print("battle")
215+
... except InvalidDefinition as e:
216+
... print(e)
217+
Callback 'on_enter_end' is declared in both the 'quest' and the 'battle' state bodies. Nested state bodies share the class namespace. Rename one of them.
218+
219+
```
220+
221+
The same callable assigned under one name in several bodies is not a collision, and a nested
222+
body may override a method inherited from a base class. A plain method named like an event or
223+
another attribute of the class is also rejected. Callbacks attached through a decorator, or by
224+
passing the function itself, may share a name, even with an event: each runs in the body that
225+
declares it, as long as no naming convention, string reference or plain method uses that name.
226+
186227
### Invalid listener entries
187228

188229
Entries in the `listeners` class attribute must be classes, callables, or
@@ -280,6 +321,7 @@ Expressions support `and`, `or`, `not`, and parentheses. See
280321
| Initial transitions have no cond | Class definition| No |
281322
| `donedata` on final states only | Class definition| No |
282323
| Invalid listener entries | Class definition| No |
324+
| Callback names in nested bodies | Class definition| No |
283325
| Callback resolution | Instance creation | No |
284326
| Boolean expression parsing | Instance creation | No |
285327

‎statemachine/callbacks.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from enum import auto
99
from functools import partial
1010
from inspect import isawaitable
11+
from inspect import isfunction
12+
from typing import Any
1113

1214
from .exceptions import AttrNotFound
1315
from .i18n import _
@@ -18,6 +20,11 @@ def allways_true(*args, **kwargs):
1820
return True
1921

2022

23+
def is_unbounded_reference(func: Any) -> bool:
24+
"""Whether ``func`` is a function that a `CallbackSpec` holds by object."""
25+
return isfunction(func) and hasattr(func, "attr_name")
26+
27+
2128
class CallbackPriority(IntEnum):
2229
GENERIC = 0
2330
INLINE = 10
@@ -103,6 +110,10 @@ def __init__(
103110
and self.reference == SpecReference.NAME
104111
)
105112

113+
self.lookup_name: str = self.attr_name
114+
"""Attribute of the machine holding an unbounded callable. Differs from `attr_name`
115+
when nested state bodies share the name and each function is stored apart."""
116+
106117
def __repr__(self):
107118
return f"{type(self).__name__}({self.func!r}, is_convention={self.is_convention!r})"
108119

‎statemachine/dispatcher.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,14 +161,15 @@ def _search_callable(self, spec):
161161
# if the attr is an unbounded method, we'll try to find the bounded method
162162
# on the self
163163
if not spec.is_bounded:
164+
attr_name = spec.lookup_name
164165
for listener in self.items:
165-
func = getattr(listener.obj, spec.attr_name, None)
166+
func = getattr(listener.obj, attr_name, None)
166167
# ``getattr`` may return a non-method that happens to share the name
167168
# (e.g. a model attribute named like a compiled guard); it is not the
168169
# unbounded method we are rebinding, so skip it instead of accessing
169170
# ``__func__`` (which would raise on a plain value).
170171
if getattr(func, "__func__", None) is spec.func:
171-
yield listener.build_key(spec.attr_name), partial(callable_method, func)
172+
yield listener.build_key(attr_name), partial(callable_method, func)
172173
return
173174

174175
yield f"{spec.attr_name}-{id(spec.func)}@None", partial(callable_method, spec.func)

‎statemachine/factory.py‎

Lines changed: 103 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from .callbacks import CallbackGroup
66
from .callbacks import CallbackPriority
77
from .callbacks import CallbackSpecList
8+
from .callbacks import SpecReference
9+
from .callbacks import is_unbounded_reference
810
from .event import Event
911
from .event import _expand_event_id
1012
from .exceptions import InvalidDefinition
@@ -13,12 +15,25 @@
1315
from .graph import iterate_states_and_transitions
1416
from .graph import states_without_path_to_final_states
1517
from .i18n import _
18+
from .spec_parser import names_in_boolean_expr
1619
from .state import State
1720
from .states import States
1821
from .transition import Transition
1922
from .transition_list import TransitionList
2023

2124

25+
def _same_callable(func: Any, other: Any) -> bool:
26+
return type(func) is type(other) and func == other
27+
28+
29+
def _can_store_apart(existing: Any, owners: list[tuple[State, Any]]) -> bool:
30+
"""Only callbacks held by object can move to a key of their own: a name is still
31+
reachable through ``self.<name>``, so it must not lose a plain method or attribute."""
32+
return all(is_unbounded_reference(func) for _, func in owners) and (
33+
existing is None or isinstance(existing, Event) or is_unbounded_reference(existing)
34+
)
35+
36+
2237
class StateMachineMetaclass(type):
2338
"Metaclass for constructing StateMachine classes"
2439

@@ -59,7 +74,6 @@ def __init__(
5974
cls.add_inherited(bases)
6075
cls.add_from_attributes(attrs)
6176
cls._collect_class_listeners(attrs, bases)
62-
cls._unpack_builders_callbacks()
6377
cls._update_event_references()
6478

6579
if not cls.states:
@@ -88,6 +102,7 @@ def __init__(
88102

89103
cls._check()
90104
cls._setup()
105+
cls._unpack_builders_callbacks(attrs)
91106
cls._expand_docstring()
92107

93108
_STATECHART_RE = re.compile(r"\{statechart:(\w+)\}")
@@ -163,14 +178,96 @@ def _initials_by_document_order( # noqa: C901
163178
if not any(t for t in parent.transitions if t.initial and t.target == state):
164179
parent.to(state, initial=True) # pragma: no cover
165180

166-
def _unpack_builders_callbacks(cls):
167-
callbacks = {}
181+
def _unpack_builders_callbacks(cls, attrs: dict[str, Any]):
182+
names_resolved_by_name = cls._names_resolved_by_name()
183+
relocated: dict[Any, str] = {}
184+
for attr_name, owners in cls._nested_callbacks().items():
185+
existing = cls.__dict__.get(attr_name)
186+
owners = [(s, f) for s, f in owners if not _same_callable(f, existing)]
187+
if not owners:
188+
continue
189+
if existing is None and len(owners) == 1:
190+
setattr(cls, attr_name, owners[0][1])
191+
continue
192+
if attr_name in names_resolved_by_name or not _can_store_apart(existing, owners):
193+
cls._raise_callback_collision(attr_name, existing, owners, attrs)
194+
for state, func in owners:
195+
relocated[func] = f"{state.id}:{attr_name}"
196+
setattr(cls, relocated[func], func)
197+
own_states = [value for value in attrs.values() if isinstance(value, State)]
198+
cls._update_lookup_names(relocated, own_states)
199+
200+
def _nested_callbacks(cls) -> dict[str, list[tuple[State, Any]]]:
201+
"""Distinct callables declared under each name, with the state body declaring them."""
202+
claims: dict[str, list[tuple[State, Any]]] = {}
168203
for state in iterate_states(cls.states):
169204
if state._callbacks:
170-
callbacks.update(state._callbacks)
205+
for attr_name, func in state._callbacks.items():
206+
owners = claims.setdefault(attr_name, [])
207+
if not any(_same_callable(func, other) for _, other in owners):
208+
owners.append((state, func))
171209
state._callbacks = None
172-
for key, value in callbacks.items():
173-
setattr(cls, key, value)
210+
return claims
211+
212+
def _update_lookup_names(cls, relocated: dict[Any, str], own_states: list[State]):
213+
"""Inherited states share their specs with the base class, which must keep its own."""
214+
if not relocated:
215+
return
216+
for visited in iterate_states_and_transitions(own_states):
217+
for spec in visited._specs:
218+
if spec.reference is SpecReference.CALLABLE and spec.func in relocated:
219+
spec.lookup_name = relocated[spec.func]
220+
221+
def _all_specs(cls):
222+
yield cls._specs
223+
for visited in iterate_states_and_transitions(cls.states):
224+
yield visited._specs
225+
226+
def _names_resolved_by_name(cls) -> set[str]:
227+
names: set[str] = set()
228+
for specs in cls._all_specs():
229+
for spec in specs:
230+
if spec.may_contain_boolean_expression:
231+
names.update(names_in_boolean_expr(spec.attr_name))
232+
elif spec.reference is SpecReference.NAME:
233+
names.add(spec.attr_name)
234+
return names
235+
236+
def _raise_callback_collision(
237+
cls,
238+
attr_name: str,
239+
existing: Any,
240+
owners: list[tuple[State, Any]],
241+
attrs: dict[str, Any],
242+
):
243+
class_body_names = set(attrs)
244+
class_body_names.update(
245+
value.attr_name for value in attrs.values() if getattr(value, "attr_name", None)
246+
)
247+
state, func = owners[0]
248+
if existing is None:
249+
conflict = _("is declared in both the '{}' and the '{}' state bodies").format(
250+
state.id, owners[1][0].id
251+
)
252+
elif isinstance(existing, Event):
253+
conflict = _("in the '{}' state body clashes with the event '{}'").format(
254+
state.id, existing.id
255+
)
256+
elif attr_name in class_body_names:
257+
conflict = _("is declared in both the '{}' state body and the '{}' class body").format(
258+
state.id, cls.__name__
259+
)
260+
else:
261+
conflict = _("in the '{}' state body clashes with the '{}' attribute of '{}'").format(
262+
state.id, attr_name, cls.__name__
263+
)
264+
name = func.__name__ if getattr(func, "is_event", False) else attr_name
265+
raise InvalidDefinition(
266+
_(
267+
"Callback '{}' {}. Nested state bodies share the class namespace. "
268+
"Rename one of them."
269+
).format(name, conflict)
270+
)
174271

175272
def _check(cls):
176273
has_states = bool(cls.states)

‎statemachine/spec_parser.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,15 @@ def parse_boolean_expr(expr, variable_hook, operator_mapping):
339339
return build_expression(tree.body, variable_hook, operator_mapping)
340340

341341

342+
def names_in_boolean_expr(expr: str) -> "set[str]":
343+
"""Variable names that :func:`parse_boolean_expr` would resolve in ``expr``."""
344+
try:
345+
tree = ast.parse(replace_operators(expr), mode="eval")
346+
except SyntaxError:
347+
return set()
348+
return {node.id for node in ast.walk(tree) if isinstance(node, ast.Name)}
349+
350+
342351
def parse_expr(expr: str, variable_hook: Callable) -> Callable:
343352
"""Parse a value expression into a callable using the restricted AST allowlist.
344353

0 commit comments

Comments
 (0)