django-queuebie 0.5.0__tar.gz → 0.7.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.
Files changed (109) hide show
  1. django_queuebie-0.7.0/CHANGES.md +92 -0
  2. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/PKG-INFO +1 -1
  3. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/docs/features/getting-started.md +25 -3
  4. django_queuebie-0.7.0/docs/features/settings.md +106 -0
  5. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/queuebie/__init__.py +1 -1
  6. django_queuebie-0.7.0/queuebie/exceptions.py +49 -0
  7. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/queuebie/registry.py +48 -20
  8. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/queuebie/runner.py +14 -2
  9. django_queuebie-0.7.0/queuebie/settings.py +72 -0
  10. django_queuebie-0.7.0/queuebie/utils.py +38 -0
  11. django_queuebie-0.7.0/testapp/handlers/commands/messages.py +9 -0
  12. django_queuebie-0.7.0/testapp/messages/commands/messages.py +13 -0
  13. django_queuebie-0.7.0/testapp/nested_domain/handlers/commands/nested_domain.py +13 -0
  14. django_queuebie-0.7.0/testapp/nested_domain/handlers/events/__init__.py +0 -0
  15. django_queuebie-0.7.0/testapp/nested_domain/handlers/events/nested_domain.py +9 -0
  16. django_queuebie-0.7.0/testapp/nested_domain/messages/__init__.py +0 -0
  17. django_queuebie-0.7.0/testapp/nested_domain/messages/commands/__init__.py +0 -0
  18. django_queuebie-0.7.0/testapp/nested_domain/messages/commands/nested_commands.py +8 -0
  19. django_queuebie-0.7.0/testapp/nested_domain/messages/events/__init__.py +0 -0
  20. django_queuebie-0.7.0/testapp/nested_domain/messages/events/nested_events.py +8 -0
  21. django_queuebie-0.7.0/testapp/tests/__init__.py +0 -0
  22. django_queuebie-0.7.0/testapp/tests/handlers/__init__.py +0 -0
  23. django_queuebie-0.7.0/testapp/tests/handlers/commands/__init__.py +0 -0
  24. django_queuebie-0.7.0/testapp/tests/handlers/commands/decoy.py +12 -0
  25. django_queuebie-0.7.0/testapp/tests/messages/__init__.py +0 -0
  26. django_queuebie-0.7.0/testapp/tests/messages/commands/__init__.py +0 -0
  27. django_queuebie-0.7.0/testapp/tests/messages/commands/decoy_commands.py +8 -0
  28. django_queuebie-0.7.0/tests/__init__.py +0 -0
  29. django_queuebie-0.7.0/tests/helpers/__init__.py +0 -0
  30. django_queuebie-0.7.0/tests/management_commands/__init__.py +0 -0
  31. django_queuebie-0.7.0/tests/test_exceptions.py +67 -0
  32. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/tests/test_registry.py +141 -11
  33. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/tests/test_runner.py +44 -0
  34. django_queuebie-0.7.0/tests/test_settings.py +91 -0
  35. django_queuebie-0.7.0/tests/test_utils.py +81 -0
  36. django_queuebie-0.5.0/CHANGES.md +0 -61
  37. django_queuebie-0.5.0/docs/features/settings.md +0 -41
  38. django_queuebie-0.5.0/queuebie/exceptions.py +0 -17
  39. django_queuebie-0.5.0/queuebie/settings.py +0 -32
  40. django_queuebie-0.5.0/queuebie/utils.py +0 -30
  41. django_queuebie-0.5.0/tests/test_exceptions.py +0 -23
  42. django_queuebie-0.5.0/tests/test_settings.py +0 -45
  43. django_queuebie-0.5.0/tests/test_utils.py +0 -38
  44. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/.ambient-package-update/metadata.py +0 -0
  45. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/.ambient-package-update/templates/snippets/content.tpl +0 -0
  46. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/.ambient-package-update/templates/snippets/tagline.tpl +0 -0
  47. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/.editorconfig +0 -0
  48. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  49. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  50. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  51. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  52. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/.github/workflows/ci.yml +0 -0
  53. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/.github/workflows/quality-gate.yml +0 -0
  54. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/.github/workflows/release.yml +0 -0
  55. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/.gitignore +0 -0
  56. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/.pre-commit-config.yaml +0 -0
  57. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/.readthedocs.yaml +0 -0
  58. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/CODE_OF_CONDUCT.md +0 -0
  59. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/CONTRIBUTING.md +0 -0
  60. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/LICENSE.md +0 -0
  61. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/MANIFEST.in +0 -0
  62. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/README.md +0 -0
  63. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/SECURITY.md +0 -0
  64. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/docs/Makefile +0 -0
  65. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/docs/conf.py +0 -0
  66. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/docs/features/changelog.rst +0 -0
  67. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/docs/features/introduction.md +0 -0
  68. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/docs/features/motivation.md +0 -0
  69. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/docs/features/setup.md +0 -0
  70. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/docs/index.rst +0 -0
  71. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/docs/make.bat +0 -0
  72. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/manage.py +0 -0
  73. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/pyproject.toml +0 -0
  74. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/queuebie/apps.py +0 -0
  75. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/queuebie/database_blocker.py +0 -0
  76. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/queuebie/logger.py +0 -0
  77. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/queuebie/management/__init__.py +0 -0
  78. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/queuebie/management/commands/__init__.py +0 -0
  79. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/queuebie/management/commands/clear_queuebie_registry.py +0 -0
  80. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/queuebie/messages.py +0 -0
  81. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/scripts/unix/install_requirements.sh +0 -0
  82. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/scripts/unix/publish_to_pypi.sh +0 -0
  83. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/scripts/windows/install_requirements.ps1 +0 -0
  84. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/scripts/windows/publish_to_pypi.ps1 +0 -0
  85. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/settings.py +0 -0
  86. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/testapp/__init__.py +0 -0
  87. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/testapp/handlers/__init__.py +0 -0
  88. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/testapp/handlers/commands/__init__.py +0 -0
  89. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/testapp/handlers/commands/testapp.py +0 -0
  90. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/testapp/handlers/events/__init__.py +0 -0
  91. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/testapp/handlers/events/testapp.py +0 -0
  92. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/testapp/messages/__init__.py +0 -0
  93. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/testapp/messages/commands/__init__.py +0 -0
  94. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/testapp/messages/commands/my_commands.py +0 -0
  95. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/testapp/messages/commands/other_commands.py +0 -0
  96. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/testapp/messages/events/__init__.py +0 -0
  97. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/testapp/messages/events/my_events.py +0 -0
  98. {django_queuebie-0.5.0/tests → django_queuebie-0.7.0/testapp/nested_domain}/__init__.py +0 -0
  99. {django_queuebie-0.5.0/tests/helpers → django_queuebie-0.7.0/testapp/nested_domain/handlers}/__init__.py +0 -0
  100. {django_queuebie-0.5.0/tests/management_commands → django_queuebie-0.7.0/testapp/nested_domain/handlers/commands}/__init__.py +0 -0
  101. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/testapp/urls.py +0 -0
  102. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/tests/helpers/commands.py +0 -0
  103. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/tests/helpers/events.py +0 -0
  104. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/tests/management_commands/test_clear_queuebie_registry.py +0 -0
  105. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/tests/test_apps.py +0 -0
  106. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/tests/test_database_blocker.py +0 -0
  107. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/tests/test_logger.py +0 -0
  108. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/tests/test_messages.py +0 -0
  109. {django_queuebie-0.5.0 → django_queuebie-0.7.0}/uv.lock +0 -0
