|
5 | 5 | from .callbacks import CallbackGroup |
6 | 6 | from .callbacks import CallbackPriority |
7 | 7 | from .callbacks import CallbackSpecList |
| 8 | +from .callbacks import SpecReference |
| 9 | +from .callbacks import is_unbounded_reference |
8 | 10 | from .event import Event |
9 | 11 | from .event import _expand_event_id |
10 | 12 | from .exceptions import InvalidDefinition |
|
13 | 15 | from .graph import iterate_states_and_transitions |
14 | 16 | from .graph import states_without_path_to_final_states |
15 | 17 | from .i18n import _ |
| 18 | +from .spec_parser import names_in_boolean_expr |
16 | 19 | from .state import State |
17 | 20 | from .states import States |
18 | 21 | from .transition import Transition |
19 | 22 | from .transition_list import TransitionList |
20 | 23 |
|
21 | 24 |
|
| 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 | + |
22 | 37 | class StateMachineMetaclass(type): |
23 | 38 | "Metaclass for constructing StateMachine classes" |
24 | 39 |
|
@@ -59,7 +74,6 @@ def __init__( |
59 | 74 | cls.add_inherited(bases) |
60 | 75 | cls.add_from_attributes(attrs) |
61 | 76 | cls._collect_class_listeners(attrs, bases) |
62 | | - cls._unpack_builders_callbacks() |
63 | 77 | cls._update_event_references() |
64 | 78 |
|
65 | 79 | if not cls.states: |
@@ -88,6 +102,7 @@ def __init__( |
88 | 102 |
|
89 | 103 | cls._check() |
90 | 104 | cls._setup() |
| 105 | + cls._unpack_builders_callbacks(attrs) |
91 | 106 | cls._expand_docstring() |
92 | 107 |
|
93 | 108 | _STATECHART_RE = re.compile(r"\{statechart:(\w+)\}") |
@@ -163,14 +178,96 @@ def _initials_by_document_order( # noqa: C901 |
163 | 178 | if not any(t for t in parent.transitions if t.initial and t.target == state): |
164 | 179 | parent.to(state, initial=True) # pragma: no cover |
165 | 180 |
|
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]]] = {} |
168 | 203 | for state in iterate_states(cls.states): |
169 | 204 | 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)) |
171 | 209 | 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 | + ) |
174 | 271 |
|
175 | 272 | def _check(cls): |
176 | 273 | has_states = bool(cls.states) |
|
0 commit comments