Skip to content

Commit 2ba92ce

Browse files
authored
Merge pull request #150 from rsinger86/feature/generalize-conditions
Make conditions more flexible by extracting them from decorator signature to allow arbitrary conditions using callables
2 parents 2d1b466 + b8b6336 commit 2ba92ce

File tree

12 files changed

+645
-156
lines changed

12 files changed

+645
-156
lines changed
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import Any
5+
from typing import Iterable
6+
from typing import Union
7+
8+
from django_lifecycle import NotSet
9+
from django_lifecycle.conditions.base import ChainableCondition
10+
11+
12+
__all__ = [
13+
"WhenFieldValueWas",
14+
"WhenFieldValueIs",
15+
"WhenFieldHasChanged",
16+
"WhenFieldValueIsNot",
17+
"WhenFieldValueWasNot",
18+
"WhenFieldValueChangesTo",
19+
"Always",
20+
]
21+
22+
23+
@dataclass
24+
class WhenFieldValueWas(ChainableCondition):
25+
field_name: str
26+
value: Any = "*"
27+
28+
def __call__(
29+
self, instance: Any, update_fields: Union[Iterable[str], None] = None
30+
) -> bool:
31+
return self.value in (instance.initial_value(self.field_name), "*")
32+
33+
34+
@dataclass
35+
class WhenFieldValueIs(ChainableCondition):
36+
field_name: str
37+
value: Any = "*"
38+
39+
def __call__(
40+
self, instance: Any, update_fields: Union[Iterable[str], None] = None
41+
) -> bool:
42+
return self.value in (instance._current_value(self.field_name), "*")
43+
44+
45+
@dataclass
46+
class WhenFieldHasChanged(ChainableCondition):
47+
field_name: str
48+
has_changed: bool | None = None
49+
50+
def __call__(
51+
self, instance: Any, update_fields: Union[Iterable[str], None] = None
52+
) -> bool:
53+
is_partial_fields_update = update_fields is not None
54+
is_synced = (
55+
is_partial_fields_update is False or self.field_name in update_fields
56+
)
57+
if not is_synced:
58+
return False
59+
60+
return self.has_changed is None or self.has_changed == instance.has_changed(
61+
self.field_name
62+
)
63+
64+
65+
@dataclass
66+
class WhenFieldValueIsNot(ChainableCondition):
67+
field_name: str
68+
value: Any = NotSet
69+
70+
def __call__(
71+
self, instance: Any, update_fields: Union[Iterable[str], None] = None
72+
) -> bool:
73+
return (
74+
self.value is NotSet
75+
or instance._current_value(self.field_name) != self.value
76+
)
77+
78+
79+
@dataclass
80+
class WhenFieldValueWasNot(ChainableCondition):
81+
field_name: str
82+
value: Any = NotSet
83+
84+
def __call__(
85+
self, instance: Any, update_fields: Union[Iterable[str], None] = None
86+
) -> bool:
87+
return (
88+
self.value is NotSet
89+
or instance.initial_value(self.field_name) != self.value
90+
)
91+
92+
93+
@dataclass
94+
class WhenFieldValueChangesTo(ChainableCondition):
95+
field_name: str
96+
value: Any = NotSet
97+
98+
def __call__(
99+
self, instance: Any, update_fields: Union[Iterable[str], None] = None
100+
) -> bool:
101+
is_partial_fields_update = update_fields is not None
102+
is_synced = (
103+
is_partial_fields_update is False or self.field_name in update_fields
104+
)
105+
if not is_synced:
106+
return False
107+
108+
value_has_changed = bool(
109+
instance.initial_value(self.field_name) != self.value
110+
)
111+
new_value_is_the_expected = bool(
112+
instance._current_value(self.field_name) == self.value
113+
)
114+
return self.value is NotSet or (
115+
value_has_changed and new_value_is_the_expected
116+
)
117+
118+
119+
class Always:
120+
def __call__(self, instance: Any, update_fields=None):
121+
return True
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from __future__ import annotations
2+
3+
import operator
4+
from dataclasses import dataclass
5+
from typing import Any
6+
from typing import Callable
7+
from typing import Iterable
8+
from typing import Union
9+
10+
from django_lifecycle import types
11+
12+
13+
@dataclass
14+
class ChainedCondition:
15+
def __init__(
16+
self,
17+
left: types.Condition,
18+
right: types.Condition,
19+
operator: Callable[[Any, Any], bool],
20+
):
21+
self.left = left
22+
self.right = right
23+
self.operator = operator
24+
25+
def __and__(self, other):
26+
return ChainedCondition(self, other, operator=operator.and_)
27+
28+
def __or__(self, other):
29+
return ChainedCondition(self, other, operator=operator.or_)
30+
31+
def __call__(
32+
self, instance: Any, update_fields: Union[Iterable[str], None] = None
33+
) -> bool:
34+
left_result = self.left(instance, update_fields)
35+
right_result = self.right(instance, update_fields)
36+
return self.operator(left_result, right_result)
37+
38+
39+
class ChainableCondition:
40+
"""Base class for defining chainable conditions using `&` and `|`"""
41+
42+
def __and__(self, other) -> ChainedCondition:
43+
return ChainedCondition(self, other, operator=operator.and_)
44+
45+
def __or__(self, other) -> ChainedCondition:
46+
return ChainedCondition(self, other, operator=operator.or_)
47+
48+
def __call__(
49+
self, instance: Any, update_fields: Union[Iterable[str], None] = None
50+
) -> bool:
51+
...
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import Any
5+
from typing import List
6+
from typing import Optional
7+
8+
from django_lifecycle import NotSet
9+
from django_lifecycle.conditions.base import ChainableCondition
10+
from django_lifecycle.conditions import WhenFieldValueChangesTo
11+
from django_lifecycle.conditions import WhenFieldHasChanged
12+
from django_lifecycle.conditions import WhenFieldValueIsNot
13+
from django_lifecycle.conditions import WhenFieldValueIs
14+
from django_lifecycle.conditions import WhenFieldValueWas
15+
from django_lifecycle.conditions import WhenFieldValueWasNot
16+
17+
18+
@dataclass
19+
class When(ChainableCondition):
20+
when: Optional[str] = None
21+
was: Any = "*"
22+
is_now: Any = "*"
23+
has_changed: Optional[bool] = None
24+
is_not: Any = NotSet
25+
was_not: Any = NotSet
26+
changes_to: Any = NotSet
27+
28+
def __call__(self, instance: Any, update_fields=None) -> bool:
29+
has_changed_condition = WhenFieldHasChanged(
30+
self.when, has_changed=self.has_changed
31+
)
32+
if not has_changed_condition(instance, update_fields=update_fields):
33+
return False
34+
35+
changes_to_condition = WhenFieldValueChangesTo(self.when, value=self.changes_to)
36+
if not changes_to_condition(instance, self.when):
37+
return False
38+
39+
is_now_condition = WhenFieldValueIs(self.when, value=self.is_now)
40+
if not is_now_condition(instance, self.when):
41+
return False
42+
43+
was_condition = WhenFieldValueWas(self.when, value=self.was)
44+
if not was_condition(instance, self.when):
45+
return False
46+
47+
was_not_condition = WhenFieldValueWasNot(self.when, value=self.was_not)
48+
if not was_not_condition(instance, self.when):
49+
return False
50+
51+
is_not_condition = WhenFieldValueIsNot(self.when, value=self.is_not)
52+
if not is_not_condition(instance, self.when):
53+
return False
54+
55+
return True
56+
57+
58+
@dataclass
59+
class WhenAny:
60+
when_any: Optional[List[str]] = None
61+
was: Any = "*"
62+
is_now: Any = "*"
63+
has_changed: Optional[bool] = None
64+
is_not: Any = NotSet
65+
was_not: Any = NotSet
66+
changes_to: Any = NotSet
67+
68+
def __call__(self, instance: Any, update_fields=None) -> bool:
69+
conditions = (
70+
When(
71+
when=field,
72+
was=self.was,
73+
is_now=self.is_now,
74+
has_changed=self.has_changed,
75+
is_not=self.is_not,
76+
was_not=self.was_not,
77+
changes_to=self.changes_to,
78+
)
79+
for field in self.when_any
80+
)
81+
return any(
82+
condition(instance, update_fields=update_fields) for condition in conditions
83+
)

