Skip to content

Commit e886b5a

Browse files
GefMarGefMars-aleshinSergei Aleshin
authored
Process as subprocess conditions (#26)
* update: pre-commit hooks * update: flake8 selected * update: versioning format * add: conditions support * Process as subprocess conditions (tests + doc) (#27) * add: condition tests update: pyproject.toml del: attrs from requirements_dev.txt * add: example with conditions add: test for the process with conditions * update: doc --------- Co-authored-by: Sergei Aleshin <[email protected]> --------- Co-authored-by: GefMar <[email protected]> Co-authored-by: Sergei Aleshin <[email protected]> Co-authored-by: Sergei Aleshin <[email protected]>
1 parent 856ac75 commit e886b5a

File tree

15 files changed

+338
-12
lines changed

15 files changed

+338
-12
lines changed

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ repos:
1818
- id: requirements-txt-fixer
1919

2020
- repo: https://github.com/astral-sh/ruff-pre-commit
21-
rev: v0.8.0
21+
rev: v0.8.6
2222
hooks:
2323
- id: ruff-format
2424
- id: ruff
@@ -32,7 +32,7 @@ repos:
3232
additional_dependencies: ["flake8-pyproject", "wemake-python-styleguide"]
3333

3434
- repo: https://github.com/pre-commit/mirrors-mypy
35-
rev: 'v1.13.0'
35+
rev: 'v1.14.1'
3636
hooks:
3737
- id: mypy
3838
additional_dependencies: [ "no_implicit_optional" ]

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,13 @@ pip install logic-processes-layer
1818

1919
## New Features
2020

21-
- ProcessAsSubprocess: Use any process as a subprocess.
22-
- InitMapper: Simplifies process initialization with attribute mapping from the context.
23-
- ProcessAttr: Retrieve attributes from the process context or directly from the process.
21+
- **ProcessAsSubprocess**: Use any process as a subprocess.
22+
- **InitMapper**: Simplifies process initialization with attribute mapping from the context.
23+
- **ProcessAttr**: Retrieve attributes from the process context or directly from the process.
24+
- **Conditions Support**: Add logical conditions to control the execution of processes.
25+
- **AttrCondition**: Define conditions based on attributes of the process or context.
26+
- **FunctionCondition**: Wrap custom functions as conditions.
27+
- **Logical Operators**: Combine conditions with `&` (AND), `|` (OR), `~` (NOT), and `^` (XOR) for advanced logic.
2428
- [Examples](tests/examples) of how to use the logic_processes_layer package.
2529

2630

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from __future__ import annotations
2+
3+
from .condition import *
4+
from .operator_enums import *
5+
from .skiped import *
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from __future__ import annotations
2+
3+
4+
__all__ = ("AttrCondition", "FunctionCondition", "OperatorCondition")
5+
6+
from functools import partial, reduce
7+
import typing
8+
9+
from ...context import BaseProcessorContext
10+
from .operator_enums import OperatorEnum
11+
12+
13+
if typing.TYPE_CHECKING:
14+
from ...protocols import CallableConditionProtocol
15+
from ..mappers import ProcessAttr
16+
17+
ContextT = typing.TypeVar("ContextT", bound=BaseProcessorContext)
18+
OperatorCallablesT = typing.Callable[[typing.Iterable[typing.Any]], bool]
19+
OperatorMapT = typing.Dict[OperatorEnum, OperatorCallablesT]
20+
21+
22+
class OperatorCondition(typing.Generic[ContextT]):
23+
operator_map: typing.ClassVar[OperatorMapT] = {
24+
OperatorEnum.AND: all,
25+
OperatorEnum.OR: any,
26+
OperatorEnum.XOR: partial(reduce, lambda itm, other: itm ^ other),
27+
}
28+
29+
def __init__(
30+
self,
31+
conditions: typing.Iterable[CallableConditionProtocol],
32+
*,
33+
operator: OperatorEnum = OperatorEnum.AND,
34+
negated: bool = False,
35+
):
36+
self.conditions = conditions
37+
self.negated = negated
38+
self.operator = operator
39+
40+
def __call__(self, context: ContextT) -> bool:
41+
operator_f = self.operator_map[self.operator]
42+
result = operator_f(bool(condition(context)) for condition in self.conditions)
43+
return not result if self.negated else result
44+
45+
def __invert__(self) -> OperatorCondition:
46+
return OperatorCondition([self], operator=self.operator, negated=not self.negated)
47+
48+
def __and__(self, other: CallableConditionProtocol) -> OperatorCondition:
49+
return OperatorCondition([self, other], operator=OperatorEnum.AND)
50+
51+
def __or__(self, other: CallableConditionProtocol) -> OperatorCondition:
52+
return OperatorCondition([self, other], operator=OperatorEnum.OR)
53+
54+
def __xor__(self, other: CallableConditionProtocol) -> OperatorCondition:
55+
return OperatorCondition([self, other], operator=OperatorEnum.XOR)
56+
57+
58+
class AttrCondition(OperatorCondition[ContextT]):
59+
def __init__(self, process_attr: ProcessAttr, *, negated: bool = False):
60+
self.process_attr = process_attr
61+
super().__init__(operator=OperatorEnum.AND, conditions=[self], negated=negated)
62+
63+
def __call__(self, context: ContextT) -> bool:
64+
value = self.process_attr.get_value(context)
65+
result = bool(value)
66+
return not result if self.negated else result
67+
68+
69+
class FunctionCondition(OperatorCondition[ContextT]):
70+
def __init__(self, func: CallableConditionProtocol, *, negated: bool = False):
71+
super().__init__(operator=OperatorEnum.AND, conditions=[func], negated=negated)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from __future__ import annotations
2+
3+
import enum
4+
5+
6+
class OperatorEnum(enum.Enum):
7+
AND = "AND"
8+
OR = "OR"
9+
XOR = "XOR"
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from __future__ import annotations
2+
3+
4+
__all__ = ("ConditionSkipped",)
5+
6+
import dataclasses
7+
import typing
8+
9+
from ...processors import BaseProcessor
10+
11+
12+
ProcessT = typing.TypeVar("ProcessT", bound=BaseProcessor)
13+
14+
15+
@dataclasses.dataclass(unsafe_hash=True)
16+
class ConditionSkipped(typing.Generic[ProcessT]):
17+
process: ProcessT
18+
19+
def __str__(self):
20+
return f"Conditions skipped for {self.process}"

logic_processes_layer/extensions/process_as_subprocess.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
import typing
88

99
from ..processors import BaseProcessor
10-
from ..sub_processors import BaseSubprocessor
10+
from ..sub_processors import BaseSubprocessor, ContextT
11+
from .conditions import ConditionSkipped
1112

1213

1314
if typing.TYPE_CHECKING:
@@ -19,11 +20,21 @@
1920
@dataclasses.dataclass(unsafe_hash=True)
2021
class ProcessAsSubprocess(BaseSubprocessor, typing.Generic[ProcessT]):
2122
process_cls: type[ProcessT]
22-
init_mapper: None | InitMapper = dataclasses.field(hash=False, default=None)
23+
init_mapper: InitMapper | None = dataclasses.field(hash=False, default=None)
24+
conditions: typing.Iterable[typing.Callable[[ContextT], bool]] = dataclasses.field(
25+
hash=False, default_factory=tuple
26+
)
2327

2428
def __call__(self):
2529
args = ()
2630
kwargs: dict[str, typing.Any] = {}
2731
if self.init_mapper is not None:
2832
args, kwargs = self.init_mapper(self.context)
29-
return self.process_cls(*args, **kwargs)()
33+
34+
subprocessor = self.process_cls(*args, **kwargs)
35+
if not self.check_conditions():
36+
return ConditionSkipped(subprocessor)
37+
return subprocessor()
38+
39+
def check_conditions(self) -> bool:
40+
return all(condition(self.context) for condition in self.conditions)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from __future__ import annotations
2+
3+
from .condition import *
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from __future__ import annotations
2+
3+
4+
__all__ = ("CallableConditionProtocol",)
5+
6+
import typing
7+
8+
from ..context import BaseProcessorContext
9+
10+
11+
ContextT_contra = typing.TypeVar("ContextT_contra", bound=BaseProcessorContext, contravariant=True)
12+
13+
14+
@typing.runtime_checkable
15+
class CallableConditionProtocol(typing.Protocol[ContextT_contra]):
16+
def __call__(self, context: ContextT_contra) -> bool: ... # noqa: WPS220, WPS428

logic_processes_layer/sub_processors/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33

4-
__all__ = ("BaseSubprocessor",)
4+
__all__ = ("BaseSubprocessor", "CallResultT", "ContextT")
55

66
import dataclasses
77
import typing

pyproject.toml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "logic_processes_layer"
7-
version = "1.1.4"
7+
version = "1.2025.01.06"
88
requires-python = ">=3.8"
99
description = "Abstractions for create business logic"
1010
readme = "README.md"
@@ -18,6 +18,7 @@ classifiers = ["License :: OSI Approved :: MIT License"]
1818
max-line-length = 120
1919
inline_quotes = "double"
2020
format = "wemake"
21+
select = ["WPS"]
2122

2223
ignore = [
2324
"C", "D", "F", "DAR", "RST", "Q", "I", "S",
@@ -28,4 +29,6 @@ ignore = [
2829
"WPS602", "WPS605"]
2930

3031
per-file-ignores = [
31-
"__init__.py:WPS347, WPS440",]
32+
"__init__.py:WPS347, WPS440",
33+
"*_enums.py:WPS115",
34+
"tests/test_*.py:WPS202, WPS442",]

ruff.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ ignore = [
1616
"TD001", "TID252",
1717
]
1818

19-
external = ["WPS221", "WPS432", "WPS529", "WPS601","WPS428"]
19+
external = ["WPS220", "WPS221", "WPS432", "WPS529", "WPS601","WPS428"]
2020

2121
[lint.per-file-ignores]
2222
"__init__.py" = ["F401", "F403"]
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from __future__ import annotations
2+
3+
import dataclasses
4+
5+
from logic_processes_layer import BaseProcessor
6+
from logic_processes_layer.extensions import InitMapper, ProcessAsSubprocess, ProcessAttr
7+
from logic_processes_layer.extensions.conditions import AttrCondition, FunctionCondition
8+
9+
10+
_ONE_H = 100
11+
12+
13+
@dataclasses.dataclass
14+
class NotifyClientProcess(BaseProcessor):
15+
notification_message: str
16+
17+
def run(self): ... # noqa: WPS428
18+
19+
20+
_order_is_completed_condition: AttrCondition = AttrCondition(ProcessAttr("is_completed"))
21+
_order_amount_condition: FunctionCondition = FunctionCondition(lambda context: context.process.order_amount > _ONE_H)
22+
_client_agreed_condition: AttrCondition = AttrCondition(ProcessAttr("client_agreed_to_notifications"))
23+
_combined_condition = _order_is_completed_condition & (_order_amount_condition | _client_agreed_condition)
24+
25+
_notify_client_process = ProcessAsSubprocess(
26+
process_cls=NotifyClientProcess,
27+
init_mapper=InitMapper(notification_message=ProcessAttr("notification_message")),
28+
conditions=(_combined_condition,),
29+
)
30+
31+
32+
@dataclasses.dataclass
33+
class OrderStatusProcess(BaseProcessor):
34+
is_completed: bool
35+
client_agreed_to_notifications: bool
36+
order_amount: float
37+
notification_message: str = dataclasses.field(init=False, default="Order completed successfully!")
38+
39+
post_run = (_notify_client_process,)
40+
41+
def run(self): ... # noqa: WPS428

tests/test_conditions.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
from __future__ import annotations
2+
3+
from unittest.mock import Mock
4+
5+
import pytest
6+
7+
from logic_processes_layer.extensions import ProcessAttr
8+
from logic_processes_layer.extensions.conditions import AttrCondition, FunctionCondition
9+
10+
11+
_TWO = 2
12+
13+
14+
@pytest.fixture
15+
def mock_process_attr():
16+
return Mock(spec=ProcessAttr)
17+
18+
19+
@pytest.fixture
20+
def mock_context():
21+
return Mock()
22+
23+
24+
@pytest.fixture
25+
def attr_condition(mock_process_attr):
26+
return AttrCondition(mock_process_attr)
27+
28+
29+
def test_attr_condition_positive(attr_condition, mock_process_attr, mock_context):
30+
mock_process_attr.get_value.return_value = True
31+
assert attr_condition(mock_context) is True
32+
mock_process_attr.get_value.assert_called_once_with(mock_context)
33+
34+
35+
def test_attr_condition_negative(attr_condition, mock_process_attr, mock_context):
36+
mock_process_attr.get_value.return_value = False
37+
assert attr_condition(mock_context) is False
38+
mock_process_attr.get_value.assert_called_once_with(mock_context)
39+
40+
41+
def test_attr_condition_negated(attr_condition, mock_process_attr, mock_context):
42+
mock_process_attr.get_value.return_value = True
43+
condition = ~attr_condition
44+
45+
assert condition(mock_context) is False
46+
mock_process_attr.get_value.assert_called_once_with(mock_context)
47+
48+
49+
def test_function_condition_positive(mock_context):
50+
mock_func = Mock(return_value=True)
51+
condition: FunctionCondition = FunctionCondition(mock_func)
52+
53+
assert condition(mock_context) is True
54+
mock_func.assert_called_once_with(mock_context)
55+
56+
57+
def test_function_condition_negative(mock_context):
58+
mock_func = Mock(return_value=False)
59+
condition: FunctionCondition = FunctionCondition(mock_func)
60+
61+
assert condition(mock_context) is False
62+
mock_func.assert_called_once_with(mock_context)
63+
64+
65+
def test_function_condition_negated(mock_context):
66+
mock_func = Mock(return_value=True)
67+
condition = ~FunctionCondition(mock_func)
68+
69+
assert condition(mock_context) is False
70+
mock_func.assert_called_once_with(mock_context)
71+
72+
73+
def test_operator_condition_and(attr_condition, mock_process_attr, mock_context):
74+
mock_process_attr.get_value.side_effect = (True, True)
75+
condition1 = attr_condition
76+
condition2 = attr_condition
77+
combined_condition = condition1 & condition2
78+
79+
assert combined_condition(mock_context) is True
80+
assert mock_process_attr.get_value.call_count == _TWO
81+
82+
83+
def test_operator_condition_or(attr_condition, mock_process_attr, mock_context):
84+
mock_process_attr.get_value.side_effect = (False, True)
85+
condition1 = attr_condition
86+
condition2 = attr_condition
87+
combined_condition = condition1 | condition2
88+
89+
assert combined_condition(mock_context) is True
90+
assert mock_process_attr.get_value.call_count == _TWO
91+
92+
93+
def test_operator_condition_complex(attr_condition, mock_process_attr, mock_context):
94+
mock_process_attr.get_value.side_effect = (True, False, True)
95+
condition1 = attr_condition
96+
condition2 = ~attr_condition
97+
condition3 = attr_condition
98+
condition4: FunctionCondition = FunctionCondition(lambda _: True)
99+
combined_condition = condition1 & (condition2 | condition3) & condition4
100+
101+
assert combined_condition(mock_context) is True
102+
assert mock_process_attr.get_value.call_count == _TWO

0 commit comments

Comments
 (0)