Skip to content

Commit 9194723

Browse files
committed
Step 7
1 parent 26368af commit 9194723

File tree

8 files changed

+127
-76
lines changed

8 files changed

+127
-76
lines changed

.mypy.ini

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

.pre-commit-config.yaml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ repos:
2525

2626

2727
- repo: https://github.com/python-poetry/poetry
28-
rev: 1.6.1
28+
rev: 1.7.0
2929
hooks:
3030
- id: poetry-check
3131
additional_dependencies:
@@ -42,8 +42,9 @@ repos:
4242
- id: ruff
4343

4444
- repo: https://github.com/pre-commit/mirrors-mypy
45-
rev: v1.7.0
45+
rev: v1.7.1
4646
hooks:
4747
- id: mypy
4848
additional_dependencies:
4949
- django-stubs==4.2.6
50+
- django-guardian

django_fsm/__init__.py

Lines changed: 41 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,20 @@
44
from __future__ import annotations
55

66
import inspect
7+
from collections.abc import Callable
8+
from collections.abc import Collection
9+
from collections.abc import Generator
10+
from collections.abc import Iterable
11+
from collections.abc import Sequence
712
from functools import partialmethod
813
from functools import wraps
914
from typing import TYPE_CHECKING
15+
from typing import Any
1016

1117
from django.apps import apps as django_apps
1218
from django.db import models
1319
from django.db.models import Field
20+
from django.db.models import QuerySet
1421
from django.db.models.query_utils import DeferredAttribute
1522
from django.db.models.signals import class_prepared
1623

@@ -33,30 +40,29 @@
3340
]
3441

3542
if TYPE_CHECKING:
36-
from collections.abc import Callable
37-
from collections.abc import Generator
38-
from collections.abc import Iterable
39-
from collections.abc import Sequence
40-
from typing import Any
43+
from typing import Self
4144

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

45-
_Model = models.Model
49+
_FSMModel = models.Model
4650
_Field = models.Field[Any, Any]
4751
CharField = models.CharField[Any, Any]
4852
IntegerField = models.IntegerField[Any, Any]
4953
ForeignKey = models.ForeignKey[Any, Any]
5054

5155
_StateValue = str | int
56+
_Permission = str | Callable[[_FSMModel, UserWithPermissions], bool]
5257
_Instance = models.Model # TODO: use real type
53-
_ToDo = Any # TODO: use real type
58+
5459
else:
55-
_Model = object
60+
_FSMModel = object
5661
_Field = object
5762
CharField = models.CharField
5863
IntegerField = models.IntegerField
5964
ForeignKey = models.ForeignKey
65+
Self = Any
6066

6167

6268
class TransitionNotAllowed(Exception):
@@ -265,7 +271,7 @@ class FSMFieldMixin(_Field):
265271

266272
def __init__(self, *args: Any, **kwargs: Any) -> None:
267273
self.protected = kwargs.pop("protected", False)
268-
self.transitions: dict[type[_Model], dict[str, Any]] = {} # cls -> (transitions name -> method)
274+
self.transitions: dict[type[_FSMModel], dict[str, Any]] = {} # cls -> (transitions name -> method)
269275
self.state_proxy = {} # state -> ProxyClsRef
270276

271277
state_choices = kwargs.pop("state_choices", None)
@@ -317,7 +323,7 @@ def set_proxy(self, instance: _Instance, state: str) -> None:
317323

318324
instance.__class__ = model
319325