@@ -0,0 +1,92 @@
1
+ # Changelog
2
+
3
+ **0.7.0** (2026-10-05)
4
+ * `handle_message()` returns the handled messages in the order they were processed - the initial message(s)
5
+ followed by every command and event raised along the way - so the caller can tell whether its command led to the
6
+ expected event
7
+ * `handle_message()` no longer empties a list passed in by the caller
8
+
9
+ **0.6.0** (2026-09-09)
10
+ * Auto-discovery now walks the whole subtree of every local Django app, so `handlers/commands` and `handlers/events`
11
+ directories may live in sub-packages instead of only at the app root
12
+ * Auto-discovery skips the directory names which occur inside Django apps but never hold handlers -
13
+ `__pycache__`, `fixtures`, `locale`, `media`, `migrations`, `node_modules`, `static`, `templates` and `tests`.
14
+ `QUEUEBIE_DEFAULT_EXCLUDED_DIRECTORIES` holds that list and can be overwritten
15
+ * Added the setting `QUEUEBIE_EXCLUDED_DIRECTORIES` for project-specific directory names. It is added to the list
16
+ above instead of replacing it
17
+ * **Breaking change:** Strict mode compares the package owning the `handlers/` or `messages/` directory instead of
18
+ the Django app. Nested layouts get the boundary enforced they always described, and two cases which used to pass
19
+ the check unconditionally are now validated:
20
+ * handlers and commands living outside any installed Django app
21
+ * commands living inside an app but outside a `messages/` directory - a command in, say,
22
+ `my_app/domain/orders/commands.py` has no owning `messages/` directory, so its scope is its full module path,
23
+ which only a handler in that same module can share. Registering a handler from anywhere else raises
24
+ `RegisterOutOfScopeCommandError` at import time. Move such commands into a `messages/` directory or turn
25
+ strict mode off
26
+ * **Breaking change:** Replaced `queuebie.utils.is_part_of_app()` with `queuebie.utils.is_same_scope()` and
27
+ `queuebie.utils.message_scope()`
28
+ * **Breaking change:** `RegisterOutOfScopeCommandError` now names both scopes instead of only the command and the
29
+ handler, so a mismatch between two sub-packages of the same app can be read off the message
30
+ * Handlers are now registered in a deterministic order - the handler package first, then its modules sorted by
31
+ name. Where a message has more than one handler, and since the bus is synchronous, that order is observable and
32
+ may differ from the one the file system happened to yield before
33
+
34
+ **0.5.0** (2026-08-27)
35
+ * Added support for Django 6.1
36
+ * **Breaking change:** Dropped support for Django 4.2, whose extended support ended in April 2026
37
+ * Updated the linting and CI setup to the current ambient-package-update template
38
+
39
+ **0.4.1** (2026-07-03)
40
+ * Updated company and maintainer information to "Beyonder Deutschland"
41
+
42
+ **0.4.0** (2026-07-03)
43
+ * **Breaking change:** Dropped support for Python 3.10 (nearing end-of-life in October 2026)
44
+ * Added support for Python 3.14
45
+ * Added native uv support to the rendered Read the Docs configuration
46
+ * Replaced the unmaintained "m2r2" documentation dependency with "sphinx-mdinclude"
47
+ * Added a Code of Conduct, issue templates and a pull request template to rendered packages
48
+ * Made the single-version CI and Read the Docs jobs track the newest supported Python version
49
+ * Bumped rendered single-version jobs to Python 3.14
50
+ * Added a cache suffix to the uv setup step to avoid CI cache namespace conflicts
51
+ * Excluded unsupported Python/Django combinations (Python 3.14 with Django 4.2 and 5.2) from the rendered CI matrix
52
+ * Fixed the rendered ruff target-version to track the minimum supported Python (matching requires-python) instead of the newest
53
+ * Removed the stale .md source suffix from the rendered Sphinx config, since sphinx-mdinclude provides only the mdinclude directive (not a Markdown source parser)
54
+
55
+ **0.3.10** (2026-03-30)
56
+ * Maintenance updates via ambient-package-update
57
+
58
+ **0.3.9** (2026-03-30)
59
+ * Maintenance updates via ambient-package-update
60
+
61
+ **0.3.8** (2025-12-11)
62
+ * Maintenance updates via ambient-package-update
63
+
64
+ **0.3.7** (2025-10-15)
65
+ * Maintenance updates via ambient-package-update
66
+
67
+ **0.3.6** (2025-10-10)
68
+ * Maintenance updates via ambient-package-update
69
+
70
+ **0.3.5** (2025-10-09)
71
+ * Maintenance updates via ambient-package-update
72
+
73
+ **0.3.4** (2025-05-29)
74
+ * Maintenance updates via ambient-package-update
75
+
76
+ **0.3.3** (2025-04-03)
77
+ * Clarified package tagline
78
+
79
+ **0.3.2** (2025-04-03)
80
+ * Maintenance updates via ambient-package-update
81
+
82
+ * *0.3.1* (2025-03-19)
83
+ * Added a paranoid-ish test to check that the import logic isn't breaking any testing functionality
84
+
85
+ * *0.3.0* (2025-03-19)
86
+ * The whole queue iteration now is wrapped in a transaction atomic
87
+
88
+ * *0.2.0* (2025-03-17)
89
+ * Extend strict mode to prohibit event (!) handlers to talk to the database
90
+
91
+ * *0.1.0* (2025-01-29)
92
+ * Project init
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: django-queuebie
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: A simple and synchronous message queue for commands and events for Django
5
5
  Project-URL: Homepage, https://github.com/ambient-innovation/django-queuebie/