django_lifecycle/decorators.py

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
1+
from __future__ import annotations
2+
3+
import operator
4+
from django_lifecycle import types
15
from dataclasses import dataclass
6+
from functools import reduce
27
from functools import wraps
38
from typing import Any
9+
from typing import Callable
410
from typing import List, Optional
511

612
from django_lifecycle import NotSet
13+
from .conditions import Always
14+
from .conditions.legacy import When
715
from .dataclass_validation import Validations
816
from .hooks import VALID_HOOKS
917
from .priority import DEFAULT_PRIORITY
@@ -16,6 +24,11 @@ class DjangoLifeCycleException(Exception):
1624
@dataclass(order=False)
1725
class HookConfig(Validations):
1826
hook: str
27+
on_commit: bool = False
28+
priority: int = DEFAULT_PRIORITY
29+
condition: Optional[types.Condition] = None
30+
31+
# Legacy parameters
1932
when: Optional[str] = None
2033
when_any: Optional[List[str]] = None
2134
was: Any = "*"
@@ -24,8 +37,57 @@ class HookConfig(Validations):
2437
is_not: Any = NotSet
2538
was_not: Any = NotSet
2639
changes_to: Any = NotSet
27-
on_commit: bool = False
28-
priority: int = DEFAULT_PRIORITY
40+
41+
def __post_init__(self):
42+
super().__post_init__()
43+
44+
if self.condition is None:
45+
self.condition = self._get_condition_from_legacy_parameters()
46+
47+
def _legacy_parameters_have_been_passed(self) -> bool:
48+
return any(
49+
[
50+
self.when is not None,
51+
self.when_any is not None,
52+
self.was != "*",
53+
self.is_now != "*",
54+
self.has_changed is not None,
55+
self.is_not is not NotSet,
56+
self.was_not is not NotSet,
57+
self.changes_to is not NotSet,
58+
]
59+
)
60+
61+
def _get_condition_from_legacy_parameters(self) -> Callable:
62+
if self.when:
63+
return When(
64+
when=self.when,
65+
was=self.was,
66+
is_now=self.is_now,
67+
has_changed=self.has_changed,
68+
is_not=self.is_not,
69+
was_not=self.was_not,
70+
changes_to=self.changes_to,
71+
)
72+
73+
elif self.when_any:
74+
return reduce(
75+
operator.or_,
76+
[
77+
When(
78+
when=field,
79+
was=self.was,
80+
is_now=self.is_now,
81+
has_changed=self.has_changed,
82+
is_not=self.is_not,
83+
was_not=self.was_not,
84+
changes_to=self.changes_to,
85+
)
86+
for field in self.when_any
87+
],
88+
)
89+
else:
90+
return Always()
2991

3092
def validate_hook(self, value, **kwargs):
3193
if value not in VALID_HOOKS:
@@ -101,9 +163,16 @@ def validate_when_and_when_any(self):
101163
"Can pass either 'when' or 'when_any' but not both"
102164
)
103165

166+
def validate_condition_and_legacy_parameters_are_not_combined(self):
167+
if self.condition is not None and self._legacy_parameters_have_been_passed():
168+
raise DjangoLifeCycleException(
169+
"Legacy parameters (when, when_any, ...) can't be used together with condition"
170+
)
171+
104172
def validate(self):
105173
self.validate_when_and_when_any()
106174
self.validate_on_commit_only_for_after_hooks()
175+
self.validate_condition_and_legacy_parameters_are_not_combined()
107176

108177
def __lt__(self, other):
109178
if not isinstance(other, HookConfig):

0 commit comments

Comments
 (0)