320-
def change_state(self, instance: _Instance, method: _ToDo, *args: Any, **kwargs: Any) -> Any:
326+
def change_state(self, instance: _Instance, method: Incomplete, *args: Any, **kwargs: Any) -> Any:
321327
meta = method._django_fsm
322328
method_name = method.__name__
323329
current_state = self.get_state(instance)
@@ -370,7 +376,7 @@ def change_state(self, instance: _Instance, method: _ToDo, *args: Any, **kwargs:
370376

371377
return result
372378

373-
def get_all_transitions(self, instance_cls: type[_Model]) -> Generator[Transition, None, None]:
379+
def get_all_transitions(self, instance_cls: type[_FSMModel]) -> Generator[Transition, None, None]:
374380
"""
375381
Returns [(source, target, name, method)] for all field transitions
376382
"""
@@ -382,7 +388,7 @@ def get_all_transitions(self, instance_cls: type[_Model]) -> Generator[Transitio
382388
for transition in meta.transitions.values():
383389
yield transition
384390

385-
def contribute_to_class(self, cls: type[_Model], name: str, private_only: bool = False, **kwargs: Any) -> None:
391+
def contribute_to_class(self, cls: type[_FSMModel], name: str, private_only: bool = False, **kwargs: Any) -> None:
386392
self.base_cls = cls
387393

388394
super().contribute_to_class(cls, name, private_only=private_only, **kwargs)
@@ -403,7 +409,7 @@ def _collect_transitions(self, *args: Any, **kwargs: Any) -> None:
403409
if not issubclass(sender, self.base_cls):
404410
return
405411

406-
def is_field_transition_method(attr: _ToDo) -> bool:
412+
def is_field_transition_method(attr: Incomplete) -> bool:
407413
return (
408414
(inspect.ismethod(attr) or inspect.isfunction(attr))
409415
and hasattr(attr, "_django_fsm")
@@ -449,14 +455,14 @@ class FSMKeyField(FSMFieldMixin, ForeignKey):
449455
State Machine support for Django model
450456
"""
451457

452-
def get_state(self, instance: _Instance) -> _ToDo:
458+
def get_state(self, instance: _Instance) -> Incomplete:
453459
return instance.__dict__[self.attname]
454460

455461
def set_state(self, instance: _Instance, state: str) -> None:
456462
instance.__dict__[self.attname] = self.to_python(state)
457463

458464

459-
class ConcurrentTransitionMixin(_Model):
465+
class ConcurrentTransitionMixin(_FSMModel):
460466
"""
461467
Protects a Model from undesirable effects caused by concurrently executed transitions,
462468
e.g. running the same transition multiple times at the same time, or running different
@@ -490,7 +496,15 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
490496
def state_fields(self) -> Iterable[Any]:
491497
return filter(lambda field: isinstance(field, FSMFieldMixin), self._meta.fields)
492498

493-
def _do_update(self, base_qs, using, pk_val, values, update_fields, forced_update): # type: ignore[no-untyped-def]
499+
def _do_update(
500+
self,
501+
base_qs: QuerySet[Self],
502+
using: Any,
503+
pk_val: Any,
504+
values: Collection[Any] | None,
505+
update_fields: Iterable[str] | None,
506+
forced_update: bool,
507+
) -> bool:
494508
# _do_update is called once for each model class in the inheritance hierarchy.
495509
# We can only filter the base_qs on state fields (can be more than one!) present in this particular model.
496510

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

503-
updated = super()._do_update( # type: ignore[misc]
517+
updated: bool = super()._do_update( # type: ignore[misc]
504518
base_qs=base_qs.filter(**state_filter),
505519
using=using,
506520
pk_val=pk_val,
@@ -538,7 +552,7 @@ def transition(
538552
target: _StateValue | State | None = None,
539553
on_error: _StateValue | None = None,
540554
conditions: list[Callable[[Any], bool]] = [],
541-
permission: str | Callable[[models.Model, UserWithPermissions], bool] | None = None,
555+
permission: _Permission | None = None,
542556
custom: dict[str, _StrOrPromise] = {},
543557
) -> Callable[[Any], Any]:
544558
"""
@@ -548,21 +562,22 @@ def transition(
548562
has not changed after the function call.
549563
"""
550564

551-
def inner_transition(func: _ToDo) -> _ToDo:
565+
def inner_transition(func: Incomplete) -> Incomplete:
552566
wrapper_installed, fsm_meta = True, getattr(func, "_django_fsm", None)
553567
if not fsm_meta:
554568
wrapper_installed = False
555569
fsm_meta = FSMMeta(field=field, method=func)
556570
setattr(func, "_django_fsm", fsm_meta)
557571

572+
# if isinstance(source, Iterable):
558573
if isinstance(source, (list, tuple, set)):
559574
for state in source:
560575
func._django_fsm.add_transition(func, state, target, on_error, conditions, permission, custom)
561576
else:
562577
func._django_fsm.add_transition(func, source, target, on_error, conditions, permission, custom)
563578

564579
@wraps(func)
565-
def _change_state(instance: _Instance, *args: Any, **kwargs: Any) -> _ToDo:
580+
def _change_state(instance: _Instance, *args: Any, **kwargs: Any) -> Incomplete:
566581
return fsm_meta.field.change_state(instance, func, *args, **kwargs)
567582

568583
if not wrapper_installed:
@@ -573,7 +588,7 @@ def _change_state(instance: _Instance, *args: Any, **kwargs: Any) -> _ToDo:
573588
return inner_transition
574589

575590

576-
def can_proceed(bound_method: _ToDo, check_conditions: bool = True) -> bool:
591+
def can_proceed(bound_method: Incomplete, check_conditions: bool = True) -> bool:
577592
"""
578593
Returns True if model in state allows to call bound_method
579594
@@ -590,7 +605,7 @@ def can_proceed(bound_method: _ToDo, check_conditions: bool = True) -> bool:
590605
return meta.has_transition(current_state) and (not check_conditions or meta.conditions_met(self, current_state))
591606

592607

593-
def has_transition_perm(bound_method: _ToDo, user: UserWithPermissions) -> bool:
608+
def has_transition_perm(bound_method: Incomplete, user: UserWithPermissions) -> bool:
594609
"""
595610
Returns True if model in state allows to call bound_method and user have rights on it
596611
"""
@@ -609,15 +624,15 @@ def has_transition_perm(bound_method: _ToDo, user: UserWithPermissions) -> bool:
609624

610625

611626
class State:
612-
def get_state(self, model: _Model, transition: Transition, result: Any, args: Any = [], kwargs: Any = {}) -> _ToDo:
627+
def get_state(self, model: _FSMModel, transition: Transition, result: Any, args: Any = [], kwargs: Any = {}) -> Incomplete:
613628
raise NotImplementedError
614629

615630

616631
class RETURN_VALUE(State):
617632
def __init__(self, *allowed_states: Sequence[_StateValue]) -> None:
618633
self.allowed_states = allowed_states if allowed_states else None
619634

620-
def get_state(self, model: _Model, transition: Transition, result: Any, args: Any = [], kwargs: Any = {}) -> _ToDo:
635+
def get_state(self, model: _FSMModel, transition: Transition, result: Any, args: Any = [], kwargs: Any = {}) -> Incomplete:
621636
if self.allowed_states is not None:
622637
if result not in self.allowed_states:
623638
raise InvalidResultState(f"{result} is not in list of allowed states\n{self.allowed_states}")
@@ -630,8 +645,8 @@ def __init__(self, func: Callable[..., _StateValue | Any], states: Sequence[_Sta
630645
self.allowed_states = states
631646

632647
def get_state(
633-
self, model: _Model, transition: Transition, result: _StateValue | Any, args: Any = [], kwargs: Any = {}
634-
) -> _ToDo:
648+
self, model: _FSMModel, transition: Transition, result: _StateValue | Any, args: Any = [], kwargs: Any = {}
649+
) -> Incomplete:
635650
result_state = self.func(model, *args, **kwargs)
636651
if self.allowed_states is not None:
637652
if result_state not in self.allowed_states:

pyproject.toml

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

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