Skip to content

Commit 1ed59f4

Browse files
committed
custom exceptions
1 parent 77eaca1 commit 1ed59f4

7 files changed

Lines changed: 116 additions & 65 deletions

File tree

django_fsm/__init__.py

Lines changed: 22 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@
1717
from django.db.models.query_utils import DeferredAttribute
1818
from django.db.models.signals import class_prepared
1919

20+
from .exceptions import ConcurrentTransition
21+
from .exceptions import InvalidResultState
22+
from .exceptions import InvalidTransition
23+
from .exceptions import NoTransition
24+
from .exceptions import TransitionConditionsUnmet
25+
from .exceptions import TransitionNotAllowed
2026
from .signals import post_transition
2127
from .signals import pre_transition
2228

@@ -64,6 +70,9 @@
6470
"FSMIntegerField",
6571
"FSMKeyField",
6672
"FSMModelMixin",
73+
"InvalidTransition",
74+
"NoTransition",
75+
"TransitionConditionsUnmet",
6776
"TransitionNotAllowed",
6877
"can_proceed",
6978
"has_transition_perm",
@@ -75,29 +84,6 @@
7584
ANY_OTHER_STATE = "+"
7685

7786

78-
class TransitionNotAllowed(Exception): # noqa: N818
79-
"""Raised when a transition is not allowed"""
80-
81-
@override
82-
def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None:
83-
self.object = kwargs.pop("object", None)
84-
self.method = kwargs.pop("method", None)
85-
self.failed_condition = kwargs.pop("failed_condition", None)
86-
super().__init__(*args, **kwargs)
87-
88-
89-
class InvalidResultState(Exception): # noqa: N818
90-
"""Raised when we got invalid result state"""
91-
92-
93-
class ConcurrentTransition(Exception): # noqa: N818
94-
"""
95-
Raised when the transition cannot be executed because the
96-
object has become stale (state has been changed since it
97-
was fetched from the database).
98-
"""
99-
100-
10187
class Transition:
10288
def __init__(
10389
self,
@@ -148,7 +134,8 @@ def __eq__(self, other: object) -> bool:
148134

149135

150136
def get_available_FIELD_transitions( # noqa: N802
151-
instance: _FSMModel, field: FSMFieldMixin
137+
instance: _FSMModel,
138+
field: FSMFieldMixin,
152139
) -> typing.Generator[Transition]:
153140
"""
154141
List of transitions available in current model state
@@ -164,7 +151,8 @@ def get_available_FIELD_transitions( # noqa: N802
164151

165152

166153
def get_all_FIELD_transitions( # noqa: N802
167-
instance: _FSMModel, field: FSMFieldMixin
154+
instance: _FSMModel,
155+
field: FSMFieldMixin,
168156
) -> typing.Generator[Transition]:
169157
"""
170158
List of all transitions available in current model state
@@ -173,7 +161,9 @@ def get_all_FIELD_transitions( # noqa: N802
173161

174162

175163
def get_available_user_FIELD_transitions( # noqa: N802
176-
instance: _FSMModel, user: UserWithPermissions, field: FSMFieldMixin
164+
instance: _FSMModel,
165+
user: UserWithPermissions,
166+
field: FSMFieldMixin,
177167
) -> typing.Generator[Transition]:
178168
"""
179169
List of transitions available in current model state
@@ -256,7 +246,7 @@ def conditions_met(self, instance: _FSMModel, state: _StateValue) -> bool:
256246

257247
return all(condition(instance) for condition in transition.conditions)
258248

259-
def _get_first_unmet_condition(
249+
def get_first_unmet_condition(
260250
self, instance: _FSMModel, state: _StateValue
261251
) -> _Condition | None:
262252
"""
@@ -290,15 +280,15 @@ def next_state(self, current_state: _StateValue) -> _StateValue:
290280
transition = self.get_transition(current_state)
291281

292282
if transition is None:
293-
raise TransitionNotAllowed(f"No transition from {current_state}")
283+
raise NoTransition(f"No transition from {current_state}")
294284

295285
return transition.target
296286

297287
def exception_state(self, current_state: _StateValue) -> _StateValue | None:
298288
transition = self.get_transition(current_state)
299289

300290
if transition is None:
301-
raise TransitionNotAllowed(f"No transition from {current_state}")
291+
raise NoTransition(f"No transition from {current_state}")
302292

303293
return transition.on_error
304294

@@ -390,14 +380,14 @@ def change_state(
390380
current_state = self.get_state(instance)
391381

392382
if not meta.has_transition(current_state):
393-
raise TransitionNotAllowed(
383+
raise InvalidTransition(
394384
f"Can't switch from state '{current_state}' using method '{method_name}'",
395385
object=instance,
396386
method=method,
397387
)
398-
unmet = meta._get_first_unmet_condition(instance, current_state)
388+
unmet = meta.get_first_unmet_condition(instance, current_state)
399389
if unmet is not None:
400-
raise TransitionNotAllowed(
390+
raise TransitionConditionsUnmet(
401391
f"Transition conditions have not been met for method '{method_name}': "
402392
f"{getattr(unmet, '__name__', repr(unmet))}",
403393
object=instance,

django_fsm/exceptions.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from __future__ import annotations
2+
3+
import typing
4+
5+
try:
6+
from typing import override
7+
except ImportError: # pragma: no cover
8+
# Py<3.12
9+
from typing_extensions import override
10+
11+
if typing.TYPE_CHECKING: # pragma: no cover
12+
from . import _Condition
13+
from . import _FSMModel
14+
from . import _TransitionFunc
15+
16+
17+
class FSMException(Exception): # noqa: N818
18+
...
19+
20+
21+
class TransitionNotAllowed(FSMException):
22+
"""Raised when a transition is not allowed"""
23+
24+
object: _FSMModel
25+
method: _TransitionFunc
26+
27+
@override
28+
def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None:
29+
self.object = kwargs.pop("object", None)
30+
self.method = kwargs.pop("method", None)
31+
super().__init__(*args, **kwargs)
32+
33+
34+
class NoTransition(TransitionNotAllowed):
35+
"""Raised when no transition exists for the current state"""
36+
37+
38+
class InvalidTransition(TransitionNotAllowed):
39+
"""Raised when a transition method is not valid for the current state"""
40+
41+
42+
class TransitionConditionsUnmet(TransitionNotAllowed):
43+
"""Raised when a transition condition fails"""
44+
45+
failed_condition: _Condition
46+
47+
@override
48+
def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None:
49+
self.failed_condition = kwargs.pop("failed_condition", None)
50+
super().__init__(*args, **kwargs)
51+
52+
53+
class InvalidResultState(FSMException):
54+
"""Raised when we got invalid result state"""
55+
56+
57+
class ConcurrentTransition(FSMException):
58+
"""
59+
Raised when the transition cannot be executed because the
60+
object has become stale (state has been changed since it
61+
was fetched from the database).
62+
"""

tests/testapp/tests/test_basic_transitions.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ def test_known_transitions_succeed(self):
7777

7878
def test_unavailable_transition_fails(self):
7979
assert not fsm.can_proceed(self.model.hide)
80-
with pytest.raises(fsm.TransitionNotAllowed):
80+
with pytest.raises(fsm.InvalidTransition):
8181
self.model.hide()
8282

8383
def test_state_unchanged_when_transition_raises(self):
@@ -94,7 +94,7 @@ def test_available_transition_with_empty_target_keeps_state(self):
9494
assert self.model.state == ApplicationState.PUBLISHED
9595

9696
def test_unavailable_transition_with_empty_target_keeps_state(self):
97-
with pytest.raises(fsm.TransitionNotAllowed):
97+
with pytest.raises(fsm.InvalidTransition):
9898
self.model.notify_all()
9999

100100
assert self.model.state == ApplicationState.NEW
@@ -134,7 +134,7 @@ def test_any_other_state_transition_fails_when_same_source(self):
134134
self.model.block()
135135

136136
assert not fsm.can_proceed(self.model.block)
137-
with pytest.raises(fsm.TransitionNotAllowed):
137+
with pytest.raises(fsm.InvalidTransition):
138138
self.model.block()
139139

140140
def test_empty_string_target_sets_blank_state(self):
@@ -170,7 +170,7 @@ def test_signals_fire_on_valid_transition(self):
170170
assert self.post_transition_called
171171

172172
def test_signals_do_not_fire_on_invalid_transition(self):
173-
with pytest.raises(fsm.TransitionNotAllowed):
173+
with pytest.raises(fsm.InvalidTransition):
174174
self.model.hide()
175175

176176
assert not self.pre_transition_called

tests/testapp/tests/test_conditions.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,27 +62,26 @@ def test_unmet_condition(self):
6262

6363
assert not fsm.can_proceed(self.model.remove)
6464

65-
with pytest.raises(fsm.TransitionNotAllowed):
65+
with pytest.raises(fsm.TransitionConditionsUnmet):
6666
self.model.remove()
6767

6868
assert fsm.can_proceed(self.model.remove, check_conditions=False)
6969

7070
def test_failed_condition_reported_on_exception(self):
7171
self.model.publish()
72-
with pytest.raises(fsm.TransitionNotAllowed) as exc_info:
72+
with pytest.raises(fsm.TransitionConditionsUnmet) as exc_info:
7373
self.model.remove()
7474
assert exc_info.value.failed_condition is BlogPostWithConditions.unmet_condition
7575

7676
def test_failed_condition_named_in_message(self):
7777
self.model.publish()
78-
with pytest.raises(fsm.TransitionNotAllowed, match="unmet_condition"):
78+
with pytest.raises(fsm.TransitionConditionsUnmet, match="unmet_condition"):
7979
self.model.remove()
8080

8181
def test_failed_condition_is_none_when_no_condition_failure(self):
82-
"""TransitionNotAllowed for a missing transition has no failed_condition."""
83-
with pytest.raises(fsm.TransitionNotAllowed) as exc_info:
82+
"""InvalidTransition for a missing transition has no failed_condition."""
83+
with pytest.raises(fsm.InvalidTransition):
8484
self.model.remove() # state is "new", destroy only works from "published"
85-
assert exc_info.value.failed_condition is None
8685

8786

8887
def _eval_tracking_condition(instance: models.Model) -> bool:
@@ -114,7 +113,7 @@ class ShortCircuitTest(TestCase):
114113
def test_only_first_failing_condition_evaluated(self):
115114
obj = BlogPostShortCircuit()
116115
obj._eval_log = []
117-
with pytest.raises(fsm.TransitionNotAllowed) as exc_info:
116+
with pytest.raises(fsm.TransitionConditionsUnmet) as exc_info:
118117
obj.publish()
119118
assert exc_info.value.failed_condition is _eval_tracking_condition
120119
assert obj._eval_log == ["first"]

tests/testapp/tests/test_integer_field.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,5 @@ def test_known_transition_should_succeed(self):
3535
assert self.model.state == BlogPostState.HIDDEN
3636

3737
def test_unknown_transition_fails(self):
38-
with pytest.raises(fsm.TransitionNotAllowed):
38+
with pytest.raises(fsm.InvalidTransition):
3939
self.model.hide()

tests/testapp/tests/test_key_field.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def test_known_transition_should_succeed(self):
8484
def test_unknown_transition_fails(self):
8585
assert not fsm.can_proceed(self.model.hide)
8686

87-
with pytest.raises(fsm.TransitionNotAllowed):
87+
with pytest.raises(fsm.InvalidTransition):
8888
self.model.hide()
8989

9090
def test_state_non_changed_after_fail(self):
@@ -104,7 +104,7 @@ def test_allowed_null_transition_should_succeed(self):
104104
assert self.model.state == "_PUBLISHED_"
105105

106106
def test_unknown_null_transition_should_fail(self):
107-
with pytest.raises(fsm.TransitionNotAllowed):
107+
with pytest.raises(fsm.InvalidTransition):
108108
self.model.notify_all()
109109

110110
assert self.model.state == "_NEW_"

tests/testapp/tests/test_permissions.py

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,39 +11,39 @@
1111
class PermissionFSMFieldTest(TestCase):
1212
def setUp(self):
1313
self.model = BlogPost()
14-
self.unprivileged = User.objects.create(username="unprivileged")
15-
self.privileged = User.objects.create(username="privileged")
16-
self.staff = User.objects.create(username="staff", is_staff=True)
14+
self.unprivileged_user = User.objects.create(username="unprivileged")
15+
self.privileged_user = User.objects.create(username="privileged")
16+
self.staff_user = User.objects.create(username="staff", is_staff=True)
1717

18-
self.privileged.user_permissions.add(
18+
self.privileged_user.user_permissions.add(
1919
Permission.objects.get_by_natural_key("can_publish_post", "testapp", "blogpost")
2020
)
21-
self.privileged.user_permissions.add(
21+
self.privileged_user.user_permissions.add(
2222
Permission.objects.get_by_natural_key("can_remove_post", "testapp", "blogpost")
2323
)
2424

2525
def test_privileged_access_succeed(self):
26-
assert fsm.has_transition_perm(self.model.publish, self.privileged)
27-
assert fsm.has_transition_perm(self.model.remove, self.privileged)
26+
assert fsm.has_transition_perm(self.model.publish, self.privileged_user)
27+
assert fsm.has_transition_perm(self.model.remove, self.privileged_user)
2828

2929
available_transitions = self.model.get_available_user_state_transitions( # type: ignore[attr-defined]
30-
self.privileged
30+
self.privileged_user
3131
)
32-
transition_names = {transition.name for transition in available_transitions}
33-
34-
assert {"publish", "remove", "moderate"} == transition_names
32+
assert {transition.name for transition in available_transitions} == {
33+
"publish",
34+
"remove",
35+
"moderate",
36+
}
3537

3638
def test_unprivileged_access_prohibited(self):
37-
assert not fsm.has_transition_perm(self.model.publish, self.unprivileged)
38-
assert not fsm.has_transition_perm(self.model.remove, self.unprivileged)
39+
assert not fsm.has_transition_perm(self.model.publish, self.unprivileged_user)
40+
assert not fsm.has_transition_perm(self.model.remove, self.unprivileged_user)
3941

4042
available_transitions = self.model.get_available_user_state_transitions( # type: ignore[attr-defined]
41-
self.unprivileged
43+
self.unprivileged_user
4244
)
43-
transition_names = {transition.name for transition in available_transitions}
44-
45-
assert {"moderate"} == transition_names
45+
assert {transition.name for transition in available_transitions} == {"moderate"}
4646

4747
def test_permission_instance_method(self):
48-
assert not fsm.has_transition_perm(self.model.restore, self.unprivileged)
49-
assert fsm.has_transition_perm(self.model.restore, self.staff)
48+
assert not fsm.has_transition_perm(self.model.restore, self.unprivileged_user)
49+
assert fsm.has_transition_perm(self.model.restore, self.staff_user)

0 commit comments

Comments
 (0)