django-queuebie 0.1.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_queuebie-0.3.0/.ambient-package-update/templates/snippets/content.tpl +53 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/.gitignore +0 -2
- django_queuebie-0.3.0/CHANGES.md +10 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/PKG-INFO +47 -1
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/README.md +46 -0
- django_queuebie-0.3.0/docs/features/motivation.md +30 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/docs/features/settings.md +2 -2
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/pyproject.toml +3 -6
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/queuebie/__init__.py +1 -1
- django_queuebie-0.3.0/queuebie/database_blocker.py +25 -0
- django_queuebie-0.3.0/queuebie/runner.py +63 -0
- django_queuebie-0.3.0/testapp/handlers/commands/testapp.py +45 -0
- django_queuebie-0.3.0/testapp/handlers/events/testapp.py +20 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/testapp/messages/commands/my_commands.py +15 -0
- django_queuebie-0.3.0/testapp/messages/events/my_events.py +18 -0
- django_queuebie-0.3.0/tests/test_database_blocker.py +17 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/tests/test_registry.py +9 -5
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/tests/test_runner.py +34 -12
- django_queuebie-0.1.0/.ambient-package-update/templates/snippets/content.tpl +0 -7
- django_queuebie-0.1.0/CHANGES.md +0 -4
- django_queuebie-0.1.0/docs/features/motivation.md +0 -14
- django_queuebie-0.1.0/queuebie/runner.py +0 -58
- django_queuebie-0.1.0/testapp/handlers/commands/testapp.py +0 -28
- django_queuebie-0.1.0/testapp/handlers/events/testapp.py +0 -11
- django_queuebie-0.1.0/testapp/messages/events/my_events.py +0 -8
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/.ambient-package-update/metadata.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/.ambient-package-update/templates/snippets/tagline.tpl +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/.editorconfig +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/.github/workflows/ci.yml +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/.pre-commit-config.yaml +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/.readthedocs.yaml +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/LICENSE +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/LICENSE.md +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/MANIFEST.in +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/SECURITY.md +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/docs/Makefile +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/docs/conf.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/docs/features/changelog.rst +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/docs/features/getting-started.md +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/docs/features/introduction.md +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/docs/features/setup.md +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/docs/index.rst +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/docs/make.bat +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/manage.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/queuebie/apps.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/queuebie/exceptions.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/queuebie/logger.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/queuebie/management/__init__.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/queuebie/management/commands/__init__.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/queuebie/management/commands/clear_queuebie_registry.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/queuebie/messages.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/queuebie/registry.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/queuebie/settings.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/queuebie/utils.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/scripts/unix/install_requirements.sh +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/scripts/unix/publish_to_pypi.sh +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/scripts/windows/install_requirements.ps1 +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/scripts/windows/publish_to_pypi.ps1 +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/settings.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/testapp/__init__.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/testapp/handlers/__init__.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/testapp/handlers/commands/__init__.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/testapp/handlers/events/__init__.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/testapp/messages/__init__.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/testapp/messages/commands/__init__.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/testapp/messages/commands/other_commands.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/testapp/messages/events/__init__.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/testapp/urls.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/tests/__init__.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/tests/helpers/__init__.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/tests/helpers/commands.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/tests/helpers/events.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/tests/management_commands/__init__.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/tests/management_commands/test_clear_queuebie_registry.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/tests/test_apps.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/tests/test_exceptions.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/tests/test_logger.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/tests/test_messages.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/tests/test_settings.py +0 -0
- {django_queuebie-0.1.0 → django_queuebie-0.3.0}/tests/test_utils.py +0 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
## Features
|
|
2
|
+
|
|
3
|
+
* Split up your business logic in commands and events
|
|
4
|
+
* Commands are imperatives telling your system what to do, events reflect that something has happened
|
|
5
|
+
* Register light-weight functions via a decorator to listen to your commands and events
|
|
6
|
+
* Message handlers receive the context of the message (command or event), providing an explicit API
|
|
7
|
+
* No magic, no side effects since the queue works synchronously
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
import dataclasses
|
|
11
|
+
|
|
12
|
+
from queuebie.runner import handle_message
|
|
13
|
+
from queuebie.messages import Command, Event
|
|
14
|
+
from queuebie import message_registry
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Example command
|
|
18
|
+
@dataclasses.dataclass(kw_only=True)
|
|
19
|
+
class BuyProduct(Command):
|
|
20
|
+
product_id: int
|
|
21
|
+
customer_id: int
|
|
22
|
+
price: float
|
|
23
|
+
currency: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Example event
|
|
27
|
+
@dataclasses.dataclass(kw_only=True)
|
|
28
|
+
class ProductBought(Event):
|
|
29
|
+
product_id: int
|
|
30
|
+
customer_id: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Example handler
|
|
34
|
+
@message_registry.register_command(BuyProduct)
|
|
35
|
+
def handle_buy_product(context: BuyProduct) -> Event:
|
|
36
|
+
# Here lives your business logic
|
|
37
|
+
|
|
38
|
+
return ProductBought(
|
|
39
|
+
product_id=context.product_id,
|
|
40
|
+
customer_id=context.customer_id,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Start queue and process messages
|
|
45
|
+
handle_message(
|
|
46
|
+
BuyProduct(
|
|
47
|
+
product_id=product.id,
|
|
48
|
+
customer_id=customer.id,
|
|
49
|
+
price=12.99,
|
|
50
|
+
currency="EUR",
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: django-queuebie
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Simple message queue for commands and events (CQRS)
|
|
5
5
|
Author-email: Ambient Digital <hello@ambient.digital>
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -65,6 +65,52 @@ A simple message queue for commands and events (CQRS) for Django.
|
|
|
65
65
|
* Message handlers receive the context of the message (command or event), providing an explicit API
|
|
66
66
|
* No magic, no side effects since the queue works synchronously
|
|
67
67
|
|
|
68
|
+
```python
|
|
69
|
+
import dataclasses
|
|
70
|
+
|
|
71
|
+
from queuebie.runner import handle_message
|
|
72
|
+
from queuebie.messages import Command, Event
|
|
73
|
+
from queuebie import message_registry
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# Example command
|
|
77
|
+
@dataclasses.dataclass(kw_only=True)
|
|
78
|
+
class BuyProduct(Command):
|
|
79
|
+
product_id: int
|
|
80
|
+
customer_id: int
|
|
81
|
+
price: float
|
|
82
|
+
currency: str
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# Example event
|
|
86
|
+
@dataclasses.dataclass(kw_only=True)
|
|
87
|
+
class ProductBought(Event):
|
|
88
|
+
product_id: int
|
|
89
|
+
customer_id: int
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# Example handler
|
|
93
|
+
@message_registry.register_command(BuyProduct)
|
|
94
|
+
def handle_buy_product(context: BuyProduct) -> Event:
|
|
95
|
+
# Here lives your business logic
|
|
96
|
+
|
|
97
|
+
return ProductBought(
|
|
98
|
+
product_id=context.product_id,
|
|
99
|
+
customer_id=context.customer_id,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# Start queue and process messages
|
|
104
|
+
handle_message(
|
|
105
|
+
BuyProduct(
|
|
106
|
+
product_id=product.id,
|
|
107
|
+
customer_id=customer.id,
|
|
108
|
+
price=12.99,
|
|
109
|
+
currency="EUR",
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
```
|
|
113
|
+
|
|
68
114
|
## Installation
|
|
69
115
|
|
|
70
116
|
- Install the package via pip:
|
|
@@ -21,6 +21,52 @@ A simple message queue for commands and events (CQRS) for Django.
|
|
|
21
21
|
* Message handlers receive the context of the message (command or event), providing an explicit API
|
|
22
22
|
* No magic, no side effects since the queue works synchronously
|
|
23
23
|
|
|
24
|
+
```python
|
|
25
|
+
import dataclasses
|
|
26
|
+
|
|
27
|
+
from queuebie.runner import handle_message
|
|
28
|
+
from queuebie.messages import Command, Event
|
|
29
|
+
from queuebie import message_registry
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Example command
|
|
33
|
+
@dataclasses.dataclass(kw_only=True)
|
|
34
|
+
class BuyProduct(Command):
|
|
35
|
+
product_id: int
|
|
36
|
+
customer_id: int
|
|
37
|
+
price: float
|
|
38
|
+
currency: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Example event
|
|
42
|
+
@dataclasses.dataclass(kw_only=True)
|
|
43
|
+
class ProductBought(Event):
|
|
44
|
+
product_id: int
|
|
45
|
+
customer_id: int
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Example handler
|
|
49
|
+
@message_registry.register_command(BuyProduct)
|
|
50
|
+
def handle_buy_product(context: BuyProduct) -> Event:
|
|
51
|
+
# Here lives your business logic
|
|
52
|
+
|
|
53
|
+
return ProductBought(
|
|
54
|
+
product_id=context.product_id,
|
|
55
|
+
customer_id=context.customer_id,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Start queue and process messages
|
|
60
|
+
handle_message(
|
|
61
|
+
BuyProduct(
|
|
62
|
+
product_id=product.id,
|
|
63
|
+
customer_id=customer.id,
|
|
64
|
+
price=12.99,
|
|
65
|
+
currency="EUR",
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
```
|
|
69
|
+
|
|
24
70
|
## Installation
|
|
25
71
|
|
|
26
72
|
- Install the package via pip:
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Motivation
|
|
2
|
+
|
|
3
|
+
You might wonder what's the USP for this package. Messages (event) queues are usually async and use a broker, etc.
|
|
4
|
+
|
|
5
|
+
The main goal is to provide a way to separate your domains in Django and reduce coupling between domains or Django apps.
|
|
6
|
+
|
|
7
|
+
So why should you use "django-queuebie"?
|
|
8
|
+
|
|
9
|
+
* Thinking in commands and events splits up your business logic from one big chunk to manageable sizes
|
|
10
|
+
* Handlers are function-based with a defined input (the context), they are predictable and easy to test
|
|
11
|
+
* Decoupling different parts of your application by listening to events instead of imperative programming
|
|
12
|
+
* Avoid putting business logic in your view layer since it has to live inside a handler function
|
|
13
|
+
* Creates an explicit pattern for connecting services (places of different business logic) instead of chaining them
|
|
14
|
+
individually
|
|
15
|
+
|
|
16
|
+
# Why not Django signals?
|
|
17
|
+
|
|
18
|
+
The reader might think "Hey, sync queues in Django? I already have this!" — and that's true, somehow.
|
|
19
|
+
|
|
20
|
+
The key differences are mostly on a semantic level.
|
|
21
|
+
|
|
22
|
+
First, commands and events are a concept that doesn't exist for Django signals. They provide a proven way of separating
|
|
23
|
+
your business logic, and in addition, provide a basis for discussion with non-technical folks.
|
|
24
|
+
|
|
25
|
+
Furthermore, queuebie messages (commands and events) don't belong to a model. Models represent the way you store your
|
|
26
|
+
data. That's not necessarily the best way to structure your custom logic. So by using signals, you're strongly primed to
|
|
27
|
+
think in models, meaning database structure, and not in domain concerns.
|
|
28
|
+
|
|
29
|
+
Messages provide an explicit context via a dataclass which can be interpreted as an explicit internal API to communicate
|
|
30
|
+
between your domains. That's a huge plus since explicit is better than implicit.
|
|
@@ -33,8 +33,8 @@ Take care to use the same name in the logging configuration in your Django setti
|
|
|
33
33
|
|
|
34
34
|
## QUEUEBIE_STRICT_MODE
|
|
35
35
|
|
|
36
|
-
Queuebie enforces by default that commands are not used outside its domain (aka Django app)
|
|
37
|
-
restriction for whatever reason, you can do so.
|
|
36
|
+
Queuebie enforces by default that commands are not used outside its domain (aka Django app) and event handlers don't
|
|
37
|
+
talk to the database. If you want to skip that restriction for whatever reason, you can do so.
|
|
38
38
|
|
|
39
39
|
```python
|
|
40
40
|
QUEUEBIE_STRICT_MODE = False
|
|
@@ -93,8 +93,8 @@ lint.select = [
|
|
|
93
93
|
"INP" # Ban PEP-420 implicit namespace packages
|
|
94
94
|
]
|
|
95
95
|
lint.ignore = [
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
"TD002", # Missing author in TODO
|
|
97
|
+
"TD003", # Missing issue link on the line following this TODO
|
|
98
98
|
]
|
|
99
99
|
|
|
100
100
|
# Allow autofix for all enabled rules (when `--fix`) is provided.
|
|
@@ -179,26 +179,23 @@ line-ending = "auto"
|
|
|
179
179
|
# Allow seemingly unused imports
|
|
180
180
|
"F401",
|
|
181
181
|
]
|
|
182
|
-
|
|
183
182
|
"**/tests/**/test_*.py" = [
|
|
184
183
|
# Allow boolean positional params in tests (for assertIs())
|
|
185
184
|
"FBT003",
|
|
186
185
|
]
|
|
187
|
-
|
|
188
186
|
"scripts/*.py" = [
|
|
189
187
|
# Checks for packages that are missing an __init__.py file
|
|
190
188
|
"INP001",
|
|
191
189
|
]
|
|
192
|
-
|
|
193
190
|
".ambient-package-update/*.py" = [
|
|
194
191
|
# Checks for packages that are missing an __init__.py file
|
|
195
192
|
"INP001",
|
|
196
193
|
]
|
|
197
|
-
|
|
198
194
|
"docs/*.py" = [
|
|
199
195
|
# Checks for packages that are missing an __init__.py file
|
|
200
196
|
"INP001",
|
|
201
197
|
]
|
|
198
|
+
|
|
202
199
|
[tool.tox]
|
|
203
200
|
legacy_tox_ini = """
|
|
204
201
|
[testenv]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from unittest.mock import patch
|
|
2
|
+
|
|
3
|
+
from django.db import connections
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DatabaseAccessDeniedError(RuntimeError):
|
|
7
|
+
def __init__(self):
|
|
8
|
+
super().__init__("Database access is disabled in this context.")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BlockDatabaseAccess:
|
|
12
|
+
def __enter__(self):
|
|
13
|
+
# Patcht alle aktiven DB-Verbindungen, sodass jede Query fehlschlägt
|
|
14
|
+
self.patches = [
|
|
15
|
+
patch.object(connections[alias], "cursor", side_effect=self._raise_error) for alias in connections
|
|
16
|
+
]
|
|
17
|
+
for p in self.patches:
|
|
18
|
+
p.start()
|
|
19
|
+
|
|
20
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
21
|
+
for p in self.patches:
|
|
22
|
+
p.stop()
|
|
23
|
+
|
|
24
|
+
def _raise_error(self, *args, **kwargs):
|
|
25
|
+
raise DatabaseAccessDeniedError()
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
from contextlib import nullcontext
|
|
3
|
+
|
|
4
|
+
from django.db import transaction
|
|
5
|
+
|
|
6
|
+
from queuebie import message_registry
|
|
7
|
+
from queuebie.database_blocker import BlockDatabaseAccess
|
|
8
|
+
from queuebie.exceptions import InvalidMessageTypeError
|
|
9
|
+
from queuebie.logger import get_logger
|
|
10
|
+
from queuebie.messages import Command, Event, Message
|
|
11
|
+
from queuebie.settings import get_queuebie_strict_mode
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def handle_message(messages: Message | list[Message]) -> None:
|
|
15
|
+
queue: list[Message] = messages if isinstance(messages, list) else [messages]
|
|
16
|
+
|
|
17
|
+
for message in queue:
|
|
18
|
+
if not isinstance(message, (Command, Event)):
|
|
19
|
+
raise InvalidMessageTypeError(class_name=message.__class__.__name__)
|
|
20
|
+
|
|
21
|
+
# Run auto-registry
|
|
22
|
+
message_registry.autodiscover()
|
|
23
|
+
|
|
24
|
+
with transaction.atomic():
|
|
25
|
+
while queue:
|
|
26
|
+
message = queue.pop(0)
|
|
27
|
+
if isinstance(message, Command):
|
|
28
|
+
handler_list = message_registry.command_dict.get(message.module_path(), [])
|
|
29
|
+
block_db_access = False
|
|
30
|
+
else:
|
|
31
|
+
handler_list = message_registry.event_dict.get(message.module_path(), [])
|
|
32
|
+
block_db_access = True if get_queuebie_strict_mode() else False
|
|
33
|
+
|
|
34
|
+
new_messages = _process_message(handler_list=handler_list, message=message, block_db_access=block_db_access)
|
|
35
|
+
queue.extend(new_messages)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _process_message(*, handler_list: list, message: [Command, Event], block_db_access: bool) -> list[Message]:
|
|
39
|
+
"""
|
|
40
|
+
Handler to process messages of type "Command"
|
|
41
|
+
"""
|
|
42
|
+
logger = get_logger()
|
|
43
|
+
messages = []
|
|
44
|
+
|
|
45
|
+
for handler in handler_list:
|
|
46
|
+
try:
|
|
47
|
+
logger.debug(
|
|
48
|
+
f"Handling command '{message.module_path()}' ({message.uuid}) with handler '{handler['name']}'."
|
|
49
|
+
)
|
|
50
|
+
module = importlib.import_module(handler["module"])
|
|
51
|
+
handler_function = getattr(module, handler["name"])
|
|
52
|
+
with BlockDatabaseAccess() if block_db_access else nullcontext():
|
|
53
|
+
handler_messages = handler_function(context=message) or []
|
|
54
|
+
handler_messages = handler_messages if isinstance(handler_messages, list) else [handler_messages]
|
|
55
|
+
if len(handler_messages) > 0:
|
|
56
|
+
messages.extend(handler_messages)
|
|
57
|
+
uuid_list = [f"{m!s}" for m in handler_messages]
|
|
58
|
+
logger.debug(f"New messages: {uuid_list!s}")
|
|
59
|
+
except Exception as e:
|
|
60
|
+
logger.debug(f"Exception handling command {message.module_path()}: {e!s}")
|
|
61
|
+
raise e from e
|
|
62
|
+
|
|
63
|
+
return messages
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from random import randint
|
|
2
|
+
|
|
3
|
+
from django.contrib.auth.models import User
|
|
4
|
+
|
|
5
|
+
from queuebie import message_registry
|
|
6
|
+
from queuebie.logger import get_logger
|
|
7
|
+
from queuebie.messages import Event
|
|
8
|
+
from testapp.messages.commands.my_commands import (
|
|
9
|
+
CreateUser,
|
|
10
|
+
CriticalCommand,
|
|
11
|
+
DoSomething,
|
|
12
|
+
PersistSomething,
|
|
13
|
+
RaiseRuntimeError,
|
|
14
|
+
)
|
|
15
|
+
from testapp.messages.events.my_events import SomethingHappened, SomethingHappenedThatWantsToBePersisted
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@message_registry.register_command(command=DoSomething)
|
|
19
|
+
def handle_my_command(*, context: DoSomething) -> list[Event] | Event:
|
|
20
|
+
logger = get_logger()
|
|
21
|
+
logger.info(f'Command "DoSomething" executed with my_var={context.my_var}.')
|
|
22
|
+
return SomethingHappened(other_var=context.my_var + 1)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@message_registry.register_command(command=PersistSomething)
|
|
26
|
+
def handle_something_that_needs_persistance(*, context: PersistSomething) -> Event:
|
|
27
|
+
User.objects.create(username="testuser" + str(randint(1, 100)))
|
|
28
|
+
|
|
29
|
+
return SomethingHappenedThatWantsToBePersisted(any_var=1)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@message_registry.register_command(command=CriticalCommand)
|
|
33
|
+
def handle_critical_command(*, context: CriticalCommand) -> None:
|
|
34
|
+
if context.my_var == 0:
|
|
35
|
+
raise RuntimeError("Handler is broken.") # noqa: TRY003
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@message_registry.register_command(command=CreateUser)
|
|
39
|
+
def create_user(context: CreateUser):
|
|
40
|
+
User.objects.create_user(username=context.username)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@message_registry.register_command(command=RaiseRuntimeError)
|
|
44
|
+
def raise_exception(context: RaiseRuntimeError):
|
|
45
|
+
raise RuntimeError(context.error_msg)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import random
|
|
2
|
+
|
|
3
|
+
from django.contrib.auth.models import User
|
|
4
|
+
|
|
5
|
+
from queuebie import message_registry
|
|
6
|
+
from queuebie.logger import get_logger
|
|
7
|
+
from queuebie.messages import Command
|
|
8
|
+
from testapp.messages.events.my_events import SomethingHappened, SomethingHappenedThatWantsToBePersistedViaEvent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@message_registry.register_event(event=SomethingHappened)
|
|
12
|
+
def handle_my_event(*, context: SomethingHappened) -> list[Command] | Command:
|
|
13
|
+
logger = get_logger()
|
|
14
|
+
logger.info(f'Event "SomethingHappened" executed with other_var={context.other_var}.')
|
|
15
|
+
return []
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@message_registry.register_event(event=SomethingHappenedThatWantsToBePersistedViaEvent)
|
|
19
|
+
def handle_event_and_try_to_persist_something(*, context: SomethingHappenedThatWantsToBePersistedViaEvent):
|
|
20
|
+
User.objects.create(username="testuser" + str(random.randint(1, 100)))
|
|
@@ -16,3 +16,18 @@ class CriticalCommand(Command):
|
|
|
16
16
|
@dataclass(kw_only=True)
|
|
17
17
|
class SameNameCommand(Command):
|
|
18
18
|
name: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(kw_only=True)
|
|
22
|
+
class PersistSomething(Command):
|
|
23
|
+
any_var: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(kw_only=True)
|
|
27
|
+
class CreateUser(Command):
|
|
28
|
+
username: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(kw_only=True)
|
|
32
|
+
class RaiseRuntimeError(Command):
|
|
33
|
+
error_msg: str
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
|
|
3
|
+
from queuebie.messages import Event
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclasses.dataclass(kw_only=True)
|
|
7
|
+
class SomethingHappened(Event):
|
|
8
|
+
other_var: int
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclasses.dataclass(kw_only=True)
|
|
12
|
+
class SomethingHappenedThatWantsToBePersisted(Event):
|
|
13
|
+
any_var: int
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclasses.dataclass(kw_only=True)
|
|
17
|
+
class SomethingHappenedThatWantsToBePersistedViaEvent(Event):
|
|
18
|
+
any_var: int
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from django.contrib.auth.models import User
|
|
3
|
+
|
|
4
|
+
from queuebie.database_blocker import BlockDatabaseAccess, DatabaseAccessDeniedError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_block_database_access_use_db():
|
|
8
|
+
with pytest.raises(DatabaseAccessDeniedError, match="Database access is disabled in this context."):
|
|
9
|
+
with BlockDatabaseAccess():
|
|
10
|
+
User.objects.get(pk=1)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_block_database_access_dont_use_db():
|
|
14
|
+
with BlockDatabaseAccess():
|
|
15
|
+
user = User(pk=1)
|
|
16
|
+
|
|
17
|
+
assert user.id == 1
|
|
@@ -10,7 +10,10 @@ from queuebie import MessageRegistry
|
|
|
10
10
|
from queuebie.exceptions import RegisterOutOfScopeCommandError
|
|
11
11
|
from queuebie.settings import get_queuebie_cache_key
|
|
12
12
|
from testapp.messages.commands.my_commands import CriticalCommand, DoSomething
|
|
13
|
-
from testapp.messages.events.my_events import
|
|
13
|
+
from testapp.messages.events.my_events import (
|
|
14
|
+
SomethingHappened,
|
|
15
|
+
SomethingHappenedThatWantsToBePersistedViaEvent,
|
|
16
|
+
)
|
|
14
17
|
from tests.helpers.commands import DoTestThings
|
|
15
18
|
|
|
16
19
|
|
|
@@ -121,7 +124,7 @@ def test_message_autodiscover_regular():
|
|
|
121
124
|
message_registry.autodiscover()
|
|
122
125
|
|
|
123
126
|
# Assert one command registered
|
|
124
|
-
assert len(message_registry.command_dict) ==
|
|
127
|
+
assert len(message_registry.command_dict) == 5 # noqa: PLR2004
|
|
125
128
|
assert DoSomething.module_path() in message_registry.command_dict.keys()
|
|
126
129
|
assert CriticalCommand.module_path() in message_registry.command_dict.keys()
|
|
127
130
|
|
|
@@ -132,11 +135,12 @@ def test_message_autodiscover_regular():
|
|
|
132
135
|
"name": "handle_my_command",
|
|
133
136
|
} == message_registry.command_dict[DoSomething.module_path()][0]
|
|
134
137
|
|
|
135
|
-
# Assert
|
|
136
|
-
assert len(message_registry.event_dict) ==
|
|
138
|
+
# Assert two events registered
|
|
139
|
+
assert len(message_registry.event_dict) == 2 # noqa: PLR2004
|
|
137
140
|
assert SomethingHappened.module_path() in message_registry.event_dict.keys()
|
|
141
|
+
assert SomethingHappenedThatWantsToBePersistedViaEvent.module_path() in message_registry.event_dict.keys()
|
|
138
142
|
|
|
139
|
-
# Assert
|
|
143
|
+
# Assert two handlers registered
|
|
140
144
|
assert len(message_registry.event_dict[SomethingHappened.module_path()]) == 1
|
|
141
145
|
assert {"module": "testapp.handlers.events.testapp", "name": "handle_my_event"} == message_registry.event_dict[
|
|
142
146
|
SomethingHappened.module_path()
|
|
@@ -3,12 +3,24 @@ from unittest import mock
|
|
|
3
3
|
|
|
4
4
|
import pytest
|
|
5
5
|
from django.contrib.auth.models import User
|
|
6
|
+
from django.test import override_settings
|
|
6
7
|
|
|
7
8
|
from queuebie import MessageRegistry
|
|
9
|
+
from queuebie.database_blocker import DatabaseAccessDeniedError
|
|
8
10
|
from queuebie.exceptions import InvalidMessageTypeError
|
|
9
|
-
from queuebie.runner import
|
|
10
|
-
from testapp.messages.commands.my_commands import
|
|
11
|
-
|
|
11
|
+
from queuebie.runner import handle_message
|
|
12
|
+
from testapp.messages.commands.my_commands import (
|
|
13
|
+
CreateUser,
|
|
14
|
+
CriticalCommand,
|
|
15
|
+
DoSomething,
|
|
16
|
+
PersistSomething,
|
|
17
|
+
RaiseRuntimeError,
|
|
18
|
+
SameNameCommand,
|
|
19
|
+
)
|
|
20
|
+
from testapp.messages.events.my_events import (
|
|
21
|
+
SomethingHappened,
|
|
22
|
+
SomethingHappenedThatWantsToBePersistedViaEvent,
|
|
23
|
+
)
|
|
12
24
|
|
|
13
25
|
|
|
14
26
|
@pytest.mark.django_db
|
|
@@ -65,6 +77,23 @@ def test_handle_message_pass_invalid_type():
|
|
|
65
77
|
handle_message(messages=MessageRegistry())
|
|
66
78
|
|
|
67
79
|
|
|
80
|
+
@pytest.mark.django_db
|
|
81
|
+
def test_handle_message_command_with_db_hit_is_ok():
|
|
82
|
+
handle_message(messages=[PersistSomething(any_var="noodle")])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.django_db
|
|
86
|
+
def test_handle_message_event_with_db_hit_fails():
|
|
87
|
+
with pytest.raises(DatabaseAccessDeniedError, match="Database access is disabled in this context"):
|
|
88
|
+
handle_message(messages=[SomethingHappenedThatWantsToBePersistedViaEvent(any_var=1)])
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@pytest.mark.django_db
|
|
92
|
+
@override_settings(QUEUEBIE_STRICT_MODE=False)
|
|
93
|
+
def test_handle_message_event_with_db_hit_ok_when_not_on_strict_mode(*args):
|
|
94
|
+
handle_message(messages=[SomethingHappenedThatWantsToBePersistedViaEvent(any_var=1)])
|
|
95
|
+
|
|
96
|
+
|
|
68
97
|
@pytest.mark.django_db
|
|
69
98
|
@mock.patch("queuebie.registry.get_queuebie_strict_mode", return_value=False)
|
|
70
99
|
@mock.patch.object(MessageRegistry, "autodiscover")
|
|
@@ -103,15 +132,8 @@ def test_handle_message_other_command_with_same_name(mocked_handle_command, *arg
|
|
|
103
132
|
|
|
104
133
|
@pytest.mark.django_db
|
|
105
134
|
@mock.patch("queuebie.registry.get_queuebie_strict_mode", return_value=False)
|
|
106
|
-
def
|
|
107
|
-
handler_list = [
|
|
108
|
-
{"module": "testapp.handlers.commands.testapp", "name": "create_user"},
|
|
109
|
-
{"module": "testapp.handlers.commands.testapp", "name": "raise_exception"},
|
|
110
|
-
]
|
|
111
|
-
|
|
112
|
-
message = DoSomething(my_var=1)
|
|
113
|
-
|
|
135
|
+
def test_handle_message_atomic_works(*args):
|
|
114
136
|
with pytest.raises(RuntimeError, match="Something is broken."):
|
|
115
|
-
|
|
137
|
+
handle_message([CreateUser(username="username"), RaiseRuntimeError(error_msg="Something is broken.")])
|
|
116
138
|
|
|
117
139
|
assert User.objects.filter(username="username").exists() is False
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
## Features
|
|
2
|
-
|
|
3
|
-
* Split up your business logic in commands and events
|
|
4
|
-
* Commands are imperatives telling your system what to do, events reflect that something has happened
|
|
5
|
-
* Register light-weight functions via a decorator to listen to your commands and events
|
|
6
|
-
* Message handlers receive the context of the message (command or event), providing an explicit API
|
|
7
|
-
* No magic, no side effects since the queue works synchronously
|
django_queuebie-0.1.0/CHANGES.md
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
# Motivation
|
|
2
|
-
|
|
3
|
-
You might wonder what's the USP for this package. Messages (event) queues are usually async and use a broker, etc.
|
|
4
|
-
|
|
5
|
-
The main goal is to provide a way to separate your domains in Django and reduce coupling between domains or Django apps.
|
|
6
|
-
|
|
7
|
-
So why should you use "django-queuebie"?
|
|
8
|
-
|
|
9
|
-
* Thinking in commands and events splits up your business logic from one big chunk to manageable sizes
|
|
10
|
-
* Handlers are function-based with a defined input (the context), they are predictable and easy to test
|
|
11
|
-
* Decoupling different parts of your application by listening to events instead of imperative programming
|
|
12
|
-
* Avoid putting business logic in your view layer since it has to live inside a handler function
|
|
13
|
-
* Creates an explicit pattern for connecting services (places of different business logic) instead of chaining them
|
|
14
|
-
individually
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import importlib
|
|
2
|
-
|
|
3
|
-
from django.db import transaction
|
|
4
|
-
|
|
5
|
-
from queuebie import message_registry
|
|
6
|
-
from queuebie.exceptions import InvalidMessageTypeError
|
|
7
|
-
from queuebie.logger import get_logger
|
|
8
|
-
from queuebie.messages import Command, Event, Message
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def handle_message(messages: Message | list[Message]) -> None:
|
|
12
|
-
queue: list[Message] = messages if isinstance(messages, list) else [messages]
|
|
13
|
-
|
|
14
|
-
for message in queue:
|
|
15
|
-
if not isinstance(message, (Command, Event)):
|
|
16
|
-
raise InvalidMessageTypeError(class_name=message.__class__.__name__)
|
|
17
|
-
|
|
18
|
-
# Run auto-registry
|
|
19
|
-
message_registry.autodiscover()
|
|
20
|
-
|
|
21
|
-
handler_list = []
|
|
22
|
-
while queue:
|
|
23
|
-
message = queue.pop(0)
|
|
24
|
-
if isinstance(message, Command):
|
|
25
|
-
handler_list = message_registry.command_dict.get(message.module_path(), [])
|
|
26
|
-
else:
|
|
27
|
-
handler_list = message_registry.event_dict.get(message.module_path(), [])
|
|
28
|
-
|
|
29
|
-
new_messages = _process_message(handler_list=handler_list, message=message)
|
|
30
|
-
queue.extend(new_messages)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def _process_message(*, handler_list: list, message: [Command, Event]):
|
|
34
|
-
"""
|
|
35
|
-
Handler to process messages of type "Command"
|
|
36
|
-
"""
|
|
37
|
-
logger = get_logger()
|
|
38
|
-
messages = []
|
|
39
|
-
|
|
40
|
-
with transaction.atomic():
|
|
41
|
-
for handler in handler_list:
|
|
42
|
-
try:
|
|
43
|
-
logger.debug(
|
|
44
|
-
f"Handling command '{message.module_path()}' ({message.uuid}) with handler '{handler['name']}'."
|
|
45
|
-
)
|
|
46
|
-
module = importlib.import_module(handler["module"])
|
|
47
|
-
handler_function = getattr(module, handler["name"])
|
|
48
|
-
handler_messages = handler_function(context=message) or []
|
|
49
|
-
handler_messages = handler_messages if isinstance(handler_messages, list) else [handler_messages]
|
|
50
|
-
if len(handler_messages) > 0:
|
|
51
|
-
messages.extend(handler_messages)
|
|
52
|
-
uuid_list = [f"{m!s}" for m in handler_messages]
|
|
53
|
-
logger.debug(f"New messages: {uuid_list!s}")
|
|
54
|
-
except Exception as e:
|
|
55
|
-
logger.debug(f"Exception handling command {message.module_path()}: {e!s}")
|
|
56
|
-
raise e from e
|
|
57
|
-
|
|
58
|
-
return messages
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
from django.contrib.auth.models import User
|
|
2
|
-
|
|
3
|
-
from queuebie import message_registry
|
|
4
|
-
from queuebie.logger import get_logger
|
|
5
|
-
from queuebie.messages import Event
|
|
6
|
-
from testapp.messages.commands.my_commands import CriticalCommand, DoSomething
|
|
7
|
-
from testapp.messages.events.my_events import SomethingHappened
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@message_registry.register_command(command=DoSomething)
|
|
11
|
-
def handle_my_command(*, context: DoSomething) -> list[Event] | Event:
|
|
12
|
-
logger = get_logger()
|
|
13
|
-
logger.info(f'Command "DoSomething" executed with my_var={context.my_var}.')
|
|
14
|
-
return SomethingHappened(other_var=context.my_var + 1)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
@message_registry.register_command(command=CriticalCommand)
|
|
18
|
-
def handle_critical_command(*, context: CriticalCommand) -> None:
|
|
19
|
-
if context.my_var == 0:
|
|
20
|
-
raise RuntimeError("Handler is broken.") # noqa: TRY003
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def create_user(*args, **kwargs):
|
|
24
|
-
return User.objects.create_user(username="username")
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def raise_exception(*args, **kwargs):
|
|
28
|
-
raise RuntimeError("Something is broken.") # noqa: TRY003
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
from queuebie import message_registry
|
|
2
|
-
from queuebie.logger import get_logger
|
|
3
|
-
from queuebie.messages import Command
|
|
4
|
-
from testapp.messages.events.my_events import SomethingHappened
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
@message_registry.register_event(event=SomethingHappened)
|
|
8
|
-
def handle_my_event(*, context: SomethingHappened) -> list[Command] | Command:
|
|
9
|
-
logger = get_logger()
|
|
10
|
-
logger.info(f'Event "SomethingHappened" executed with other_var={context.other_var}.')
|
|
11
|
-
return []
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|