Skip to content

Commit c6445c8

Browse files
authored
refactor!: modernize codebase for v3 — remove deprecated APIs, migrate tests to StateChart (#565)
* feat!: remove add_observer() and short registry names (deprecated since v2.x) - Remove `add_observer()` method (deprecated v2.3.2, use `add_listener()`) - Remove short name registration in registry (deprecated v0.8, use fully qualified names) - Update release notes and upgrade guide accordingly * feat!: change States.from_enum default to use_enum_instance=True Fulfills the deprecation promise from v2.3.3. The enum instance is now used as the state value by default. Pass use_enum_instance=False to get the previous behavior of using the raw enum value. * test: add dedicated backward-compat tests for StateMachine (v2 API) Cover all four flag defaults, TransitionNotAllowed behavior (sync and async), error_on_execution=False propagation, self-transition entries, current_state deprecated property, and basic smoke tests. * refactor(tests): migrate conftest fixtures from StateMachine to StateChart - Switch all 7 inline fixture classes to StateChart - Add error_on_execution=False to validator fixture (test expects direct propagation) - Remove classic_traffic_light_machine_allow_event subclass (redundant with StateChart default) - Remove TransitionNotAllowed tests from test_statemachine.py (covered by compat tests) * refactor(tests): migrate main test files from StateMachine to StateChart - test_statemachine.py: all classes → StateChart, current_state → is_active - test_copy.py: all classes → StateChart, current_state → is_active - test_async.py: all classes → StateChart with explicit flags where needed (allow_event_without_transition=False, error_on_execution=False) * refactor(tests): migrate remaining test files from StateMachine to StateChart Migrate 18 test files to use StateChart as base class. For tests that depend on StateMachine-specific behavior (TransitionNotAllowed, error propagation), explicit flags are added (allow_event_without_transition=False, error_on_execution=False). * refactor(tests): migrate testcases, Django and SCXML tests to StateChart * docs: update source doctests to use StateChart as primary API * docs: update release notes and upgrade guide for v3 breaking changes
1 parent 4654aba commit c6445c8

38 files changed

Lines changed: 790 additions & 378 deletions

docs/releases/3.0.0.md

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -497,10 +497,9 @@ def on_validate(self, previous_configuration):
497497
```
498498

499499

500-
### `add_observer()` renamed to `add_listener()`
500+
### `add_observer()` removed
501501

502-
The method `add_observer` has been renamed to `add_listener`. The old name still works but emits
503-
a `DeprecationWarning`.
502+
The method `add_observer`, deprecated since v2.3.2, has been removed. Use `add_listener` instead.
504503

505504

506505
### `TransitionNotAllowed` exception changes
@@ -516,3 +515,20 @@ The `allow_event_without_transition` was previously configured as an init parame
516515
attribute.
517516

518517
Defaults to `False` in `StateMachine` class to preserve maximum backwards compatibility.
518+
519+
520+
### `States.from_enum` default `use_enum_instance=True`
521+
522+
The `use_enum_instance` parameter of `States.from_enum` now defaults to `True` (was `False` in 2.x).
523+
This means state values are the enum instances themselves, not their raw values.
524+
525+
If your code relies on raw enum values (e.g., integers), pass `use_enum_instance=False` explicitly.
526+
527+
528+
### Short registry names removed
529+
530+
State machine classes are now only registered by their fully-qualified name (`qualname`).
531+
The short-name lookup (by `cls.__name__`) that was deprecated since v0.8 has been removed.
532+
533+
If you use `get_machine_cls()` (e.g., via `MachineMixin`), make sure you pass the fully-qualified
534+
dotted path.

docs/releases/upgrade_2x_to_3.md

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ defaults. Review this guide to understand what changed and adopt the new APIs at
1717
5. Replace `sm.add_observer(...)` with `sm.add_listener(...)`.
1818
6. Update code that catches `TransitionNotAllowed` and accesses `.state` → use `.configuration`.
1919
7. Review `on` callbacks that query `is_active` or `current_state` during transitions.
20+
8. If using `States.from_enum`, note that `use_enum_instance` now defaults to `True`.
21+
9. If using `get_machine_cls()` with short names, switch to fully-qualified names.
2022

2123
---
2224

@@ -159,8 +161,7 @@ while not sm.is_terminated:
159161

160162
## Replace `add_observer()` with `add_listener()`
161163

162-
The method `add_observer` has been renamed to `add_listener`. The old name still works but emits
163-
a `DeprecationWarning`.
164+
The method `add_observer` has been removed in v3.0. Use `add_listener` instead.
164165

165166
**Before (2.x):**
166167

@@ -175,6 +176,51 @@ sm.add_listener(my_listener)
175176
```
176177

177178

179+
## `States.from_enum` default changed to `use_enum_instance=True`
180+
181+
In 2.x, `States.from_enum` defaulted to `use_enum_instance=False`, meaning state values were the
182+
raw enum values (e.g., integers). In 3.0, the default is `True`, so state values are the enum
183+
instances themselves.
184+
185+
**Before (2.x):**
186+
187+
```python
188+
states = States.from_enum(MyEnum, initial=MyEnum.start)
189+
# states.start.value == 1 (raw value)
190+
```
191+
192+
**After (3.0):**
193+
194+
```python
195+
states = States.from_enum(MyEnum, initial=MyEnum.start)
196+
# states.start.value == MyEnum.start (enum instance)
197+
```
198+
199+
If your code relies on raw enum values, pass `use_enum_instance=False` explicitly.
200+
201+
202+
## Short registry names removed
203+
204+
In 2.x, state machine classes were registered both by their fully-qualified name and their short
205+
class name. The short-name lookup was deprecated since v0.8 and has been removed in 3.0.
206+
207+
**Before (2.x):**
208+
209+
```python
210+
from statemachine.registry import get_machine_cls
211+
212+
cls = get_machine_cls("MyMachine") # short name — worked with warning
213+
```
214+
215+
**After (3.0):**
216+
217+
```python
218+
from statemachine.registry import get_machine_cls
219+
220+
cls = get_machine_cls("myapp.machines.MyMachine") # fully-qualified name
221+
```
222+
223+
178224
## Update `TransitionNotAllowed` exception handling
179225

180226
The `TransitionNotAllowed` exception now stores a `configuration` attribute (a set of states)

statemachine/registry.py

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import warnings
2-
31
from .utils import qualname
42

53
try:
@@ -16,18 +14,11 @@ def autodiscover_modules(module_name: str):
1614

1715
def register(cls):
1816
_REGISTRY[qualname(cls)] = cls
19-
_REGISTRY[cls.__name__] = cls
2017
return cls
2118

2219

2320
def get_machine_cls(name):
2421
init_registry()
25-
if "." not in name:
26-
warnings.warn(
27-
"""Use fully qualified names (<module>.<class>) for state machine mixins.""",
28-
DeprecationWarning,
29-
stacklevel=2,
30-
)
3122
return _REGISTRY[name]
3223

3324

statemachine/statemachine.py

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -243,15 +243,6 @@ def _register_callbacks(self, listeners: List[object]):
243243

244244
self._callbacks.async_or_sync()
245245

246-
def add_observer(self, *observers):
247-
"""Add a listener."""
248-
warnings.warn(
249-
"""Method `add_observer` has been renamed to `add_listener`.""",
250-
DeprecationWarning,
251-
stacklevel=2,
252-
)
253-
return self.add_listener(*observers)
254-
255246
def add_listener(self, *listeners):
256247
"""Add a listener.
257248

statemachine/states.py

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@ class States:
1212
"""
1313
A class representing a collection of :ref:`State` objects.
1414
15-
Helps creating :ref:`StateMachine`'s :ref:`state` definitions from other
15+
Helps creating :ref:`StateChart`'s :ref:`state` definitions from other
1616
sources, like an ``Enum`` class, using :meth:`States.from_enum`.
1717
1818
>>> states_def = [('open', {'initial': True}), ('closed', {'final': True})]
1919
20-
>>> from statemachine import StateMachine
21-
>>> class SM(StateMachine):
20+
>>> from statemachine import StateChart
21+
>>> class SM(StateChart):
2222
...
2323
... states = States({
2424
... name: State(**params) for name, params in states_def
@@ -30,8 +30,8 @@ class States:
3030
3131
>>> sm = SM()
3232
>>> sm.send("close")
33-
>>> sm.current_state.id
34-
'closed'
33+
>>> sm.closed.is_active
34+
True
3535
3636
"""
3737

@@ -83,7 +83,7 @@ def items(self):
8383
return self._states.items()
8484

8585
@classmethod
86-
def from_enum(cls, enum_type: EnumType, initial, final=None, use_enum_instance: bool = False):
86+
def from_enum(cls, enum_type: EnumType, initial, final=None, use_enum_instance: bool = True):
8787
"""
8888
Creates a new instance of the ``States`` class from an enumeration.
8989
@@ -93,10 +93,10 @@ def from_enum(cls, enum_type: EnumType, initial, final=None, use_enum_instance:
9393
... pending = 1
9494
... completed = 2
9595
96-
A :ref:`StateMachine` that uses this enum can be declared as follows:
96+
A :ref:`StateChart` that uses this enum can be declared as follows:
9797
98-
>>> from statemachine import StateMachine
99-
>>> class ApprovalMachine(StateMachine):
98+
>>> from statemachine import StateChart
99+
>>> class ApprovalMachine(StateChart):
100100
...
101101
... _ = States.from_enum(Status, initial=Status.pending, final=Status.completed)
102102
...
@@ -107,7 +107,7 @@ def from_enum(cls, enum_type: EnumType, initial, final=None, use_enum_instance:
107107
108108
.. tip::
109109
When you assign the result of ``States.from_enum`` to a class-level variable in your
110-
:ref:`StateMachine`, you're all set. You can use any name for this variable. In this
110+
:ref:`StateChart`, you're all set. You can use any name for this variable. In this
111111
example, we used ``_`` to show that the name doesn't matter. The metaclass will inspect
112112
the variable of type :ref:`States (class)` and automatically assign the inner
113113
:ref:`State` instances to the state machine.
@@ -128,25 +128,25 @@ def from_enum(cls, enum_type: EnumType, initial, final=None, use_enum_instance:
128128
True
129129
130130
>>> sm.current_state_value
131-
2
131+
<Status.completed: 2>
132132
133-
If you need to use the enum instance as the state value, you can set the
134-
``use_enum_instance=True``:
133+
If you need to use the raw enum value instead of the enum instance, you can set
134+
``use_enum_instance=False``:
135135
136-
>>> states = States.from_enum(Status, initial=Status.pending, use_enum_instance=True)
136+
>>> states = States.from_enum(Status, initial=Status.pending, use_enum_instance=False)
137137
>>> states.completed.value
138-
<Status.completed: 2>
138+
2
139139
140-
.. deprecated:: 2.3.3
140+
.. versionchanged:: 3.0.0
141141
142-
On the next major release, ``use_enum_instance=True`` will be the default.
142+
The default changed from ``False`` to ``True``.
143143
144144
Args:
145145
enum_type: An enumeration containing the states of the machine.
146146
initial: The initial state of the machine.
147147
final: A set of final states of the machine.
148148
use_enum_instance: If ``True``, the value of the state will be the enum item instance,
149-
otherwise the enum item value. Defaults to ``False``.
149+
otherwise the enum item value. Defaults to ``True``.
150150
151151
Returns:
152152
A new instance of the :ref:`States (class)`.

tests/conftest.py

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ def current_time():
2727
def campaign_machine():
2828
"Define a new class for each test"
2929
from statemachine import State
30-
from statemachine import StateMachine
30+
from statemachine import StateChart
3131

32-
class CampaignMachine(StateMachine):
32+
class CampaignMachine(StateChart):
3333
"A workflow machine"
3434

3535
draft = State(initial=True)
@@ -47,11 +47,13 @@ class CampaignMachine(StateMachine):
4747
def campaign_machine_with_validator():
4848
"Define a new class for each test"
4949
from statemachine import State
50-
from statemachine import StateMachine
50+
from statemachine import StateChart
5151

52-
class CampaignMachine(StateMachine):
52+
class CampaignMachine(StateChart):
5353
"A workflow machine"
5454

55+
error_on_execution = False
56+
5557
draft = State(initial=True)
5658
producing = State("Being produced")
5759
closed = State(final=True)
@@ -71,9 +73,9 @@ def can_produce(*args, **kwargs):
7173
def campaign_machine_with_final_state():
7274
"Define a new class for each test"
7375
from statemachine import State
74-
from statemachine import StateMachine
76+
from statemachine import StateChart
7577

76-
class CampaignMachine(StateMachine):
78+
class CampaignMachine(StateChart):
7779
"A workflow machine"
7880

7981
draft = State(initial=True)
@@ -91,9 +93,9 @@ class CampaignMachine(StateMachine):
9193
def campaign_machine_with_values():
9294
"Define a new class for each test"
9395
from statemachine import State
94-
from statemachine import StateMachine
96+
from statemachine import StateChart
9597

96-
class CampaignMachineWithKeys(StateMachine):
98+
class CampaignMachineWithKeys(StateChart):
9799
"A workflow machine"
98100

99101
draft = State(initial=True, value=1)
@@ -131,9 +133,9 @@ def AllActionsMachine():
131133
@pytest.fixture()
132134
def classic_traffic_light_machine(engine):
133135
from statemachine import State
134-
from statemachine import StateMachine
136+
from statemachine import StateChart
135137

136-
class TrafficLightMachine(StateMachine):
138+
class TrafficLightMachine(StateChart):
137139
green = State(initial=True)
138140
yellow = State()
139141
red = State()
@@ -150,18 +152,16 @@ def _get_engine(self):
150152

151153
@pytest.fixture()
152154
def classic_traffic_light_machine_allow_event(classic_traffic_light_machine):
153-
class TrafficLightMachineAllowingEventWithoutTransition(classic_traffic_light_machine):
154-
allow_event_without_transition = True
155-
156-
return TrafficLightMachineAllowingEventWithoutTransition
155+
"""Already allow_event_without_transition=True (StateChart default)."""
156+
return classic_traffic_light_machine
157157

158158

159159
@pytest.fixture()
160160
def reverse_traffic_light_machine():
161161
from statemachine import State
162-
from statemachine import StateMachine
162+
from statemachine import StateChart
163163

164-
class ReverseTrafficLightMachine(StateMachine):
164+
class ReverseTrafficLightMachine(StateChart):
165165
"A traffic light machine"
166166

167167
green = State(initial=True)
@@ -177,9 +177,9 @@ class ReverseTrafficLightMachine(StateMachine):
177177
@pytest.fixture()
178178
def approval_machine(current_time): # noqa: C901
179179
from statemachine import State
180-
from statemachine import StateMachine
180+
from statemachine import StateChart
181181

182-
class ApprovalMachine(StateMachine):
182+
class ApprovalMachine(StateChart):
183183
"A workflow machine"
184184

185185
requested = State(initial=True)

tests/django_project/workflow/statemachines.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
from statemachine.states import States
22

3-
from statemachine import StateMachine
3+
from statemachine import StateChart
44

55
from .models import WorkflowSteps
66

77

8-
class WorfklowStateMachine(StateMachine):
8+
class WorfklowStateMachine(StateChart):
9+
allow_event_without_transition = False
10+
911
_ = States.from_enum(WorkflowSteps, initial=WorkflowSteps.DRAFT, final=WorkflowSteps.PUBLISHED)
1012

1113
publish = _.DRAFT.to(_.PUBLISHED, cond="is_active")

tests/examples/enum_campaign_machine.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ class CampaignMachine(StateChart):
2727
CampaignStatus,
2828
initial=CampaignStatus.DRAFT,
2929
final=CampaignStatus.CLOSED,
30-
use_enum_instance=True,
3130
)
3231

3332
add_job = states.DRAFT.to(states.DRAFT) | states.PRODUCING.to(states.PRODUCING)

tests/examples/statechart_error_handling_machine.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,16 +78,16 @@ def on_enter_recovering(self, error=None, **kwargs):
7878

7979

8080
# %%
81-
# Comparison with StateMachine (error propagation)
82-
# --------------------------------------------------
81+
# Comparison with error_on_execution=False (error propagation)
82+
# --------------------------------------------------------------
8383
#
84-
# With ``StateMachine`` (where ``error_on_execution=False``), the same error
84+
# With ``error_on_execution=False``, the same error
8585
# would propagate as an exception instead of being caught.
8686

87-
from statemachine import StateMachine # noqa: E402
8887

88+
class QuestNoCatch(StateChart):
89+
error_on_execution = False
8990

90-
class QuestNoCatch(StateMachine):
9191
safe = State("Safe", initial=True)
9292
danger_zone = State("Danger Zone")
9393

tests/scxml/test_microwave.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def test_microwave_scxml():
4040
processor.parse_scxml("microwave", MICROWAVE_SCXML)
4141
sm = processor.start()
4242

43-
assert sm.current_state.id == "unplugged"
43+
assert "unplugged" in sm.current_state_value
4444
sm.send("plug-in")
4545

4646
assert "idle" in sm.current_state_value

0 commit comments

Comments
 (0)