6
6
  Project-URL: Documentation, https://django-queuebie.readthedocs.io/en/latest/index.html
@@ -52,15 +52,21 @@ class ProductBought(Event):
52
52
  Here's an example of a simple handler function. By design, these are functions and not objects to keep it as simple,
53
53
  understandable and testable as possible.
54
54
 
55
- They have to live inside your Django app in `my_app/handlers/commands/[your_filename].py` or
56
- respectively `handlers/events/[your_filename].py` for the auto-discovery to find it.
55
+ They have to live in a `handlers/commands/[your_filename].py` or respectively `handlers/events/[your_filename].py`
56
+ directory for the auto-discovery to find them. Such a directory may sit at any depth below the root of a Django app,
57
+ so `my_app/handlers/commands/product.py` and `my_app/shipping/handlers/commands/product.py` both work. Directories
58
+ listed in `QUEUEBIE_EXCLUDED_DIRECTORIES` are skipped.
57
59
 
58
60
  A handler becomes a handler when three conditions are met:
59
61
 
60
62
  * The function is registered as such via one of the two decorators `register_command` and `register_event`
61
- * The function lives within `my_app/handlers/commands/` or `my_app/handlers/events/`
63
+ * The function lives within a `handlers/commands/` or `handlers/events/` directory inside one of your Django apps
62
64
  * The function takes a message (command or event) and returns optionally the other type of message (event or command).
