Skip to content

Commit ec9184b

Browse files
committed
Step 7
1 parent 93e1248 commit ec9184b

File tree

7 files changed

+123
-72
lines changed

7 files changed

+123
-72
lines changed

.mypy.ini

Lines changed: 0 additions & 38 deletions
This file was deleted.

.pre-commit-config.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,9 @@ repos:
5050
- id: ruff
5151

5252
- repo: https://github.com/pre-commit/mirrors-mypy
53-
rev: v1.7.0
53+
rev: v1.7.1
5454
hooks:
5555
- id: mypy
5656
additional_dependencies:
5757
- django-stubs==4.2.6
58+
- django-guardian

django_fsm/__init__.py

Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,20 @@
55
from __future__ import annotations
66

77
import inspect
8+
from collections.abc import Callable
9+
from collections.abc import Collection
10+
from collections.abc import Generator
11+
from collections.abc import Iterable
12+
from collections.abc import Sequence
813
from functools import partialmethod
914
from functools import wraps
1015
from typing import TYPE_CHECKING
16+
from typing import Any
1117

1218
from django.apps import apps as django_apps
1319
from django.db import models
1420
from django.db.models import Field
21+
from django.db.models import QuerySet
1522
from django.db.models.query_utils import DeferredAttribute
1623
from django.db.models.signals import class_prepared
1724

@@ -34,30 +41,29 @@
3441
]
3542

3643
if TYPE_CHECKING:
37-
from collections.abc import Callable
38-
from collections.abc import Generator
39-
from collections.abc import Iterable
40-
from collections.abc import Sequence
41-
from typing import Any
44+
from typing import Self
4245

46+
from _typeshed import Incomplete
4347
from django.contrib.auth.models import PermissionsMixin as UserWithPermissions
4448
from django.utils.functional import _StrOrPromise
4549

46-
_Model = models.Model
50+
_FSMModel = models.Model
4751
_Field = models.Field[Any, Any]
4852
CharField = models.CharField[Any, Any]
4953
IntegerField = models.IntegerField[Any, Any]
5054
ForeignKey = models.ForeignKey[Any, Any]
5155

5256
_StateValue = str | int
57+
_Permission = str | Callable[[_FSMModel, UserWithPermissions], bool]
5358
_Instance = models.Model # TODO: use real type
54-
_ToDo = Any # TODO: use real type
59+
5560
else:
56-
_Model = object
61+
_FSMModel = object
5762
_Field = object
5863
CharField = models.CharField
5964
IntegerField = models.IntegerField
6065
ForeignKey = models.ForeignKey
66+
Self = Any
6167

6268

6369
class TransitionNotAllowed(Exception):
@@ -277,7 +283,7 @@ class FSMFieldMixin(_Field):
277283

278284
def __init__(self, *args: Any, **kwargs: Any) -> None:
279285
self.protected = kwargs.pop("protected", False)
280-
self.transitions: dict[type[_Model], dict[str, Any]] = {} # cls -> (transitions name -> method)
286+
self.transitions: dict[type[_FSMModel], dict[str, Any]] = {} # cls -> (transitions name -> method)
281287
self.state_proxy = {} # state -> ProxyClsRef
282288

283289
state_choices = kwargs.pop("state_choices", None)
@@ -329,7 +335,7 @@ def set_proxy(self, instance: _Instance, state: str) -> None:
329335

330336
instance.__class__ = model
331337