63
65
 
66
+ The package owning that `handlers/` directory is the handler's *scope*. In strict mode, a command handler may only
67
+ handle commands whose `messages/` directory has the same owner. See
68
+ [QUEUEBIE_STRICT_MODE](settings.md#queuebie_strict_mode) for the details.
69
+
64
70
  ```python
65
71
  # my_app/handlers/commands/product.py
66
72
  from queuebie import message_registry
@@ -96,3 +102,19 @@ handle_message(
96
102
  )
97
103
  )
98
104
  ```
105
+
106
+ `handle_message()` returns every message it handled, in the order they were processed: the message(s) you passed in,
107
+ followed by all commands and events the handlers returned along the way. Use it to find out what your command led to,
108
+ for example when a command handler can decide to do nothing and return `None`:
109
+
110
+ ```python
111
+ handled = handle_message(BuyProduct(...))
112
+
113
+ if any(isinstance(message, ProductBought) for message in handled):
114
+ messages.success(request, "Thank you for your purchase.")
115
+ else:
116
+ messages.error(request, "The product could not be bought.")
117
+ ```
118
+
119
+ The returned list tells you which handlers ran, not that their changes are committed. If you call `handle_message()`
120
+ inside an outer `transaction.atomic()` block, that outer transaction can still roll everything back.
@@ -0,0 +1,106 @@
1
+ # Settings
2
+
3
+ ## QUEUEBIE_APP_BASE_PATH
4
+
5
+ Queuebie needs to know where your project lives to detect local Django apps. It defaults to `settings.BASE_PATH`
6
+ but you can overwrite it with a string or a `Pathlib` object.
7
+
8
+ ```python
9
+ from pathlib import Path
10
+
11
+ QUEUEBIE_APP_BASE_PATH = Path(__file__).resolve(strict=True).parent
12
+ ```
13
+
14
+ ## QUEUEBIE_CACHE_KEY
15
+
16
+ Queuebie will cache all detected message handlers in Django's default cache. The default cache key is "queuebie".
17
+ You can overwrite it with this variable.
18
+
19
+ ```python
20
+ QUEUEBIE_CACHE_KEY = "my_very_special_cache_key"
21
+ ```
22
+
23
+ ## QUEUEBIE_LOGGER_NAME
24
+
25
+ Queuebie defines a Django logger with the default name "queuebie". If you want to rename that logger, you can set this
26
+ variable.
27
+
28
+ ```python
29
+ QUEUEBIE_LOGGER_NAME = "my_very_special_logger"
30
+ ```
31
+
32
+ Take care to use the same name in the logging configuration in your Django settings.
33
+
34
+ ## QUEUEBIE_EXCLUDED_DIRECTORIES
35
+
36
+ Queuebie searches the whole subtree of every local Django app for `handlers/commands` and `handlers/events`
37
+ directories. Directory names listed here are skipped, which keeps handler-shaped trees that are not meant to be
38
+ registered - most notably a test suite mirroring your handler layout - out of the auto-discovery.
39
+
40
+ Whatever you put here is **added to** the built-in list below, so you only name what is special about your project.
41
+
42
+ ```python
43
+ QUEUEBIE_EXCLUDED_DIRECTORIES = {"vendor"}
44
+ ```
45
+
46
+ A directory is skipped when any part of its path below the app root matches one of these names.
47
+
48
+ ## QUEUEBIE_DEFAULT_EXCLUDED_DIRECTORIES
49
+
50
+ The built-in list of directory names which occur inside Django apps but never hold message handlers:
51
+
52
+ ```python
53
+ QUEUEBIE_DEFAULT_EXCLUDED_DIRECTORIES = {
54
+ "__pycache__",
55
+ "fixtures",
56
+ "locale",
57
+ "media",
58
+ "migrations",
59
+ "node_modules",
60
+ "static",
61
+ "templates",
62
+ "tests",
63
+ }
64
+ ```
65
+
66
+ You can overwrite it, but you probably don't want to - use `QUEUEBIE_EXCLUDED_DIRECTORIES` to add names and leave
67
+ this one alone. Overwriting is the way to get one of these directories searched after all, for instance if your
68
+ handlers really do live under `tests/`. Note that an overwritten list is frozen: names a later queuebie release adds
69
+ to the built-in list won't reach your project.
70
+
71
+ ## QUEUEBIE_STRICT_MODE
72
+
73
+ Queuebie enforces by default that commands are not used outside their scope and event handlers don't talk to the
74
+ database. If you want to skip that restriction for whatever reason, you can do so.
75
+
76
+ ```python
77
+ QUEUEBIE_STRICT_MODE = False
78
+ ```
79
+
80
+ ### What a scope is
81
+
82
+ The scope of a message or a handler is the package which owns the `handlers/` or `messages/` directory the module
83
+ lives in - the last such directory in the module path, so a package legitimately called `messages` further up doesn't
84
+ truncate the scope.
85
+
86
+ | Module | Scope |
87
+ |----------------------------------------------------|--------------------------|
88
+ | `apps.shipping.messages.commands.shipment` | `apps.shipping` |
89
+ | `apps.shipping.handlers.commands.shipment` | `apps.shipping` |
90
+ | `apps.logistics.billing.handlers.commands.invoice` | `apps.logistics.billing` |
91
+
92
+ A module which lives in neither directory has no owning package, so its full module path becomes its scope. Only a
93
+ handler in that very module shares it; registering one from anywhere else raises `RegisterOutOfScopeCommandError` at
94
+ import time. Keep your commands in a `messages/` directory.
95
+
96
+ Put them in a module *inside* that directory - `messages/commands/orders.py` - and not in `messages/__init__.py`
97
+ itself. A class defined there reports `apps.shipping.messages` as its module, which is indistinguishable from a
98
+ module called `messages.py`, so the last path segment is never treated as the marker and the scope ends up as
99
+ `apps.shipping.messages` rather than `apps.shipping`. Every handler in `apps/shipping/handlers/commands/` then fails
100
+ the strict-mode check.
101
+
102
+ A command handler may only handle commands of its own scope. For the common layout - one `handlers/` directory at the
103
+ root of a Django app - the scope is that app, so nothing changes. If you organise a Django app into sub-packages, the
104
+ boundary follows those sub-packages instead.
105
+
106
+ Event handlers are deliberately not scope-checked: crossing scopes is what events are for.
@@ -1,6 +1,6 @@
1
1
  """A simple and synchronous message queue for commands and events for Django"""
2
2
 
3
- __version__ = "0.5.0"
3
+ __version__ = "0.7.0"
4
4
 
5
5
  from queuebie.registry import MessageRegistry
6
6
 
@@ -0,0 +1,49 @@
1
+ from django.core.exceptions import ImproperlyConfigured
2
+
3
+ # Unpickling replays an exception's rendered message through "args", so every exception below accepts it
4
+ # positionally and renders a new one only when constructed without. Frameworks which ship exceptions
5
+ # between processes - Celery, a multiprocessing test runner - rely on that.
6
+
7
+
8
+ class RegisterWrongMessageTypeError(TypeError):
9
+ def __init__(self, *args, message_name: str = "", decoratee_name: str = ""):
10
+ if not args:
11
+ args = (
12
+ f'Trying to register message function of wrong type: "{message_name}" on handler "{decoratee_name}".',
13
+ )
14
+
15
+ super().__init__(*args)
16
+
17
+
18
+ class RegisterOutOfScopeCommandError(TypeError):
19
+ def __init__(
20
+ self,
21
+ *args,
22
+ message_name: str = "",
23
+ message_scope: str = "",
24
+ decoratee_name: str = "",
25
+ decoratee_scope: str = "",
26
+ ):
27
+ if not args:
28
+ args = (
29
+ f'Command "{message_name}" (scope "{message_scope}") cannot be handled by '
30
+ f'"{decoratee_name}" (scope "{decoratee_scope}").',
31
+ )
32
+
33
+ super().__init__(*args)
34
+
35
+
36
+ class InvalidMessageTypeError(TypeError):
37
+ def __init__(self, *args, class_name: str = ""):
38
+ if not args:
39
+ args = (f'"{class_name}" is not an Event or Command',)
40
+
41
+ super().__init__(*args)
42
+
43
+
44
+ class InvalidExcludedDirectoriesError(ImproperlyConfigured):
45
+ def __init__(self, *args, setting_name: str = "QUEUEBIE_EXCLUDED_DIRECTORIES"):
46
+ if not args:
47
+ args = (f"{setting_name} has to be a collection of directory names, not a string.",)
48
+
49
+ super().__init__(*args)
@@ -11,8 +11,15 @@ from django.core.cache import cache
11
11
  from queuebie.exceptions import RegisterOutOfScopeCommandError, RegisterWrongMessageTypeError
12
12
  from queuebie.logger import get_logger
13
13
  from queuebie.messages import Command, Event
14
- from queuebie.settings import get_queuebie_app_base_path, get_queuebie_cache_key, get_queuebie_strict_mode
15
- from queuebie.utils import is_part_of_app, unique_append_to_inner_list
14
+ from queuebie.settings import (
15
+ get_queuebie_app_base_path,
16
+ get_queuebie_cache_key,
17
+ get_queuebie_excluded_directories,
18
+ get_queuebie_strict_mode,
19
+ )
20
+ from queuebie.utils import HANDLERS_DIRECTORY_NAME, is_same_scope, message_scope, unique_append_to_inner_list
21
+
22
+ MESSAGE_TYPE_DIRECTORY_NAMES = ("commands", "events")
16
23
 
17
24
 
18
25
  @dataclasses.dataclass(kw_only=True)
@@ -44,8 +51,13 @@ class MessageRegistry:
44
51
  if not (issubclass(command, Command)):
45
52
  raise RegisterWrongMessageTypeError(message_name=command.__name__, decoratee_name=decoratee.__name__)
46
53
 
47
- if get_queuebie_strict_mode() and not is_part_of_app(function=decoratee, class_type=command):
48
- raise RegisterOutOfScopeCommandError(message_name=command.__name__, decoratee_name=decoratee.__name__)
54
+ if get_queuebie_strict_mode() and not is_same_scope(function=decoratee, class_type=command):
55
+ raise RegisterOutOfScopeCommandError(
56
+ message_name=command.__name__,
57
+ message_scope=message_scope(module_path=command.__module__),
58
+ decoratee_name=decoratee.__name__,
59
+ decoratee_scope=message_scope(module_path=decoratee.__module__),
60
+ )
49
61
 
50
62
  # Add decoratee to dependency list
51
63
  function_definition = dataclasses.asdict(
@@ -86,7 +98,7 @@ class MessageRegistry:
86
98
 
87
99
  return decorator
88
100
 
89
- def autodiscover(self) -> None: # noqa: C901
101
+ def autodiscover(self) -> None:
90
102
  """
91
103
  Detects message registries which have been registered via the "register_*" decorator.
92
104
  """
@@ -101,6 +113,8 @@ class MessageRegistry:
101
113
  project_path = get_queuebie_app_base_path()
102
114
  logger = get_logger()
103
115
 
116
+ excluded_directories = get_queuebie_excluded_directories()
117
+
104
118
  for app_config in apps.get_app_configs():
105
119
  app_path = Path(app_config.path).resolve()
106
120
 
@@ -108,21 +122,23 @@ class MessageRegistry:
108
122
  if project_path not in app_path.parents:
109
123
  continue
110
124
 
111
- for message_type in ("commands", "events"):
112
- try:
113
- for module in os.listdir(app_path / "handlers" / message_type):
114
- if module[-3:] != ".py":
115
- continue
116
- module_name = module.replace(".py", "")
117
- module_path = f"{app_config.name}.handlers.{message_type}.{module_name}"
118
- sys_module = sys.modules.get(module_path)
119
- if sys_module:
120
- importlib.reload(sys_module)
121
- else:
122
- importlib.import_module(module_path)
123
- logger.debug(f'"{module_path}" imported.')
124
- except FileNotFoundError:
125
- pass
125
+ for directory, directory_names, file_names in os.walk(app_path):
126
+ # Excluded directories are pruned from the walk so their subtrees are never visited
127
+ directory_names[:] = sorted(name for name in directory_names if name not in excluded_directories)
128
+
129
+ current_path = Path(directory)
130
+ if (
131
+ current_path.name not in MESSAGE_TYPE_DIRECTORY_NAMES
132
+ or current_path.parent.name != HANDLERS_DIRECTORY_NAME
133
+ ):
134
+ continue
135
+
136
+ package_path = f"{app_config.name}.{'.'.join(current_path.relative_to(app_path).parts)}"
137
+
138
+ # Importing the package covers handlers registered in its "__init__.py"
139
+ self._import_handler_module(module_path=package_path)
140
+ for file_name in sorted(name for name in file_names if name.endswith(".py") and name != "__init__.py"):
141
+ self._import_handler_module(module_path=f"{package_path}.{Path(file_name).stem}")
126
142
 
127
143
  # Log to shell which functions have been detected
128
144
  logger.debug("Message autodiscovery running for commands...")
@@ -139,6 +155,18 @@ class MessageRegistry:
139
155
  # Update cache
140
156
  cache.set(get_queuebie_cache_key(), json.dumps({"commands": self.command_dict, "events": self.event_dict}))
141
157
 
158
+ def _import_handler_module(self, *, module_path: str) -> None:
159
+ """
160
+ Imports a module containing message handlers, reloading it if it was imported before.
161
+ """
162
+ sys_module = sys.modules.get(module_path)
163
+ if sys_module:
164
+ importlib.reload(sys_module)
165
+ else:
166
+ importlib.import_module(module_path)
167
+
168
+ get_logger().debug('"%s" imported.', module_path)
169
+
142
170
  def _load_handlers_from_cache(self) -> tuple[dict, dict]:
143
171
  """
144
172
  Get registered handler definitions from Django cache
@@ -11,8 +11,16 @@ from queuebie.messages import Command, Event, Message
11
11
  from queuebie.settings import get_queuebie_strict_mode
12
12
 
13
13
 
14
- def handle_message(messages: Message | list[Message]) -> None:
15
- queue: list[Message] = messages if isinstance(messages, list) else [messages]
14
+ def handle_message(messages: Message | list[Message]) -> list[Message]:
15
+ """
16
+ Process the given message(s) and every message their handlers return, until the queue is drained.
17
+
18
+ Returns all handled messages in the order they were processed - the initial message(s) first, followed by
19
+ everything raised along the way. The caller can inspect it to learn what its command led to, e.g. whether a
20
+ handler returned an event or bailed out with `None`.
21
+ """
22
+ # Copy the input, so draining the queue doesn't empty the caller's list
23
+ queue: list[Message] = list(messages) if isinstance(messages, list) else [messages]
16
24
 
17
25
  for message in queue:
18
26
  if not isinstance(message, (Command, Event)):
@@ -21,9 +29,11 @@ def handle_message(messages: Message | list[Message]) -> None:
21
29
  # Run auto-registry
22
30
  message_registry.autodiscover()
23
31
 
32
+ handled: list[Message] = []
24
33
  with transaction.atomic():
25
34
  while queue:
26
35
  message = queue.pop(0)
36
+ handled.append(message)
27
37
  if isinstance(message, Command):
28
38
  handler_list = message_registry.command_dict.get(message.module_path(), [])
29
39
  block_db_access = False
@@ -34,6 +44,8 @@ def handle_message(messages: Message | list[Message]) -> None:
34
44
  new_messages = _process_message(handler_list=handler_list, message=message, block_db_access=block_db_access)
35
45
  queue.extend(new_messages)
36
46
 
47
+ return handled
48
+
37
49
 
38
50
  def _process_message(*, handler_list: list, message: [Command, Event], block_db_access: bool) -> list[Message]:
39
51
  """
@@ -0,0 +1,72 @@
1
+ from pathlib import Path
2
+
3
+ from django.conf import settings
4
+
5
+ from queuebie.exceptions import InvalidExcludedDirectoriesError
6
+
7
+ DEFAULT_EXCLUDED_DIRECTORIES = {
8
+ "__pycache__",
9
+ "fixtures",
10
+ "locale",
11
+ "media",
12
+ "migrations",
13
+ "node_modules",
14
+ "static",
15
+ "templates",
16
+ "tests",
17
+ }
18
+
19
+
20
+ def get_queuebie_app_base_path() -> Path | str:
21
+ """
22
+ Base path of the application queuebie should look for registered handlers.
23
+ :return:
24
+ """
25
+ return getattr(settings, "QUEUEBIE_APP_BASE_PATH", getattr(settings, "BASE_PATH", None))
26
+
27
+
28
+ def get_queuebie_cache_key() -> str:
29
+ """
30
+ Cache key to store registered handlers in.
31
+ """
32
+ return getattr(settings, "QUEUEBIE_CACHE_KEY", "queuebie")
33
+
34
+
35
+ def get_queuebie_logger_name() -> str:
36
+ """
37
+ Django logger name
38
+ """
39
+ return getattr(settings, "QUEUEBIE_LOGGER_NAME", "queuebie")
40
+
41
+
42
+ def get_queuebie_strict_mode() -> bool:
43
+ """
44
+ Determines if commands are allowed to be handled outside the scope they are defined in.
45
+ """
46
+ return getattr(settings, "QUEUEBIE_STRICT_MODE", True)
47
+
48
+
49
+ def get_queuebie_default_excluded_directories() -> set[str]:
50
+ """
51
+ Directory names which occur inside Django apps but never hold message handlers.
52
+ """
53
+ return _directory_names(setting_name="QUEUEBIE_DEFAULT_EXCLUDED_DIRECTORIES", default=DEFAULT_EXCLUDED_DIRECTORIES)
54
+
55
+
56
+ def get_queuebie_excluded_directories() -> set[str]:
57
+ """
58
+ Directory names which are skipped when searching for handler modules.
59
+ """
60
+ return get_queuebie_default_excluded_directories() | _directory_names(
61
+ setting_name="QUEUEBIE_EXCLUDED_DIRECTORIES", default=set()
62
+ )
63
+
64
+
65
+ def _directory_names(*, setting_name: str, default: set[str]) -> set[str]:
66
+ directory_names = getattr(settings, setting_name, default)
67
+
68
+ # A string would decay into a set of single characters, silently excluding the wrong directories
69
+ if isinstance(directory_names, str):
70
+ raise InvalidExcludedDirectoriesError(setting_name=setting_name)
71
+
72
+ return set(directory_names)
@@ -0,0 +1,38 @@
1
+ from collections.abc import Callable
2
+
3
+ HANDLERS_DIRECTORY_NAME = "handlers"
4
+ MESSAGES_DIRECTORY_NAME = "messages"
5
+ SCOPE_MARKERS = (HANDLERS_DIRECTORY_NAME, MESSAGES_DIRECTORY_NAME)
6
+
7
+
8
+ def message_scope(*, module_path: str) -> str:
9
+ """
10
+ Determines the package owning the "handlers" or "messages" directory the given module lives in.
11
+ Falls back to the full module path for modules outside such a directory.
12
+ """
13
+ parts = module_path.split(".")
14
+ # The last segment is the module itself, so a module called "messages.py" is not a marker
15
+ marker_indices = [index for index, part in enumerate(parts[:-1]) if part in SCOPE_MARKERS]
16
+
17
+ return ".".join(parts[: marker_indices[-1]]) if marker_indices else module_path
18
+
19
+
20
+ def is_same_scope(*, function: Callable, class_type: type) -> bool:
21
+ """
22
+ Checks if a class and the given function belong to the same scope.
23
+ """
24
+ return message_scope(module_path=class_type.__module__) == message_scope(module_path=function.__module__)
25
+
26
+
27
+ def unique_append_to_inner_list(*, data: dict, key: str | int, value) -> dict:
28
+ """
29
+ Inserts "value" in the dictionary "data" on "key".
30
+ If "key" doesn't exist yet, it will create a new list containing "value".
31
+ If "value" at "key" already exists, it won't be appended.
32
+ """
33
+ if key not in data:
34
+ data[key] = [value]
35
+ elif value not in data[key]:
36
+ data[key].append(value)
37
+
38
+ return data
@@ -0,0 +1,9 @@
1
+ from queuebie import message_registry
2
+ from queuebie.logger import get_logger
3
+ from testapp.messages.commands.messages import SendMessage
4
+
5
+
6
+ @message_registry.register_command(command=SendMessage)
7
+ def handle_send_message(*, context: SendMessage) -> None:
8
+ logger = get_logger()
9
+ logger.info(f'Command "SendMessage" executed with text={context.text}.')
@@ -0,0 +1,13 @@
1
+ import dataclasses
2
+
3
+ from queuebie.messages import Command
4
+
5
+
6
+ @dataclasses.dataclass(kw_only=True)
7
+ class SendMessage(Command):
8
+ """
9
+ Lives in a module called "messages" on purpose: the module name must not be mistaken for the
10
+ directory determining the scope.
11
+ """
12
+
13
+ text: str
@@ -0,0 +1,13 @@
1
+ from queuebie import message_registry
2
+ from queuebie.logger import get_logger
3
+ from queuebie.messages import Event
4
+ from testapp.nested_domain.messages.commands.nested_commands import DoSomethingNested
5
+ from testapp.nested_domain.messages.events.nested_events import SomethingNestedHappened
6
+
7
+
8
+ @message_registry.register_command(command=DoSomethingNested)
9
+ def handle_nested_command(*, context: DoSomethingNested) -> Event:
10
+ logger = get_logger()
11
+ logger.info(f'Command "DoSomethingNested" executed with my_var={context.my_var}.')
12
+
13
+ return SomethingNestedHappened(other_var=context.my_var + 1)
@@ -0,0 +1,9 @@
1
+ from queuebie import message_registry
2
+ from queuebie.logger import get_logger
3
+ from testapp.nested_domain.messages.events.nested_events import SomethingNestedHappened
4
+
5
+
6
+ @message_registry.register_event(event=SomethingNestedHappened)
7
+ def handle_nested_event(*, context: SomethingNestedHappened) -> None:
8
+ logger = get_logger()
9
+ logger.info(f'Event "SomethingNestedHappened" executed with other_var={context.other_var}.')
@@ -0,0 +1,8 @@
1
+ import dataclasses
2
+
3
+ from queuebie.messages import Command
4
+
5
+
6
+ @dataclasses.dataclass(kw_only=True)
7
+ class DoSomethingNested(Command):
8
+ my_var: int
@@ -0,0 +1,8 @@
1
+ import dataclasses
2
+
3
+ from queuebie.messages import Event
4
+
5
+
6
+ @dataclasses.dataclass(kw_only=True)
7
+ class SomethingNestedHappened(Event):
8
+ other_var: int
File without changes