332-
def change_state(self, instance: _Instance, method: _ToDo, *args: Any, **kwargs: Any) -> Any:
338+
def change_state(self, instance: _Instance, method: Incomplete, *args: Any, **kwargs: Any) -> Any:
333339
meta = method._django_fsm
334340
method_name = method.__name__
335341
current_state = self.get_state(instance)
@@ -382,7 +388,7 @@ def change_state(self, instance: _Instance, method: _ToDo, *args: Any, **kwargs:
382388

383389
return result
384390

385-
def get_all_transitions(self, instance_cls: type[_Model]) -> Generator[Transition, None, None]:
391+
def get_all_transitions(self, instance_cls: type[_FSMModel]) -> Generator[Transition, None, None]:
386392
"""
387393
Returns [(source, target, name, method)] for all field transitions
388394
"""
@@ -394,7 +400,7 @@ def get_all_transitions(self, instance_cls: type[_Model]) -> Generator[Transitio
394400
for transition in meta.transitions.values():
395401
yield transition
396402

397-
def contribute_to_class(self, cls: type[_Model], name: str, private_only: bool = False, **kwargs: Any) -> None:
403+
def contribute_to_class(self, cls: type[_FSMModel], name: str, private_only: bool = False, **kwargs: Any) -> None:
398404
self.base_cls = cls
399405

400406
super().contribute_to_class(cls, name, private_only=private_only, **kwargs)
@@ -415,7 +421,7 @@ def _collect_transitions(self, *args: Any, **kwargs: Any) -> None:
415421
if not issubclass(sender, self.base_cls):
416422
return
417423

418-
def is_field_transition_method(attr: _ToDo) -> bool:
424+
def is_field_transition_method(attr: Incomplete) -> bool:
419425
return (
420426
(inspect.ismethod(attr) or inspect.isfunction(attr))
421427
and hasattr(attr, "_django_fsm")
@@ -461,14 +467,14 @@ class FSMKeyField(FSMFieldMixin, ForeignKey):
461467
State Machine support for Django model
462468
"""
463469

464-
def get_state(self, instance: _Instance) -> _ToDo:
470+
def get_state(self, instance: _Instance) -> Incomplete:
465471
return instance.__dict__[self.attname]
466472

467473
def set_state(self, instance: _Instance, state: str) -> None:
468474
instance.__dict__[self.attname] = self.to_python(state)
469475

470476

471-
class FSMModelMixin(_Model):
477+
class FSMModelMixin(_FSMModel):
472478
"""
473479
Mixin that allows refresh_from_db for models with fsm protected fields
474480
"""
@@ -495,7 +501,7 @@ def refresh_from_db(self, *args, **kwargs):
495501
super().refresh_from_db(*args, **kwargs)
496502

497503

498-
class ConcurrentTransitionMixin(_Model):
504+
class ConcurrentTransitionMixin(_FSMModel):
499505
"""
500506
Protects a Model from undesirable effects caused by concurrently executed transitions,
501507
e.g. running the same transition multiple times at the same time, or running different
@@ -529,7 +535,15 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
529535
def state_fields(self) -> Iterable[Any]:
530536
return filter(lambda field: isinstance(field, FSMFieldMixin), self._meta.fields)
531537

532-
def _do_update(self, base_qs, using, pk_val, values, update_fields, forced_update): # type: ignore[no-untyped-def]
538+
def _do_update(
539+
self,
540+
base_qs: QuerySet[Self],
541+
using: Any,
542+
pk_val: Any,
543+
values: Collection[Any] | None,
544+
update_fields: Iterable[str] | None,
545+
forced_update: bool,
546+
) -> bool:
533547
# _do_update is called once for each model class in the inheritance hierarchy.
534548
# We can only filter the base_qs on state fields (can be more than one!) present in this particular model.
535549

@@ -539,7 +553,7 @@ def _do_update(self, base_qs, using, pk_val, values, update_fields, forced_updat
539553
# state filter will be used to narrow down the standard filter checking only PK
540554
state_filter = {field.attname: self.__initial_states[field.attname] for field in filter_on}
541555

542-
updated = super()._do_update( # type: ignore[misc]
556+
updated: bool = super()._do_update( # type: ignore[misc]
543557
base_qs=base_qs.filter(**state_filter),
544558
using=using,
545559
pk_val=pk_val,
@@ -577,7 +591,7 @@ def transition(
577591
target: _StateValue | State | None = None,
578592
on_error: _StateValue | None = None,
579593
conditions: list[Callable[[Any], bool]] = [],
580-
permission: str | Callable[[models.Model, UserWithPermissions], bool] | None = None,
594+
permission: _Permission | None = None,
581595
custom: dict[str, _StrOrPromise] = {},
582596
) -> Callable[[Any], Any]:
583597
"""
@@ -587,21 +601,22 @@ def transition(
587601
has not changed after the function call.
588602
"""
589603

590-
def inner_transition(func: _ToDo) -> _ToDo:
604+
def inner_transition(func: Incomplete) -> Incomplete:
591605
wrapper_installed, fsm_meta = True, getattr(func, "_django_fsm", None)
592606
if not fsm_meta:
593607
wrapper_installed = False
594608
fsm_meta = FSMMeta(field=field, method=func)
595609
setattr(func, "_django_fsm", fsm_meta)
596610

611+
# if isinstance(source, Iterable):
597612
if isinstance(source, (list, tuple, set)):
598613
for state in source:
599614
func._django_fsm.add_transition(func, state, target, on_error, conditions, permission, custom)
600615
else:
601616
func._django_fsm.add_transition(func, source, target, on_error, conditions, permission, custom)
602617

603618
@wraps(func)
604-
def _change_state(instance: _Instance, *args: Any, **kwargs: Any) -> _ToDo:
619+
def _change_state(instance: _Instance, *args: Any, **kwargs: Any) -> Incomplete:
605620
return fsm_meta.field.change_state(instance, func, *args, **kwargs)
606621

607622
if not wrapper_installed:
@@ -612,7 +627,7 @@ def _change_state(instance: _Instance, *args: Any, **kwargs: Any) -> _ToDo:
612627
return inner_transition
613628

614629

615-
def can_proceed(bound_method: _ToDo, check_conditions: bool = True) -> bool:
630+
def can_proceed(bound_method: Incomplete, check_conditions: bool = True) -> bool:
616631
"""
617632
Returns True if model in state allows to call bound_method
618633
@@ -629,7 +644,7 @@ def can_proceed(bound_method: _ToDo, check_conditions: bool = True) -> bool:
629644
return meta.has_transition(current_state) and (not check_conditions or meta.conditions_met(self, current_state))
630645

631646

632-
def has_transition_perm(bound_method: _ToDo, user: UserWithPermissions) -> bool:
647+
def has_transition_perm(bound_method: Incomplete, user: UserWithPermissions) -> bool:
633648
"""
634649
Returns True if model in state allows to call bound_method and user have rights on it
635650
"""
@@ -648,15 +663,15 @@ def has_transition_perm(bound_method: _ToDo, user: UserWithPermissions) -> bool:
648663

649664

650665
class State:
651-
def get_state(self, model: _Model, transition: Transition, result: Any, args: Any = [], kwargs: Any = {}) -> _ToDo:
666+
def get_state(self, model: _FSMModel, transition: Transition, result: Any, args: Any = [], kwargs: Any = {}) -> Incomplete:
652667
raise NotImplementedError
653668

654669

655670
class RETURN_VALUE(State):
656671
def __init__(self, *allowed_states: Sequence[_StateValue]) -> None:
657672
self.allowed_states = allowed_states if allowed_states else None
658673

659-
def get_state(self, model: _Model, transition: Transition, result: Any, args: Any = [], kwargs: Any = {}) -> _ToDo:
674+
def get_state(self, model: _FSMModel, transition: Transition, result: Any, args: Any = [], kwargs: Any = {}) -> Incomplete:
660675
if self.allowed_states is not None:
661676
if result not in self.allowed_states:
662677
raise InvalidResultState(f"{result} is not in list of allowed states\n{self.allowed_states}")
@@ -669,8 +684,8 @@ def __init__(self, func: Callable[..., _StateValue | Any], states: Sequence[_Sta
669684
self.allowed_states = states
670685

671686
def get_state(
672-
self, model: _Model, transition: Transition, result: _StateValue | Any, args: Any = [], kwargs: Any = {}
673-
) -> _ToDo:
687+
self, model: _FSMModel, transition: Transition, result: _StateValue | Any, args: Any = [], kwargs: Any = {}
688+
) -> Incomplete:
674689
result_state = self.func(model, *args, **kwargs)
675690
if self.allowed_states is not None:
676691
if result_state not in self.allowed_states:

pyproject.toml

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,79 @@ fixable = ["I"]
7575
force-single-line = true
7676
required-imports = ["from __future__ import annotations"]
7777

78+
[tool.django-stubs]
79+
django_settings_module = "tests.settings"
80+
81+
[tool.mypy]
82+
python_version = 3.11
83+
plugins = ["mypy_django_plugin.main"]
84+
85+
# Start off with these
86+
warn_unused_configs = true
87+
warn_redundant_casts = true
88+
warn_unused_ignores = true
89+
90+
# Getting these passing should be easy
91+
strict_equality = true
92+
extra_checks = true
93+
94+
# Strongly recommend enabling this one as soon as you can
95+
check_untyped_defs = true
96+
97+
# These shouldn't be too much additional work, but may be tricky to
98+
# get passing if you use a lot of untyped libraries
99+
disallow_subclassing_any = true
100+
disallow_untyped_decorators = true
101+
disallow_any_generics = true
102+
103+
# These next few are various gradations of forcing use of type annotations
104+
disallow_untyped_calls = true
105+
disallow_incomplete_defs = true
106+
disallow_untyped_defs = true
107+
108+
# This one isn't too hard to get passing, but return on investment is lower
109+
no_implicit_reexport = true
110+
111+
# This one can be tricky to get passing if you use a lot of untyped libraries
112+
warn_return_any = true
113+
114+
[[tool.mypy.overrides]]
115+
module = [
116+
"tests.*",
117+
"django_fsm.tests.*"
118+
]
119+
ignore_errors = true
120+
121+
# Start off with these
122+
warn_unused_ignores = true
123+
124+
# Getting these passing should be easy
125+
strict_equality = false
126+
extra_checks = false
127+
128+
# Strongly recommend enabling this one as soon as you can
129+
check_untyped_defs = false
130+
# These shouldn't be too much additional work, but may be tricky to
131+
# get passing if you use a lot of untyped libraries
132+
disallow_subclassing_any = false
133+
disallow_untyped_decorators = false
134+
disallow_any_generics = false
135+
136+
# These next few are various gradations of forcing use of type annotations
137+
disallow_untyped_calls = false
138+
disallow_incomplete_defs = false
139+
disallow_untyped_defs = false
140+
141+
# This one isn't too hard to get passing, but return on investment is lower
142+
no_implicit_reexport = false
143+
144+
# This one can be tricky to get passing if you use a lot of untyped libraries
145+
warn_return_any = false
146+
147+
[[tool.mypy.overrides]]
148+
module = "django_fsm.management.commands.graph_transitions"
149+
ignore_errors = true
150+
78151
[build-system]
79152
requires = ["poetry-core"]
80153
build-backend = "poetry.core.masonry.api"

tests/testapp/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ class BlogPost(models.Model):
9393

9494
state = FSMField(default="new", protected=True)
9595

96-
def can_restore(self, user):
96+
def can_restore(self, user) -> bool:
9797
return user.is_superuser or user.is_staff
9898

9999
@transition(field=state, source="new", target="published", on_error="failed", permission="testapp.can_publish_post")

tests/testapp/tests/test_multidecorators.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from django_fsm.signals import post_transition
99

1010

11-
class TestModel(models.Model):
11+
class MultipletransitionsModel(models.Model):
1212
counter = models.IntegerField(default=0)
1313
signal_counter = models.IntegerField(default=0)
1414
state = FSMField(default="SUBMITTED_BY_USER")
@@ -27,12 +27,12 @@ def count_calls(sender, instance, name, source, target, **kwargs):
2727
instance.signal_counter += 1
2828

2929

30-
post_transition.connect(count_calls, sender=TestModel)
30+
post_transition.connect(count_calls, sender=MultipletransitionsModel)
3131

3232

3333
class TestStateProxy(TestCase):
3434
def test_transition_method_called_once(self):
35-
model = TestModel()
35+
model = MultipletransitionsModel()
3636
model.review()
3737
self.assertEqual(1, model.counter)
3838
self.assertEqual(1, model.signal_counter)

0 commit comments

Comments
 (0)