├── .gitignore ├── .pylintrc ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── mypy.ini ├── poetry.lock ├── pyproject.toml ├── setup.cfg ├── setup.py ├── src └── pymessagebus │ ├── __init__.py │ ├── _commandbus.py │ ├── _messagebus.py │ ├── api.py │ ├── default │ ├── __init__.py │ ├── commandbus.py │ └── messagebus.py │ └── middleware │ ├── __init__.py │ └── logger.py └── tests ├── __init__.py ├── __init___test.py ├── _commandbus_test.py ├── _messagebus_test.py ├── default ├── __init__.py ├── commandbus_test.py └── messagebus_test.py └── middleware └── logger_test.py /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | /.venv 3 | 4 | /.eggs 5 | /build 6 | /dist 7 | *.egg-info 8 | 9 | /.mypy_cache 10 | /.vscode 11 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | ignore=.git 3 | 4 | [pytest] 5 | # one of PyTest dependencies raises warnings :-/ 6 | filterwarnings = 7 | ignore::DeprecationWarning 8 | 9 | [MESSAGES CONTROL] 10 | # (we have do disable "bad-continuation" because it's not compatible with the formatting applied by Black) 11 | disable=missing-docstring,useless-import-alias,bad-continuation 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.6" 4 | - "3.7" 5 | - "3.8" 6 | - "3.9" 7 | env: 8 | global: 9 | - PYTHONPATH=src/ 10 | - MYPYPATH=src/ 11 | install: 12 | - pip install -U poetry 13 | - poetry install 14 | - pip install -U coverage python-coveralls pytest-cov 15 | script: 16 | - black src/ --check 17 | - mypy src/ 18 | - pylint pymessagebus 19 | - pytest --cov pymessagebus --cov-report term-missing 20 | after_success: 21 | - coveralls 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) Olivier Philippon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is furnished 10 | to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PYTHON_BINS ?= ./.venv/bin/ 2 | PYPIRC ?= ~/.pypirc 3 | 4 | .PHONY: install 5 | install: .venv 6 | ${PYTHON_BINS}poetry install 7 | 8 | .PHONY: black 9 | black: .venv 10 | @${PYTHON_BINS}black src/ 11 | 12 | .PHONY: pylint 13 | pylint: .venv 14 | @ PYTHONPATH=${PWD}/src/ ${PYTHON_BINS}pylint pymessagebus 15 | 16 | .PHONY: mypy 17 | mypy: .venv 18 | @ PYTHONPATH=${PWD}/src/ ${PYTHON_BINS}mypy src/ 19 | 20 | .PHONY: test 21 | test: ARGS ?= 22 | test: 23 | @ PYTHONPATH=${PWD}/src/ ${PYTHON_BINS}pytest ${ARGS} 24 | 25 | .PHONY: package-clean 26 | package-clean: 27 | rm -rf .cache/ .eggs/ build/ dist/ **/*.egg-info 28 | 29 | .PHONY: package-build 30 | package-build: package-clean 31 | ${PYTHON_BINS}python setup.py bdist_wheel 32 | 33 | # The "upload" tasks require a "~/.pypicrc" file 34 | # @link https://packaging.python.org/specifications/pypirc/ 35 | .PHONY: package-upload-test 36 | package-upload-test: package-build 37 | ${PYTHON_BINS}python -m twine upload --config-file ${PYPIRC} --repository testpypi dist/* 38 | 39 | .venv: 40 | python3 -m venv .venv 41 | ./.venv/bin/pip install poetry 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pymessagebus 2 | 3 |

a Message/Command Bus for Python

4 | 5 |

6 | Build Status 7 | Coverage Status 8 | Code style: black 9 |

10 | 11 | Pymessagebus is a message bus library. It comes with a generic MessageBus class, as well as a more specialised CommandBus one. 12 | 13 | _N.B.: here the "Message Bus" / "Command Bus" terms refer to a design patterns, and have nothing to do with messaging systems like RabbitMQ. (even though they can be used together)_ 14 | 15 | I created it because I've been using this design pattern for years while working on Symfony applications, and it never disappointed me - it's really a pretty simple and efficient way to decouple the business actions from their implementations. 16 | 17 | You can have a look at the following URLs to learn more about this design pattern: 18 | 19 | - https://matthiasnoback.nl/2015/01/a-wave-of-command-buses/ - a great series of articles explaining the design pattern - it uses PHP but that doesn't matter, the pattern is the same whatever the language is :-) 20 | - http://tactician.thephpleague.com/ - this is a pretty good and pragamatic PHP implementation of the CommandBus pattern, with clear explanations about the pattern 21 | - http://docs.simplebus.io/en/latest/ - another excellent PHP implementation, a bit more pure since sending Commands on the CommandBus can't return values here. _(my personal experience is that it's often handy to be able to return something from the execution of a Command, even if it's a bit less pure)_ 22 | - https://en.wikipedia.org/wiki/Command_pattern 23 | 24 | ## Install 25 | 26 | If you're using [Poetry](https://python-poetry.org/): 27 | 28 | ```bash 29 | $ poetry install pymessagebus 30 | ``` 31 | 32 | Or, if you prefer using raw pip: 33 | 34 | ```bash 35 | $ pip install "pymessagebus==1.*" 36 | ``` 37 | 38 | ## Synopsis 39 | 40 | A naive example of how the CommandBus allows one to keep the business actions (Commands) decoupled from the implementation of their effect (the Command Handlers): 41 | 42 | ```python 43 | # domain.py 44 | import typing as t 45 | 46 | class CreateCustomerCommand(t.NamedTuple): 47 | first_name: str 48 | last_name: str 49 | 50 | # command_handlers.py 51 | import domain 52 | 53 | def handle_customer_creation(command: domain.CreateCustomerCommand) -> int: 54 | customer = OrmCustomer() 55 | customer.full_name = f"{command.first_name} {command.last_name}" 56 | customer.creation_date = datetime.now() 57 | customer.save() 58 | return customer.id 59 | 60 | # command_bus.py 61 | command_bus = CommandBus() 62 | command_bus.add_handler(CreateCustomerCommand, handle_customer_creation) 63 | 64 | # api.py 65 | import domain 66 | from command_bus import command_bus 67 | 68 | @post("/customer) 69 | def post_customer(params): 70 | # Note that the implmentation (the "handle_customer_creation" function) 71 | # is completely invisible here, we only know about the (agnostic) CommandBus 72 | # and the class that describe the business action (the Command) 73 | command = CreateCustomerCommand(params["first_name"], params["last_name"]) 74 | customer_id = command_bus.handle(command) 75 | return customer_id 76 | ``` 77 | 78 | ## API 79 | 80 | #### MessageBus 81 | 82 | The `MessageBus` class allows one to trigger one or multiple handlers when a 83 | message of a given type is sent on the bus. 84 | The result is an array of results, where each item is the result of one the handlers execution. 85 | 86 | ```python 87 | class BusinessMessage(t.NamedTuple): 88 | payload: int 89 | 90 | def handler_one(message: BusinessMessage): 91 | return f"handler one result: {message.payload}" 92 | 93 | def handler_two(message: BusinessMessage): 94 | return f"handler two result: {message.payload}" 95 | 96 | message_bus = MessageBus() 97 | message_bus.add_handler(BusinessMessage, handler_one) 98 | message_bus.add_handler(BusinessMessage, handler_two) 99 | 100 | message = BusinessMessage(payload=33) 101 | result = message_bus.handle(message) 102 | # result = ["handler one result: 33", "handler one result: 34"] 103 | ``` 104 | 105 | The API is therefore pretty straightforward (you can see it as an abstract class in the [api](/pymessagebus/api.py) module): 106 | 107 | - `add_handler(message_class: type, message_handler: t.Callable) -> None` adds a handler, that will be triggered by the instance of the bus when a message of this class is sent to it. 108 | - `handle(message: object) -> t.List[t.Any]` trigger the handler(s) previously registered for that message class. If no handler has been registered for this kind of message, an empty list is returned. 109 | - `has_handler_for(message_class: type) -> bool` just allows one to check if one or more handlers have been registered for a given message class. 110 | - `remove_handler(message_class: type, message_handler: t.Callable) -> bool` removes a previously registered handler. Returns `True` if the handler was removed, `False` if such a handler was not previously registered. 111 | 112 | #### CommandBus 113 | 114 | The `CommandBus` is a specialised version of a `MessageBus` (technically it's just a proxy on top of a MessageBus, which adds the management of those specificities), which comes with the following subtleties: 115 | 116 | - Only one handler can be registered for a given message class 117 | - When a message is sent to the bus via the `handle` method, an error will be raised if no handler has been registered for this message class. 118 | 119 | **In short, a Command Bus assumes that it's mandatory to a handler triggered for every business action we send on it - an to have only one.** 120 | 121 | The API is thus exactly the same than the MessageBus, with the following technical differences: 122 | 123 | - the `add_handler(message_class, handler)` method will raise a `api.CommandHandlerAlreadyRegisteredForAType` exception if one tries to register a handler for a class of message for which another handler has already been registered before. 124 | - the `handle(message)` method returns a single result rather than a list of result (as we can - and must - have only one single handler for a given message class). If no handler has been registered for this message class, a `api.CommandHandlerNotFound` exception is raised. 125 | - the `remove_handler(message_class: type) -> bool` only takes a single argument. 126 | 127 | ##### Additional options for the CommandBus 128 | 129 | The CommandBus constructor have additional options that you can use to customise its behaviour: 130 | 131 | - `allow_result`: it's possible to be stricter about the implementation of the CommandBus pattern, by using the `allow_result=True` named parameter when the class is instanciated (the default value being `False`). 132 | In that case the result of the `handle(message)` will always be `None`. By doing this one can follow a more pure version of the design pattern. (and access the result of the Command handling via the application repositories, though a pre-generated id attached to the message for example) 133 | - `locking`: by default the CommandBus will raise a `api.CommandBusAlreadyProcessingAMessage` exception if a message is sent to it while another message is still processed (which can happen if one of the Command Handlers sends a message to the bus). 134 | You can disable this behaviour by setting the named argument `locking=False` (the default value being `True`). 135 | 136 | #### Middlewares 137 | 138 | Last but not least, both kinds of buses can accept Middlewares. 139 | 140 | A Middleware is a function that receives a message (sent to the bus) as its first argument and a "next_middleware" function as second argument. That function can do some custom processing before or/and after the next Middleware (or the execution of the handler(s) registered for that kind of message) is triggered. 141 | 142 | Middlewares are triggered in a "onion shape": in the case of 2 Middlweares for example: 143 | 144 | - the first registered Middleware "pre-processing" will be executed first 145 | - the second one will come after 146 | - then the handler(s) registed for that message class is executed (it's the core of the onion) 147 | 148 | And then we get out of the onion in the opposite direction: 149 | 150 | - the second Middleware "post-processing" takes place 151 | - the first Middleware "post-processing" is triggered 152 | - the result if finally returned 153 | 154 | Middlewares can change the message sent to the next Middlewares (or to the message handler(s)), but they can also perform some processing that doesn't affect the message (like logging for instance). 155 | 156 | Here is a snippet illustrating this: 157 | 158 | ```python 159 | class MessageWithList(t.NamedTuple): 160 | payload: t.List[str] 161 | 162 | def middleware_one(message: MessageWithList, next: api.CallNextMiddleware): 163 | message.payload.append("middleware one: does something before the handler") 164 | result = next(message) 165 | message.payload.append("middleware one: does something after the handler") 166 | return result 167 | 168 | def middleware_two(message: MessageWithList, next: api.CallNextMiddleware): 169 | message.payload.append("middleware two: does something before the handler") 170 | result = next(message) 171 | message.payload.append("middleware two: does something after the handler") 172 | return result 173 | 174 | def handler(message: MessageWithList) -> str: 175 | message.payload.append("handler does something") 176 | return "handler result" 177 | 178 | message_bus = MessageBus(middlewares=[middleware_one, middleware_two]) 179 | message_bus.add_handler(MessageWithList, handler) 180 | 181 | message = MessageWithList(payload=["initial message payload"]) 182 | result = sut.handle(message) 183 | assert message.payload == [ 184 | "initial message payload", 185 | "middleware one: does something before the handler", 186 | "middleware two: does something before the handler", 187 | "handler does something", 188 | "middleware two: does something after the handler", 189 | "middleware one: does something after the handler", 190 | ] 191 | assert result == "handler result" 192 | ``` 193 | 194 | #### Logging middleware 195 | 196 | For convenience a "logging" middleware comes with the package. 197 | 198 | Synopis 199 | 200 | ```python 201 | import logging 202 | from pymessagebus.middleware.logger import get_logger_middleware 203 | 204 | logger = logging.getLogger("message_bus") 205 | logging_middleware = get_logger_middleware(logger) 206 | 207 | message_bus = MessageBus(middlewares=[logging_middleware]) 208 | 209 | # Now you will get logging messages: 210 | # - when a message is sent on the bus (default logging level: DEBUG) 211 | # - when a message has been successfully handled by the bus, with no Exception raised (default logging level: DEBUG) 212 | # - when the processing of a message has raised an Exception (default logging level: ERROR) 213 | ``` 214 | 215 | You can customise the logging levels of the middleware via the `LoggingMiddlewareConfig` class: 216 | 217 | ```python 218 | import logging 219 | from pymessagebus.middleware.logger import get_logger_middleware, LoggingMiddlewareConfig 220 | 221 | logger = logging.getLogger("message_bus") 222 | logging_middleware_config = LoggingMiddlewareConfig( 223 | mgs_received_level=logging.INFO, 224 | mgs_succeeded_level=logging.INFO, 225 | mgs_failed_level=logging.CRITICAL 226 | ) 227 | logging_middleware = get_logger_middleware(logger, logging_middleware_config) 228 | ``` 229 | 230 | ### "default" singletons 231 | 232 | Because most of the use cases of those buses rely on a single instance of the bus, for commodity you can also use singletons for both the MessageBus and CommandBus, accessible from a "default" subpackage. 233 | 234 | These versions also expose a very handy `register_handler(message_class: type)` decorator. 235 | 236 | Synopsis: 237 | 238 | ```python 239 | # domain.py 240 | import typing as t 241 | 242 | class CreateCustomerCommand(t.NamedTuple): 243 | first_name: str 244 | last_name: str 245 | 246 | # command_handlers.py 247 | from pymessagebus.default import commandbus 248 | import domain 249 | 250 | @commandbus.register_handler(domain.CreateCustomerCommand) 251 | def handle_customer_creation(command) -> int: 252 | customer = OrmCustomer() 253 | customer.full_name = f"{command.first_name} {command.last_name}" 254 | customer.creation_date = datetime.now() 255 | customer.save() 256 | return customer.id 257 | 258 | # api.py 259 | from pymessagebus.default import commandbus 260 | import domain 261 | 262 | @post("/customer) 263 | def post_customer(params): 264 | # Note that the implmentation (the "handle_customer_creation" function) 265 | # is completely invisible here, we only know about the (agnostic) CommandBus 266 | # and the class that describe the business action (the Command) 267 | command = CreateCustomerCommand(params["first_name"], params["last_name"]) 268 | customer_id = command_bus.handle(command) 269 | return customer_id 270 | ``` 271 | 272 | You can notice that the difference with the first synopsis is that here we don't have to instantiate the CommandBus, and that the `handle_customer_creation` function is registered to it automatically by using the decorator. 273 | 274 | ## Code quality 275 | 276 | The code itself is formatted with Black and checked with PyLint and MyPy. 277 | 278 | The whole package comes with a full test suite, managed by PyTest. 279 | 280 | ```bash 281 | $ make test 282 | ``` 283 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.6 3 | 4 | [mypy-setup] 5 | ignore_errors = True 6 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "appdirs" 3 | version = "1.4.4" 4 | description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 5 | category = "dev" 6 | optional = false 7 | python-versions = "*" 8 | 9 | [[package]] 10 | name = "astroid" 11 | version = "2.5.1" 12 | description = "An abstract syntax tree for Python with inference support." 13 | category = "dev" 14 | optional = false 15 | python-versions = ">=3.6" 16 | 17 | [package.dependencies] 18 | lazy-object-proxy = ">=1.4.0" 19 | typed-ast = {version = ">=1.4.0,<1.5", markers = "implementation_name == \"cpython\" and python_version < \"3.8\""} 20 | wrapt = ">=1.11,<1.13" 21 | 22 | [[package]] 23 | name = "atomicwrites" 24 | version = "1.4.0" 25 | description = "Atomic file writes." 26 | category = "dev" 27 | optional = false 28 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 29 | 30 | [[package]] 31 | name = "attrs" 32 | version = "20.3.0" 33 | description = "Classes Without Boilerplate" 34 | category = "dev" 35 | optional = false 36 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 37 | 38 | [package.extras] 39 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"] 40 | docs = ["furo", "sphinx", "zope.interface"] 41 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] 42 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] 43 | 44 | [[package]] 45 | name = "black" 46 | version = "20.8b1" 47 | description = "The uncompromising code formatter." 48 | category = "dev" 49 | optional = false 50 | python-versions = ">=3.6" 51 | 52 | [package.dependencies] 53 | appdirs = "*" 54 | click = ">=7.1.2" 55 | dataclasses = {version = ">=0.6", markers = "python_version < \"3.7\""} 56 | mypy-extensions = ">=0.4.3" 57 | pathspec = ">=0.6,<1" 58 | regex = ">=2020.1.8" 59 | toml = ">=0.10.1" 60 | typed-ast = ">=1.4.0" 61 | typing-extensions = ">=3.7.4" 62 | 63 | [package.extras] 64 | colorama = ["colorama (>=0.4.3)"] 65 | d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] 66 | 67 | [[package]] 68 | name = "click" 69 | version = "7.1.2" 70 | description = "Composable command line interface toolkit" 71 | category = "dev" 72 | optional = false 73 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 74 | 75 | [[package]] 76 | name = "colorama" 77 | version = "0.4.4" 78 | description = "Cross-platform colored terminal text." 79 | category = "dev" 80 | optional = false 81 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 82 | 83 | [[package]] 84 | name = "dataclasses" 85 | version = "0.8" 86 | description = "A backport of the dataclasses module for Python 3.6" 87 | category = "dev" 88 | optional = false 89 | python-versions = ">=3.6, <3.7" 90 | 91 | [[package]] 92 | name = "importlib-metadata" 93 | version = "3.7.3" 94 | description = "Read metadata from Python packages" 95 | category = "dev" 96 | optional = false 97 | python-versions = ">=3.6" 98 | 99 | [package.dependencies] 100 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 101 | zipp = ">=0.5" 102 | 103 | [package.extras] 104 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 105 | testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "pytest-enabler", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] 106 | 107 | [[package]] 108 | name = "iniconfig" 109 | version = "1.1.1" 110 | description = "iniconfig: brain-dead simple config-ini parsing" 111 | category = "dev" 112 | optional = false 113 | python-versions = "*" 114 | 115 | [[package]] 116 | name = "isort" 117 | version = "5.8.0" 118 | description = "A Python utility / library to sort Python imports." 119 | category = "dev" 120 | optional = false 121 | python-versions = ">=3.6,<4.0" 122 | 123 | [package.extras] 124 | pipfile_deprecated_finder = ["pipreqs", "requirementslib"] 125 | requirements_deprecated_finder = ["pipreqs", "pip-api"] 126 | colors = ["colorama (>=0.4.3,<0.5.0)"] 127 | 128 | [[package]] 129 | name = "lazy-object-proxy" 130 | version = "1.6.0" 131 | description = "A fast and thorough lazy object proxy." 132 | category = "dev" 133 | optional = false 134 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" 135 | 136 | [[package]] 137 | name = "mccabe" 138 | version = "0.6.1" 139 | description = "McCabe checker, plugin for flake8" 140 | category = "dev" 141 | optional = false 142 | python-versions = "*" 143 | 144 | [[package]] 145 | name = "mypy" 146 | version = "0.812" 147 | description = "Optional static typing for Python" 148 | category = "dev" 149 | optional = false 150 | python-versions = ">=3.5" 151 | 152 | [package.dependencies] 153 | mypy-extensions = ">=0.4.3,<0.5.0" 154 | typed-ast = ">=1.4.0,<1.5.0" 155 | typing-extensions = ">=3.7.4" 156 | 157 | [package.extras] 158 | dmypy = ["psutil (>=4.0)"] 159 | 160 | [[package]] 161 | name = "mypy-extensions" 162 | version = "0.4.3" 163 | description = "Experimental type system extensions for programs checked with the mypy typechecker." 164 | category = "dev" 165 | optional = false 166 | python-versions = "*" 167 | 168 | [[package]] 169 | name = "packaging" 170 | version = "20.9" 171 | description = "Core utilities for Python packages" 172 | category = "dev" 173 | optional = false 174 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 175 | 176 | [package.dependencies] 177 | pyparsing = ">=2.0.2" 178 | 179 | [[package]] 180 | name = "pathspec" 181 | version = "0.8.1" 182 | description = "Utility library for gitignore style pattern matching of file paths." 183 | category = "dev" 184 | optional = false 185 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 186 | 187 | [[package]] 188 | name = "pluggy" 189 | version = "0.13.1" 190 | description = "plugin and hook calling mechanisms for python" 191 | category = "dev" 192 | optional = false 193 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 194 | 195 | [package.dependencies] 196 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 197 | 198 | [package.extras] 199 | dev = ["pre-commit", "tox"] 200 | 201 | [[package]] 202 | name = "py" 203 | version = "1.10.0" 204 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 205 | category = "dev" 206 | optional = false 207 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 208 | 209 | [[package]] 210 | name = "pylint" 211 | version = "2.7.2" 212 | description = "python code static checker" 213 | category = "dev" 214 | optional = false 215 | python-versions = "~=3.6" 216 | 217 | [package.dependencies] 218 | astroid = ">=2.5.1,<2.6" 219 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 220 | isort = ">=4.2.5,<6" 221 | mccabe = ">=0.6,<0.7" 222 | toml = ">=0.7.1" 223 | 224 | [package.extras] 225 | docs = ["sphinx (==3.5.1)", "python-docs-theme (==2020.12)"] 226 | 227 | [[package]] 228 | name = "pyparsing" 229 | version = "2.4.7" 230 | description = "Python parsing module" 231 | category = "dev" 232 | optional = false 233 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 234 | 235 | [[package]] 236 | name = "pytest" 237 | version = "6.2.2" 238 | description = "pytest: simple powerful testing with Python" 239 | category = "dev" 240 | optional = false 241 | python-versions = ">=3.6" 242 | 243 | [package.dependencies] 244 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 245 | attrs = ">=19.2.0" 246 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 247 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 248 | iniconfig = "*" 249 | packaging = "*" 250 | pluggy = ">=0.12,<1.0.0a1" 251 | py = ">=1.8.2" 252 | toml = "*" 253 | 254 | [package.extras] 255 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 256 | 257 | [[package]] 258 | name = "regex" 259 | version = "2021.3.17" 260 | description = "Alternative regular expression module, to replace re." 261 | category = "dev" 262 | optional = false 263 | python-versions = "*" 264 | 265 | [[package]] 266 | name = "toml" 267 | version = "0.10.2" 268 | description = "Python Library for Tom's Obvious, Minimal Language" 269 | category = "dev" 270 | optional = false 271 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 272 | 273 | [[package]] 274 | name = "typed-ast" 275 | version = "1.4.2" 276 | description = "a fork of Python 2 and 3 ast modules with type comment support" 277 | category = "dev" 278 | optional = false 279 | python-versions = "*" 280 | 281 | [[package]] 282 | name = "typing-extensions" 283 | version = "3.7.4.3" 284 | description = "Backported and Experimental Type Hints for Python 3.5+" 285 | category = "dev" 286 | optional = false 287 | python-versions = "*" 288 | 289 | [[package]] 290 | name = "wrapt" 291 | version = "1.12.1" 292 | description = "Module for decorators, wrappers and monkey patching." 293 | category = "dev" 294 | optional = false 295 | python-versions = "*" 296 | 297 | [[package]] 298 | name = "zipp" 299 | version = "3.4.1" 300 | description = "Backport of pathlib-compatible object wrapper for zip files" 301 | category = "dev" 302 | optional = false 303 | python-versions = ">=3.6" 304 | 305 | [package.extras] 306 | docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 307 | testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "pytest-enabler", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] 308 | 309 | [metadata] 310 | lock-version = "1.1" 311 | python-versions = "^3.6" 312 | content-hash = "bb6a3bc86415173c02ce6a272f9aa8564613e79e084f1cd65f8dfc580d9b2cc0" 313 | 314 | [metadata.files] 315 | appdirs = [ 316 | {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, 317 | {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, 318 | ] 319 | astroid = [ 320 | {file = "astroid-2.5.1-py3-none-any.whl", hash = "sha256:21d735aab248253531bb0f1e1e6d068f0ee23533e18ae8a6171ff892b98297cf"}, 321 | {file = "astroid-2.5.1.tar.gz", hash = "sha256:cfc35498ee64017be059ceffab0a25bedf7548ab76f2bea691c5565896e7128d"}, 322 | ] 323 | atomicwrites = [ 324 | {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, 325 | {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, 326 | ] 327 | attrs = [ 328 | {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"}, 329 | {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"}, 330 | ] 331 | black = [ 332 | {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, 333 | ] 334 | click = [ 335 | {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, 336 | {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, 337 | ] 338 | colorama = [ 339 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 340 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 341 | ] 342 | dataclasses = [ 343 | {file = "dataclasses-0.8-py3-none-any.whl", hash = "sha256:0201d89fa866f68c8ebd9d08ee6ff50c0b255f8ec63a71c16fda7af82bb887bf"}, 344 | {file = "dataclasses-0.8.tar.gz", hash = "sha256:8479067f342acf957dc82ec415d355ab5edb7e7646b90dc6e2fd1d96ad084c97"}, 345 | ] 346 | importlib-metadata = [ 347 | {file = "importlib_metadata-3.7.3-py3-none-any.whl", hash = "sha256:b74159469b464a99cb8cc3e21973e4d96e05d3024d337313fedb618a6e86e6f4"}, 348 | {file = "importlib_metadata-3.7.3.tar.gz", hash = "sha256:742add720a20d0467df2f444ae41704000f50e1234f46174b51f9c6031a1bd71"}, 349 | ] 350 | iniconfig = [ 351 | {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, 352 | {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, 353 | ] 354 | isort = [ 355 | {file = "isort-5.8.0-py3-none-any.whl", hash = "sha256:2bb1680aad211e3c9944dbce1d4ba09a989f04e238296c87fe2139faa26d655d"}, 356 | {file = "isort-5.8.0.tar.gz", hash = "sha256:0a943902919f65c5684ac4e0154b1ad4fac6dcaa5d9f3426b732f1c8b5419be6"}, 357 | ] 358 | lazy-object-proxy = [ 359 | {file = "lazy-object-proxy-1.6.0.tar.gz", hash = "sha256:489000d368377571c6f982fba6497f2aa13c6d1facc40660963da62f5c379726"}, 360 | {file = "lazy_object_proxy-1.6.0-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:c6938967f8528b3668622a9ed3b31d145fab161a32f5891ea7b84f6b790be05b"}, 361 | {file = "lazy_object_proxy-1.6.0-cp27-cp27m-win32.whl", hash = "sha256:ebfd274dcd5133e0afae738e6d9da4323c3eb021b3e13052d8cbd0e457b1256e"}, 362 | {file = "lazy_object_proxy-1.6.0-cp27-cp27m-win_amd64.whl", hash = "sha256:ed361bb83436f117f9917d282a456f9e5009ea12fd6de8742d1a4752c3017e93"}, 363 | {file = "lazy_object_proxy-1.6.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d900d949b707778696fdf01036f58c9876a0d8bfe116e8d220cfd4b15f14e741"}, 364 | {file = "lazy_object_proxy-1.6.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:5743a5ab42ae40caa8421b320ebf3a998f89c85cdc8376d6b2e00bd12bd1b587"}, 365 | {file = "lazy_object_proxy-1.6.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:bf34e368e8dd976423396555078def5cfc3039ebc6fc06d1ae2c5a65eebbcde4"}, 366 | {file = "lazy_object_proxy-1.6.0-cp36-cp36m-win32.whl", hash = "sha256:b579f8acbf2bdd9ea200b1d5dea36abd93cabf56cf626ab9c744a432e15c815f"}, 367 | {file = "lazy_object_proxy-1.6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:4f60460e9f1eb632584c9685bccea152f4ac2130e299784dbaf9fae9f49891b3"}, 368 | {file = "lazy_object_proxy-1.6.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d7124f52f3bd259f510651450e18e0fd081ed82f3c08541dffc7b94b883aa981"}, 369 | {file = "lazy_object_proxy-1.6.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:22ddd618cefe54305df49e4c069fa65715be4ad0e78e8d252a33debf00f6ede2"}, 370 | {file = "lazy_object_proxy-1.6.0-cp37-cp37m-win32.whl", hash = "sha256:9d397bf41caad3f489e10774667310d73cb9c4258e9aed94b9ec734b34b495fd"}, 371 | {file = "lazy_object_proxy-1.6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:24a5045889cc2729033b3e604d496c2b6f588c754f7a62027ad4437a7ecc4837"}, 372 | {file = "lazy_object_proxy-1.6.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:17e0967ba374fc24141738c69736da90e94419338fd4c7c7bef01ee26b339653"}, 373 | {file = "lazy_object_proxy-1.6.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:410283732af311b51b837894fa2f24f2c0039aa7f220135192b38fcc42bd43d3"}, 374 | {file = "lazy_object_proxy-1.6.0-cp38-cp38-win32.whl", hash = "sha256:85fb7608121fd5621cc4377a8961d0b32ccf84a7285b4f1d21988b2eae2868e8"}, 375 | {file = "lazy_object_proxy-1.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:d1c2676e3d840852a2de7c7d5d76407c772927addff8d742b9808fe0afccebdf"}, 376 | {file = "lazy_object_proxy-1.6.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:b865b01a2e7f96db0c5d12cfea590f98d8c5ba64ad222300d93ce6ff9138bcad"}, 377 | {file = "lazy_object_proxy-1.6.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:4732c765372bd78a2d6b2150a6e99d00a78ec963375f236979c0626b97ed8e43"}, 378 | {file = "lazy_object_proxy-1.6.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:9698110e36e2df951c7c36b6729e96429c9c32b3331989ef19976592c5f3c77a"}, 379 | {file = "lazy_object_proxy-1.6.0-cp39-cp39-win32.whl", hash = "sha256:1fee665d2638491f4d6e55bd483e15ef21f6c8c2095f235fef72601021e64f61"}, 380 | {file = "lazy_object_proxy-1.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:f5144c75445ae3ca2057faac03fda5a902eff196702b0a24daf1d6ce0650514b"}, 381 | ] 382 | mccabe = [ 383 | {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, 384 | {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, 385 | ] 386 | mypy = [ 387 | {file = "mypy-0.812-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:a26f8ec704e5a7423c8824d425086705e381b4f1dfdef6e3a1edab7ba174ec49"}, 388 | {file = "mypy-0.812-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:28fb5479c494b1bab244620685e2eb3c3f988d71fd5d64cc753195e8ed53df7c"}, 389 | {file = "mypy-0.812-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:9743c91088d396c1a5a3c9978354b61b0382b4e3c440ce83cf77994a43e8c521"}, 390 | {file = "mypy-0.812-cp35-cp35m-win_amd64.whl", hash = "sha256:d7da2e1d5f558c37d6e8c1246f1aec1e7349e4913d8fb3cb289a35de573fe2eb"}, 391 | {file = "mypy-0.812-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:4eec37370483331d13514c3f55f446fc5248d6373e7029a29ecb7b7494851e7a"}, 392 | {file = "mypy-0.812-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d65cc1df038ef55a99e617431f0553cd77763869eebdf9042403e16089fe746c"}, 393 | {file = "mypy-0.812-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:61a3d5b97955422964be6b3baf05ff2ce7f26f52c85dd88db11d5e03e146a3a6"}, 394 | {file = "mypy-0.812-cp36-cp36m-win_amd64.whl", hash = "sha256:25adde9b862f8f9aac9d2d11971f226bd4c8fbaa89fb76bdadb267ef22d10064"}, 395 | {file = "mypy-0.812-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:552a815579aa1e995f39fd05dde6cd378e191b063f031f2acfe73ce9fb7f9e56"}, 396 | {file = "mypy-0.812-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:499c798053cdebcaa916eef8cd733e5584b5909f789de856b482cd7d069bdad8"}, 397 | {file = "mypy-0.812-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:5873888fff1c7cf5b71efbe80e0e73153fe9212fafdf8e44adfe4c20ec9f82d7"}, 398 | {file = "mypy-0.812-cp37-cp37m-win_amd64.whl", hash = "sha256:9f94aac67a2045ec719ffe6111df543bac7874cee01f41928f6969756e030564"}, 399 | {file = "mypy-0.812-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d23e0ea196702d918b60c8288561e722bf437d82cb7ef2edcd98cfa38905d506"}, 400 | {file = "mypy-0.812-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:674e822aa665b9fd75130c6c5f5ed9564a38c6cea6a6432ce47eafb68ee578c5"}, 401 | {file = "mypy-0.812-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:abf7e0c3cf117c44d9285cc6128856106183938c68fd4944763003decdcfeb66"}, 402 | {file = "mypy-0.812-cp38-cp38-win_amd64.whl", hash = "sha256:0d0a87c0e7e3a9becdfbe936c981d32e5ee0ccda3e0f07e1ef2c3d1a817cf73e"}, 403 | {file = "mypy-0.812-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7ce3175801d0ae5fdfa79b4f0cfed08807af4d075b402b7e294e6aa72af9aa2a"}, 404 | {file = "mypy-0.812-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:b09669bcda124e83708f34a94606e01b614fa71931d356c1f1a5297ba11f110a"}, 405 | {file = "mypy-0.812-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:33f159443db0829d16f0a8d83d94df3109bb6dd801975fe86bacb9bf71628e97"}, 406 | {file = "mypy-0.812-cp39-cp39-win_amd64.whl", hash = "sha256:3f2aca7f68580dc2508289c729bd49ee929a436208d2b2b6aab15745a70a57df"}, 407 | {file = "mypy-0.812-py3-none-any.whl", hash = "sha256:2f9b3407c58347a452fc0736861593e105139b905cca7d097e413453a1d650b4"}, 408 | {file = "mypy-0.812.tar.gz", hash = "sha256:cd07039aa5df222037005b08fbbfd69b3ab0b0bd7a07d7906de75ae52c4e3119"}, 409 | ] 410 | mypy-extensions = [ 411 | {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, 412 | {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, 413 | ] 414 | packaging = [ 415 | {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, 416 | {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, 417 | ] 418 | pathspec = [ 419 | {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"}, 420 | {file = "pathspec-0.8.1.tar.gz", hash = "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd"}, 421 | ] 422 | pluggy = [ 423 | {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, 424 | {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, 425 | ] 426 | py = [ 427 | {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, 428 | {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, 429 | ] 430 | pylint = [ 431 | {file = "pylint-2.7.2-py3-none-any.whl", hash = "sha256:d09b0b07ba06bcdff463958f53f23df25e740ecd81895f7d2699ec04bbd8dc3b"}, 432 | {file = "pylint-2.7.2.tar.gz", hash = "sha256:0e21d3b80b96740909d77206d741aa3ce0b06b41be375d92e1f3244a274c1f8a"}, 433 | ] 434 | pyparsing = [ 435 | {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, 436 | {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, 437 | ] 438 | pytest = [ 439 | {file = "pytest-6.2.2-py3-none-any.whl", hash = "sha256:b574b57423e818210672e07ca1fa90aaf194a4f63f3ab909a2c67ebb22913839"}, 440 | {file = "pytest-6.2.2.tar.gz", hash = "sha256:9d1edf9e7d0b84d72ea3dbcdfd22b35fb543a5e8f2a60092dd578936bf63d7f9"}, 441 | ] 442 | regex = [ 443 | {file = "regex-2021.3.17-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:b97ec5d299c10d96617cc851b2e0f81ba5d9d6248413cd374ef7f3a8871ee4a6"}, 444 | {file = "regex-2021.3.17-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:cb4ee827857a5ad9b8ae34d3c8cc51151cb4a3fe082c12ec20ec73e63cc7c6f0"}, 445 | {file = "regex-2021.3.17-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:633497504e2a485a70a3268d4fc403fe3063a50a50eed1039083e9471ad0101c"}, 446 | {file = "regex-2021.3.17-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:a59a2ee329b3de764b21495d78c92ab00b4ea79acef0f7ae8c1067f773570afa"}, 447 | {file = "regex-2021.3.17-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:f85d6f41e34f6a2d1607e312820971872944f1661a73d33e1e82d35ea3305e14"}, 448 | {file = "regex-2021.3.17-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:4651f839dbde0816798e698626af6a2469eee6d9964824bb5386091255a1694f"}, 449 | {file = "regex-2021.3.17-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:39c44532d0e4f1639a89e52355b949573e1e2c5116106a395642cbbae0ff9bcd"}, 450 | {file = "regex-2021.3.17-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:3d9a7e215e02bd7646a91fb8bcba30bc55fd42a719d6b35cf80e5bae31d9134e"}, 451 | {file = "regex-2021.3.17-cp36-cp36m-win32.whl", hash = "sha256:159fac1a4731409c830d32913f13f68346d6b8e39650ed5d704a9ce2f9ef9cb3"}, 452 | {file = "regex-2021.3.17-cp36-cp36m-win_amd64.whl", hash = "sha256:13f50969028e81765ed2a1c5fcfdc246c245cf8d47986d5172e82ab1a0c42ee5"}, 453 | {file = "regex-2021.3.17-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b9d8d286c53fe0cbc6d20bf3d583cabcd1499d89034524e3b94c93a5ab85ca90"}, 454 | {file = "regex-2021.3.17-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:201e2619a77b21a7780580ab7b5ce43835e242d3e20fef50f66a8df0542e437f"}, 455 | {file = "regex-2021.3.17-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d47d359545b0ccad29d572ecd52c9da945de7cd6cf9c0cfcb0269f76d3555689"}, 456 | {file = "regex-2021.3.17-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:ea2f41445852c660ba7c3ebf7d70b3779b20d9ca8ba54485a17740db49f46932"}, 457 | {file = "regex-2021.3.17-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:486a5f8e11e1f5bbfcad87f7c7745eb14796642323e7e1829a331f87a713daaa"}, 458 | {file = "regex-2021.3.17-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:18e25e0afe1cf0f62781a150c1454b2113785401ba285c745acf10c8ca8917df"}, 459 | {file = "regex-2021.3.17-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:a2ee026f4156789df8644d23ef423e6194fad0bc53575534101bb1de5d67e8ce"}, 460 | {file = "regex-2021.3.17-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:4c0788010a93ace8a174d73e7c6c9d3e6e3b7ad99a453c8ee8c975ddd9965643"}, 461 | {file = "regex-2021.3.17-cp37-cp37m-win32.whl", hash = "sha256:575a832e09d237ae5fedb825a7a5bc6a116090dd57d6417d4f3b75121c73e3be"}, 462 | {file = "regex-2021.3.17-cp37-cp37m-win_amd64.whl", hash = "sha256:8e65e3e4c6feadf6770e2ad89ad3deb524bcb03d8dc679f381d0568c024e0deb"}, 463 | {file = "regex-2021.3.17-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a0df9a0ad2aad49ea3c7f65edd2ffb3d5c59589b85992a6006354f6fb109bb18"}, 464 | {file = "regex-2021.3.17-cp38-cp38-manylinux1_i686.whl", hash = "sha256:b98bc9db003f1079caf07b610377ed1ac2e2c11acc2bea4892e28cc5b509d8d5"}, 465 | {file = "regex-2021.3.17-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:808404898e9a765e4058bf3d7607d0629000e0a14a6782ccbb089296b76fa8fe"}, 466 | {file = "regex-2021.3.17-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:5770a51180d85ea468234bc7987f5597803a4c3d7463e7323322fe4a1b181578"}, 467 | {file = "regex-2021.3.17-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:976a54d44fd043d958a69b18705a910a8376196c6b6ee5f2596ffc11bff4420d"}, 468 | {file = "regex-2021.3.17-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:63f3ca8451e5ff7133ffbec9eda641aeab2001be1a01878990f6c87e3c44b9d5"}, 469 | {file = "regex-2021.3.17-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:bcd945175c29a672f13fce13a11893556cd440e37c1b643d6eeab1988c8b209c"}, 470 | {file = "regex-2021.3.17-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:3d9356add82cff75413bec360c1eca3e58db4a9f5dafa1f19650958a81e3249d"}, 471 | {file = "regex-2021.3.17-cp38-cp38-win32.whl", hash = "sha256:f5d0c921c99297354cecc5a416ee4280bd3f20fd81b9fb671ca6be71499c3fdf"}, 472 | {file = "regex-2021.3.17-cp38-cp38-win_amd64.whl", hash = "sha256:14de88eda0976020528efc92d0a1f8830e2fb0de2ae6005a6fc4e062553031fa"}, 473 | {file = "regex-2021.3.17-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4c2e364491406b7888c2ad4428245fc56c327e34a5dfe58fd40df272b3c3dab3"}, 474 | {file = "regex-2021.3.17-cp39-cp39-manylinux1_i686.whl", hash = "sha256:8bd4f91f3fb1c9b1380d6894bd5b4a519409135bec14c0c80151e58394a4e88a"}, 475 | {file = "regex-2021.3.17-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:882f53afe31ef0425b405a3f601c0009b44206ea7f55ee1c606aad3cc213a52c"}, 476 | {file = "regex-2021.3.17-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:07ef35301b4484bce843831e7039a84e19d8d33b3f8b2f9aab86c376813d0139"}, 477 | {file = "regex-2021.3.17-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:360a01b5fa2ad35b3113ae0c07fb544ad180603fa3b1f074f52d98c1096fa15e"}, 478 | {file = "regex-2021.3.17-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:709f65bb2fa9825f09892617d01246002097f8f9b6dde8d1bb4083cf554701ba"}, 479 | {file = "regex-2021.3.17-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:c66221e947d7207457f8b6f42b12f613b09efa9669f65a587a2a71f6a0e4d106"}, 480 | {file = "regex-2021.3.17-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:c782da0e45aff131f0bed6e66fbcfa589ff2862fc719b83a88640daa01a5aff7"}, 481 | {file = "regex-2021.3.17-cp39-cp39-win32.whl", hash = "sha256:dc9963aacb7da5177e40874585d7407c0f93fb9d7518ec58b86e562f633f36cd"}, 482 | {file = "regex-2021.3.17-cp39-cp39-win_amd64.whl", hash = "sha256:a0d04128e005142260de3733591ddf476e4902c0c23c1af237d9acf3c96e1b38"}, 483 | {file = "regex-2021.3.17.tar.gz", hash = "sha256:4b8a1fb724904139149a43e172850f35aa6ea97fb0545244dc0b805e0154ed68"}, 484 | ] 485 | toml = [ 486 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 487 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 488 | ] 489 | typed-ast = [ 490 | {file = "typed_ast-1.4.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:7703620125e4fb79b64aa52427ec192822e9f45d37d4b6625ab37ef403e1df70"}, 491 | {file = "typed_ast-1.4.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c9aadc4924d4b5799112837b226160428524a9a45f830e0d0f184b19e4090487"}, 492 | {file = "typed_ast-1.4.2-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:9ec45db0c766f196ae629e509f059ff05fc3148f9ffd28f3cfe75d4afb485412"}, 493 | {file = "typed_ast-1.4.2-cp35-cp35m-win32.whl", hash = "sha256:85f95aa97a35bdb2f2f7d10ec5bbdac0aeb9dafdaf88e17492da0504de2e6400"}, 494 | {file = "typed_ast-1.4.2-cp35-cp35m-win_amd64.whl", hash = "sha256:9044ef2df88d7f33692ae3f18d3be63dec69c4fb1b5a4a9ac950f9b4ba571606"}, 495 | {file = "typed_ast-1.4.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c1c876fd795b36126f773db9cbb393f19808edd2637e00fd6caba0e25f2c7b64"}, 496 | {file = "typed_ast-1.4.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:5dcfc2e264bd8a1db8b11a892bd1647154ce03eeba94b461effe68790d8b8e07"}, 497 | {file = "typed_ast-1.4.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8db0e856712f79c45956da0c9a40ca4246abc3485ae0d7ecc86a20f5e4c09abc"}, 498 | {file = "typed_ast-1.4.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:d003156bb6a59cda9050e983441b7fa2487f7800d76bdc065566b7d728b4581a"}, 499 | {file = "typed_ast-1.4.2-cp36-cp36m-win32.whl", hash = "sha256:4c790331247081ea7c632a76d5b2a265e6d325ecd3179d06e9cf8d46d90dd151"}, 500 | {file = "typed_ast-1.4.2-cp36-cp36m-win_amd64.whl", hash = "sha256:d175297e9533d8d37437abc14e8a83cbc68af93cc9c1c59c2c292ec59a0697a3"}, 501 | {file = "typed_ast-1.4.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf54cfa843f297991b7388c281cb3855d911137223c6b6d2dd82a47ae5125a41"}, 502 | {file = "typed_ast-1.4.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:b4fcdcfa302538f70929eb7b392f536a237cbe2ed9cba88e3bf5027b39f5f77f"}, 503 | {file = "typed_ast-1.4.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:987f15737aba2ab5f3928c617ccf1ce412e2e321c77ab16ca5a293e7bbffd581"}, 504 | {file = "typed_ast-1.4.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:37f48d46d733d57cc70fd5f30572d11ab8ed92da6e6b28e024e4a3edfb456e37"}, 505 | {file = "typed_ast-1.4.2-cp37-cp37m-win32.whl", hash = "sha256:36d829b31ab67d6fcb30e185ec996e1f72b892255a745d3a82138c97d21ed1cd"}, 506 | {file = "typed_ast-1.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:8368f83e93c7156ccd40e49a783a6a6850ca25b556c0fa0240ed0f659d2fe496"}, 507 | {file = "typed_ast-1.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:963c80b583b0661918718b095e02303d8078950b26cc00b5e5ea9ababe0de1fc"}, 508 | {file = "typed_ast-1.4.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e683e409e5c45d5c9082dc1daf13f6374300806240719f95dc783d1fc942af10"}, 509 | {file = "typed_ast-1.4.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:84aa6223d71012c68d577c83f4e7db50d11d6b1399a9c779046d75e24bed74ea"}, 510 | {file = "typed_ast-1.4.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:a38878a223bdd37c9709d07cd357bb79f4c760b29210e14ad0fb395294583787"}, 511 | {file = "typed_ast-1.4.2-cp38-cp38-win32.whl", hash = "sha256:a2c927c49f2029291fbabd673d51a2180038f8cd5a5b2f290f78c4516be48be2"}, 512 | {file = "typed_ast-1.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:c0c74e5579af4b977c8b932f40a5464764b2f86681327410aa028a22d2f54937"}, 513 | {file = "typed_ast-1.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:07d49388d5bf7e863f7fa2f124b1b1d89d8aa0e2f7812faff0a5658c01c59aa1"}, 514 | {file = "typed_ast-1.4.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:240296b27397e4e37874abb1df2a608a92df85cf3e2a04d0d4d61055c8305ba6"}, 515 | {file = "typed_ast-1.4.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:d746a437cdbca200622385305aedd9aef68e8a645e385cc483bdc5e488f07166"}, 516 | {file = "typed_ast-1.4.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:14bf1522cdee369e8f5581238edac09150c765ec1cb33615855889cf33dcb92d"}, 517 | {file = "typed_ast-1.4.2-cp39-cp39-win32.whl", hash = "sha256:cc7b98bf58167b7f2db91a4327da24fb93368838eb84a44c472283778fc2446b"}, 518 | {file = "typed_ast-1.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:7147e2a76c75f0f64c4319886e7639e490fee87c9d25cb1d4faef1d8cf83a440"}, 519 | {file = "typed_ast-1.4.2.tar.gz", hash = "sha256:9fc0b3cb5d1720e7141d103cf4819aea239f7d136acf9ee4a69b047b7986175a"}, 520 | ] 521 | typing-extensions = [ 522 | {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, 523 | {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, 524 | {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, 525 | ] 526 | wrapt = [ 527 | {file = "wrapt-1.12.1.tar.gz", hash = "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7"}, 528 | ] 529 | zipp = [ 530 | {file = "zipp-3.4.1-py3-none-any.whl", hash = "sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098"}, 531 | {file = "zipp-3.4.1.tar.gz", hash = "sha256:3607921face881ba3e026887d8150cca609d517579abe052ac81fc5aeffdbd76"}, 532 | ] 533 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pymessagebus" 3 | version = "1.2.3" 4 | description = "A simple implementation of the MessageBus / CommandBus pattern" 5 | authors = ["Olivier Philippon "] 6 | license = "MIT" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.6" 10 | 11 | [tool.poetry.dev-dependencies] 12 | pytest = "^6.2.2" 13 | pylint = "^2.7.2" 14 | mypy = "^0.812" 15 | black = "^20.8b1" 16 | 17 | [build-system] 18 | requires = ["poetry-core>=1.0.0"] 19 | build-backend = "poetry.core.masonry.api" 20 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test=pytest 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # pylint: skip-file 2 | 3 | from glob import glob 4 | from os.path import basename, splitext 5 | 6 | import setuptools 7 | 8 | # Get the long description from the README file 9 | with open("README.md", "r", encoding="utf-8") as f: 10 | long_description = f.read() 11 | 12 | setuptools.setup( 13 | name="pymessagebus", 14 | version="1.2.3", 15 | description="A simple implementation of the MessageBus / CommandBus pattern", 16 | long_description=long_description, 17 | long_description_content_type="text/markdown", 18 | url="https://github.com/DrBenton/pymessagebus", 19 | author="Olivier Philippon", 20 | author_email="olivier@rougemine.com", 21 | license="MIT", 22 | # See https://pypi.python.org/pypi?%3Aaction=list_classifiers 23 | classifiers=[ 24 | "Development Status :: 5 - Production/Stable", 25 | "Intended Audience :: Developers", 26 | "License :: OSI Approved :: MIT License", 27 | "Operating System :: OS Independent", 28 | "Topic :: Software Development :: Libraries :: Python Modules", 29 | "Programming Language :: Python :: 3.6", 30 | "Programming Language :: Python :: 3.7", 31 | "Programming Language :: Python :: 3.8", 32 | "Programming Language :: Python :: 3.9", 33 | ], 34 | keywords="CommandBus MessageBus CommandHandler DDD domain-driven-design design-pattern decoupling", 35 | packages=setuptools.find_packages("src"), 36 | package_dir={"": "src"}, 37 | py_modules=[splitext(basename(path))[0] for path in glob("src/**/*.py")], 38 | include_package_data=True, 39 | zip_safe=False, 40 | install_requires=[], 41 | python_requires=">=3.6", 42 | tests_require=[ 43 | "pytest", 44 | "pylint", 45 | "mypy", 46 | "black", 47 | ], 48 | ) 49 | -------------------------------------------------------------------------------- /src/pymessagebus/__init__.py: -------------------------------------------------------------------------------- 1 | from ._messagebus import MessageBus 2 | from ._commandbus import CommandBus 3 | -------------------------------------------------------------------------------- /src/pymessagebus/_commandbus.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from ._messagebus import api, MessageBus 4 | 5 | 6 | class CommandBus(api.CommandBus): 7 | def __init__( 8 | self, 9 | *, 10 | middlewares: t.List[api.Middleware] = None, 11 | allow_result: bool = True, 12 | locking: bool = True, 13 | ) -> None: 14 | self._messagebus = MessageBus(middlewares=middlewares) 15 | self._allow_result = bool(allow_result) 16 | self._locking = bool(locking) 17 | self._is_processing_a_message = False 18 | 19 | def add_handler(self, message_class: type, message_handler: t.Callable) -> None: 20 | if self._messagebus.has_handler_for(message_class): 21 | raise api.CommandHandlerAlreadyRegisteredForAType( 22 | f"A command handler is already registed for message class '{message_class}'." 23 | ) 24 | self._messagebus.add_handler(message_class, message_handler) 25 | 26 | def remove_handler(self, message_class: type) -> bool: 27 | if not self._messagebus.has_handler_for(message_class): 28 | return False 29 | return self._messagebus.remove_handler( 30 | message_class, self._messagebus._handlers[message_class][0] 31 | ) 32 | 33 | def handle(self, message: object) -> t.Any: 34 | if not self._messagebus.has_handler_for(message.__class__): 35 | raise api.CommandHandlerNotFound( 36 | f"No command handler is registered for message class '{message.__class__}'." 37 | ) 38 | if self._locking and self._is_processing_a_message: 39 | raise api.CommandBusAlreadyProcessingAMessage( 40 | f"CommandBus already processing a message when received a '{message.__class__}' one." # pylint: disable=line-too-long 41 | ) 42 | self._is_processing_a_message = True 43 | result = self._messagebus.handle(message) 44 | self._is_processing_a_message = False 45 | return result[0] if self._allow_result else None 46 | 47 | def has_handler_for(self, message_class: type) -> bool: 48 | return self._messagebus.has_handler_for(message_class) 49 | -------------------------------------------------------------------------------- /src/pymessagebus/_messagebus.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | import typing as t 3 | 4 | from . import api 5 | 6 | 7 | class MessageBus(api.MessageBus): 8 | def __init__(self, *, middlewares: t.List[api.Middleware] = None) -> None: 9 | self._handlers: t.Dict[type, t.List[t.Callable]] = defaultdict(list) 10 | self._middlewares_chain = self._get_middlewares_callables_chain( 11 | middlewares, self._trigger_handlers_for_message_as_a_middleware 12 | ) 13 | 14 | def add_handler(self, message_class: type, message_handler: t.Callable) -> None: 15 | if not isinstance(message_class, type): 16 | raise api.MessageHandlerMappingRequiresAType( 17 | f"add_handler() first argument must be a type, got '{type(message_class)}" 18 | ) 19 | if not callable(message_handler): 20 | raise api.MessageHandlerMappingRequiresACallable( 21 | f"add_handler() second argument must be a callable, got '{type(message_handler)}" 22 | ) 23 | 24 | self._handlers[message_class].append(message_handler) 25 | 26 | def remove_handler(self, message_class: type, message_handler: t.Callable) -> bool: 27 | """ 28 | Returns `True` if a handler was found for this message class and caller and removed, 29 | `False` otherwise 30 | """ 31 | if not isinstance(message_class, type): 32 | raise api.MessageHandlerMappingRequiresAType( 33 | f"add_handler() first argument must be a type, got '{type(message_class)}" 34 | ) 35 | if not callable(message_handler): 36 | raise api.MessageHandlerMappingRequiresACallable( 37 | f"add_handler() second argument must be a callable, got '{type(message_handler)}" 38 | ) 39 | if message_class not in self._handlers: 40 | return False 41 | if message_handler not in self._handlers[message_class]: 42 | return False 43 | 44 | self._handlers[message_class].remove(message_handler) 45 | 46 | if len(self._handlers[message_class]) == 0: 47 | del self._handlers[message_class] 48 | 49 | return True 50 | 51 | def handle(self, message: object) -> t.List[t.Any]: 52 | if not self.has_handler_for(message.__class__): 53 | return [] 54 | result = self._middlewares_chain(message) 55 | return result 56 | 57 | def has_handler_for(self, message_class: type) -> bool: 58 | return message_class in self._handlers 59 | 60 | def _trigger_handlers_for_message_as_a_middleware( 61 | self, message: object, unused_next: t.Callable 62 | ) -> t.List[t.Any]: 63 | handlers: t.List[t.Callable] = self._handlers[message.__class__] 64 | results = [self._trigger_handler(message, handler) for handler in handlers] 65 | return results 66 | 67 | @staticmethod 68 | def _get_middlewares_callables_chain( 69 | middlewares: t.Union[t.List[api.Middleware], None], message_handler: t.Callable 70 | ) -> t.Callable[[object], t.Any]: 71 | """ 72 | The algorithm comes from the source code of Tactician (PHP CommandBus): 73 | https://github.com/thephpleague/tactician/blob/master/src/CommandBus.php#L50 :-) 74 | """ 75 | all_middlewares = (middlewares or []) + [message_handler] 76 | 77 | # the last "middleware" is actually the execution of the target Message Handler, 78 | # so it won't make any use of the "next" parameter but we have to provide it. 79 | # --> let's use a no-op lambda as the last middleware's "next" parameter: 80 | chain = lambda _: None 81 | 82 | for middleware in reversed(all_middlewares): 83 | chain = MessageBus._get_middleware_callable_for_middleware( 84 | middleware, chain 85 | ) 86 | return chain 87 | 88 | @staticmethod 89 | def _get_middleware_callable_for_middleware( 90 | middleware: api.Middleware, next_middleware: t.Callable 91 | ) -> t.Callable[[object], t.Any]: 92 | def middleware_callable(message: object): 93 | return middleware(message, next_middleware) 94 | 95 | return middleware_callable 96 | 97 | @staticmethod 98 | def _trigger_handler(message: object, handler: t.Callable) -> t.Any: 99 | return handler(message) 100 | -------------------------------------------------------------------------------- /src/pymessagebus/api.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | import typing as t 3 | 4 | CallNextMiddleware = t.Callable[[object], t.Any] 5 | Middleware = t.Callable[[object, CallNextMiddleware], t.Any] 6 | 7 | 8 | class MessageBus(ABC): 9 | @abstractmethod 10 | def add_handler(self, message_class: type, message_handler: t.Callable) -> None: 11 | pass 12 | 13 | @abstractmethod 14 | def remove_handler(self, message_class: type, message_handler: t.Callable) -> bool: 15 | pass 16 | 17 | @abstractmethod 18 | def handle(self, message: object) -> t.List[t.Any]: 19 | pass 20 | 21 | @abstractmethod 22 | def has_handler_for(self, message_class: type) -> bool: 23 | pass 24 | 25 | 26 | class CommandBus(ABC): 27 | @abstractmethod 28 | def add_handler(self, message_class: type, message_handler: t.Callable) -> None: 29 | pass 30 | 31 | @abstractmethod 32 | def remove_handler(self, message_class: type) -> bool: 33 | pass 34 | 35 | @abstractmethod 36 | def handle(self, message: object) -> None: 37 | pass 38 | 39 | @abstractmethod 40 | def has_handler_for(self, message_class: type) -> bool: 41 | pass 42 | 43 | 44 | class MessageBusError(Exception, ABC): 45 | pass 46 | 47 | 48 | class MessageHandlerMappingRequiresAType(MessageBusError): 49 | pass 50 | 51 | 52 | class MessageHandlerMappingRequiresACallable(MessageBusError): 53 | pass 54 | 55 | 56 | class CommandHandlerNotFound(MessageBusError): 57 | pass 58 | 59 | 60 | class CommandHandlerAlreadyRegisteredForAType(MessageBusError): 61 | pass 62 | 63 | 64 | class CommandBusAlreadyProcessingAMessage(MessageBusError): 65 | pass 66 | -------------------------------------------------------------------------------- /src/pymessagebus/default/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olivierphi/pymessagebus/4a64607531816adda2adba246a922b6cf3688adc/src/pymessagebus/default/__init__.py -------------------------------------------------------------------------------- /src/pymessagebus/default/commandbus.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from pymessagebus._commandbus import CommandBus 4 | 5 | 6 | _DEFAULT_COMMAND_BUS = CommandBus() 7 | 8 | # Public API: 9 | # This is our handy decorator: 10 | def register_handler(message_class: type): 11 | def decorator(handler: t.Callable): 12 | _DEFAULT_COMMAND_BUS.add_handler(message_class, handler) 13 | return handler 14 | 15 | return decorator 16 | 17 | 18 | # And those are aliases to our "default" singleton instance: 19 | # pylint: disable=invalid-name 20 | add_handler = _DEFAULT_COMMAND_BUS.add_handler 21 | handle = _DEFAULT_COMMAND_BUS.handle 22 | has_handler_for = _DEFAULT_COMMAND_BUS.has_handler_for 23 | -------------------------------------------------------------------------------- /src/pymessagebus/default/messagebus.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | from pymessagebus._messagebus import MessageBus 4 | 5 | 6 | _DEFAULT_MESSAGE_BUS = MessageBus() 7 | 8 | # Public API: 9 | # This is our handy decorator: 10 | def register_handler(message_class: type): 11 | def decorator(handler: t.Callable): 12 | _DEFAULT_MESSAGE_BUS.add_handler(message_class, handler) 13 | return handler 14 | 15 | return decorator 16 | 17 | 18 | # And those are aliases to our "default" singleton instance: 19 | # pylint: disable=invalid-name 20 | add_handler = _DEFAULT_MESSAGE_BUS.add_handler 21 | handle = _DEFAULT_MESSAGE_BUS.handle 22 | has_handler_for = _DEFAULT_MESSAGE_BUS.has_handler_for 23 | -------------------------------------------------------------------------------- /src/pymessagebus/middleware/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olivierphi/pymessagebus/4a64607531816adda2adba246a922b6cf3688adc/src/pymessagebus/middleware/__init__.py -------------------------------------------------------------------------------- /src/pymessagebus/middleware/logger.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | import logging 3 | 4 | # Heavily inspired by the Tactician Logger Middleware :-) 5 | # @link https://github.com/thephpleague/tactician-logger 6 | 7 | # pylint: disable=too-few-public-methods 8 | 9 | 10 | class LoggingMiddlewareConfig(t.NamedTuple): 11 | mgs_received_level: int = logging.DEBUG 12 | mgs_succeeded_level: int = logging.DEBUG 13 | mgs_failed_level: int = logging.ERROR 14 | 15 | 16 | def get_logger_middleware( 17 | logger: logging.Logger, config: t.Optional[LoggingMiddlewareConfig] = None 18 | ) -> t.Callable: 19 | # pylint: disable=E1120 20 | middleware_config: LoggingMiddlewareConfig = config or LoggingMiddlewareConfig() 21 | 22 | def logger_middleware(message: object, next_: t.Callable) -> object: 23 | message_type = type(message) 24 | 25 | logger.log( 26 | middleware_config.mgs_received_level, f"Message received: ${message_type}" 27 | ) 28 | 29 | try: 30 | result = next_(message) 31 | except Exception as err: 32 | logger.log( 33 | middleware_config.mgs_failed_level, 34 | f"Message failed: ${message_type}", 35 | exc_info=True, 36 | ) 37 | raise err 38 | 39 | logger.log( 40 | middleware_config.mgs_succeeded_level, f"Message succeeded: ${message_type}" 41 | ) 42 | 43 | return result 44 | 45 | return logger_middleware 46 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olivierphi/pymessagebus/4a64607531816adda2adba246a922b6cf3688adc/tests/__init__.py -------------------------------------------------------------------------------- /tests/__init___test.py: -------------------------------------------------------------------------------- 1 | # pylint: skip-file 2 | 3 | 4 | def test_messagebus_package_alias(): 5 | from pymessagebus import MessageBus 6 | 7 | sut = MessageBus() 8 | sut.add_handler(EmptyMessage, get_one) 9 | sut.add_handler(EmptyMessage, get_two) 10 | 11 | message = EmptyMessage() 12 | handling_result = sut.handle(message) 13 | assert handling_result == [1, 2] 14 | 15 | 16 | def test_commandbus_package_alias(): 17 | from pymessagebus import CommandBus 18 | 19 | sut = CommandBus() 20 | sut.add_handler(EmptyMessage, get_one) 21 | 22 | message = EmptyMessage() 23 | handling_result = sut.handle(message) 24 | assert handling_result == 1 25 | 26 | 27 | class EmptyMessage: 28 | pass 29 | 30 | 31 | get_one = lambda _: 1 32 | get_two = lambda _: 2 33 | -------------------------------------------------------------------------------- /tests/_commandbus_test.py: -------------------------------------------------------------------------------- 1 | # pylint: skip-file 2 | import typing as t 3 | 4 | import pytest 5 | 6 | from pymessagebus import api 7 | from pymessagebus._commandbus import CommandBus 8 | 9 | 10 | def test_simplest_handler(): 11 | sut = CommandBus() 12 | sut.add_handler(EmptyMessage, get_one) 13 | 14 | message = EmptyMessage() 15 | handling_result = sut.handle(message) 16 | assert handling_result == 1 17 | 18 | 19 | def test_has_handler_for(): 20 | sut = CommandBus() 21 | sut.add_handler(MessageClassOne, get_one) 22 | 23 | assert sut.has_handler_for(MessageClassOne) is True 24 | assert sut.has_handler_for(MessageClassTwo) is False 25 | 26 | 27 | def test_remove_handler(): 28 | sut = CommandBus() 29 | sut.add_handler(MessageClassOne, get_one) 30 | assert sut.has_handler_for(MessageClassOne) 31 | 32 | assert sut.remove_handler(MessageClassOne) is True 33 | assert sut.remove_handler(MessageClassOne) is False 34 | assert sut.has_handler_for(MessageClassOne) is False 35 | 36 | 37 | def test_commandbus_can_be_configured_to_not_return_anything_on_command_handling(): 38 | sut = CommandBus(allow_result=False) 39 | sut.add_handler(MessageClassOne, get_one) 40 | 41 | message = MessageClassOne() 42 | handling_result = sut.handle(message) 43 | assert handling_result is None 44 | 45 | 46 | def test_handlers_get_message(): 47 | sut = CommandBus() 48 | sut.add_handler(EmptyMessage, identity_handler) 49 | 50 | message = EmptyMessage() 51 | handling_result = sut.handle(message) 52 | assert handling_result == message 53 | 54 | 55 | def test_handler_must_be_registered_for_a_message_type(): 56 | sut = CommandBus() 57 | 58 | message = EmptyMessage() 59 | with pytest.raises(api.CommandHandlerNotFound): 60 | sut.handle(message) 61 | 62 | 63 | def test_handler_message_must_be_a_type(): 64 | sut = CommandBus() 65 | 66 | not_a_type = EmptyMessage() 67 | with pytest.raises(api.MessageHandlerMappingRequiresAType): 68 | sut.add_handler(not_a_type, get_one) 69 | 70 | 71 | def test_multiple_handlers_for_single_message_triggers_error(): 72 | sut = CommandBus() 73 | sut.add_handler(EmptyMessage, get_one) 74 | 75 | with pytest.raises(api.CommandHandlerAlreadyRegisteredForAType): 76 | sut.add_handler(EmptyMessage, get_one) 77 | 78 | 79 | def test_handler_is_triggered_each_time(): 80 | counter = 0 81 | 82 | def handler(msg): 83 | nonlocal counter 84 | counter += 1 85 | return counter 86 | 87 | sut = CommandBus() 88 | sut.add_handler(EmptyMessage, handler) 89 | 90 | message = EmptyMessage() 91 | handling_result = sut.handle(message) 92 | assert handling_result == 1 93 | handling_result = sut.handle(message) 94 | assert handling_result == 2 95 | 96 | 97 | def test_middlewares(): 98 | class MessageWithList(t.NamedTuple): 99 | payload: t.List[str] 100 | 101 | def middleware_one(message: MessageWithList, next: api.CallNextMiddleware): 102 | message.payload.append("middleware one: does something before the handler") 103 | result = next(message) 104 | message.payload.append("middleware one: does something after the handler") 105 | return result 106 | 107 | def middleware_two(message: MessageWithList, next: api.CallNextMiddleware): 108 | message.payload.append("middleware two: does something before the handler") 109 | result = next(message) 110 | message.payload.append("middleware two: does something after the handler") 111 | return result 112 | 113 | def handler(message: MessageWithList) -> str: 114 | message.payload.append("handler does something") 115 | return "handler result" 116 | 117 | # We already tests simpler cases on the MessageBus test suite, so we will only test the most complex case here: 118 | sut = CommandBus(middlewares=[middleware_one, middleware_two]) 119 | sut.add_handler(MessageWithList, handler) 120 | 121 | message = MessageWithList(payload=["initial message payload"]) 122 | result = sut.handle(message) 123 | assert message.payload == [ 124 | "initial message payload", 125 | "middleware one: does something before the handler", 126 | "middleware two: does something before the handler", 127 | "handler does something", 128 | "middleware two: does something after the handler", 129 | "middleware one: does something after the handler", 130 | ] 131 | assert result == "handler result" 132 | 133 | 134 | def test_locking(): 135 | sut = None 136 | test_list = [] 137 | 138 | class MessageWithPayload(t.NamedTuple): 139 | payload: int 140 | 141 | class OtherMessageWithPayload(MessageWithPayload): 142 | pass 143 | 144 | message = MessageWithPayload(payload=33) 145 | 146 | def handler_which_triggers_handler_two(msg): 147 | nonlocal test_list, sut 148 | test_list.append(f"1:{msg.payload}") 149 | result = sut.handle(OtherMessageWithPayload(payload=43)) 150 | return f"handler_one_was_here:{result}" 151 | 152 | def handler_two(msg): 153 | nonlocal test_list 154 | test_list.append(f"2:{msg.payload}") 155 | return "handler_two_was_here" 156 | 157 | # With the default "locking" option expect an error to be raised 158 | # if a message is sent to the bus while another one is still in progress: 159 | sut = CommandBus() 160 | sut.add_handler(MessageWithPayload, handler_which_triggers_handler_two) 161 | sut.add_handler(OtherMessageWithPayload, handler_two) 162 | 163 | with pytest.raises(api.CommandBusAlreadyProcessingAMessage): 164 | sut.handle(message) 165 | 166 | # But by setting the "locking" option to `False` we should be able to process a message even in such a case: 167 | test_list = [] 168 | sut = CommandBus(locking=False) 169 | sut.add_handler(MessageWithPayload, handler_which_triggers_handler_two) 170 | sut.add_handler(OtherMessageWithPayload, handler_two) 171 | 172 | result = sut.handle(message) 173 | assert test_list == ["1:33", "2:43"] 174 | assert result == "handler_one_was_here:handler_two_was_here" 175 | 176 | 177 | class EmptyMessage: 178 | pass 179 | 180 | 181 | class MessageClassOne: 182 | pass 183 | 184 | 185 | class MessageClassTwo: 186 | pass 187 | 188 | 189 | def identity_handler(message: object) -> object: 190 | return message 191 | 192 | 193 | get_one = lambda _: 1 194 | get_two = lambda _: 2 195 | get_three = lambda _: 3 196 | -------------------------------------------------------------------------------- /tests/_messagebus_test.py: -------------------------------------------------------------------------------- 1 | # pylint: skip-file 2 | import typing as t 3 | 4 | import pytest 5 | 6 | from pymessagebus import api 7 | from pymessagebus._messagebus import MessageBus 8 | 9 | 10 | def test_simplest_handler_can_have_no_handlers_for_a_message(): 11 | sut = MessageBus() 12 | 13 | message = EmptyMessage() 14 | handling_result = sut.handle(message) 15 | assert handling_result == [] 16 | 17 | 18 | def test_simplest_handler(): 19 | sut = MessageBus() 20 | sut.add_handler(EmptyMessage, get_one) 21 | 22 | message = EmptyMessage() 23 | handling_result = sut.handle(message) 24 | assert handling_result == [1] 25 | 26 | 27 | def test_has_handler_for(): 28 | sut = MessageBus() 29 | sut.add_handler(MessageClassOne, get_one) 30 | 31 | assert sut.has_handler_for(MessageClassOne) is True 32 | assert sut.has_handler_for(MessageClassTwo) is False 33 | 34 | def test_remove_handler(): 35 | sut = MessageBus() 36 | sut.add_handler(MessageClassOne, get_one) 37 | assert sut.has_handler_for(MessageClassOne) 38 | 39 | assert sut.remove_handler(MessageClassOne, get_one) is True 40 | assert sut.remove_handler(MessageClassOne, get_two) is False 41 | assert sut.has_handler_for(MessageClassOne) is False 42 | 43 | def test_handlers_get_message(): 44 | sut = MessageBus() 45 | sut.add_handler(EmptyMessage, identity_handler) 46 | 47 | message = EmptyMessage() 48 | handling_result = sut.handle(message) 49 | assert handling_result == [message] 50 | 51 | 52 | def test_handler_message_must_be_a_type(): 53 | sut = MessageBus() 54 | 55 | not_a_type = EmptyMessage() 56 | with pytest.raises(api.MessageHandlerMappingRequiresAType): 57 | sut.add_handler(not_a_type, get_one) 58 | 59 | 60 | def test_handler_handler_must_be_a_callable(): 61 | sut = MessageBus() 62 | 63 | not_a_callable = 2 64 | with pytest.raises(api.MessageHandlerMappingRequiresACallable): 65 | sut.add_handler(EmptyMessage, not_a_callable) 66 | 67 | 68 | def test_multiple_handlers_for_single_message(): 69 | sut = MessageBus() 70 | sut.add_handler(EmptyMessage, get_one) 71 | sut.add_handler(EmptyMessage, get_one) 72 | 73 | message = EmptyMessage() 74 | handling_result = sut.handle(message) 75 | assert handling_result == [1, 1] 76 | 77 | 78 | def test_multiple_handlers_for_single_message_triggered_in_correct_order(): 79 | sut = MessageBus() 80 | sut.add_handler(EmptyMessage, get_one) 81 | sut.add_handler(EmptyMessage, get_two) 82 | sut.add_handler(EmptyMessage, get_three) 83 | 84 | message = EmptyMessage() 85 | handling_result = sut.handle(message) 86 | assert handling_result == [1, 2, 3] 87 | 88 | 89 | def test_multiple_handlers_correctly_triggered(): 90 | sut = MessageBus() 91 | sut.add_handler(MessageClassOne, get_one) 92 | sut.add_handler(MessageClassOne, get_two) 93 | sut.add_handler(MessageClassTwo, get_three) 94 | 95 | message_one = MessageClassOne() 96 | handling_result_one = sut.handle(message_one) 97 | assert handling_result_one == [1, 2] 98 | 99 | message_two = MessageClassTwo() 100 | handling_result_two = sut.handle(message_two) 101 | assert handling_result_two == [3] 102 | 103 | 104 | def test_handler_is_triggered_each_time(): 105 | counter = 0 106 | 107 | def handler(msg): 108 | nonlocal counter 109 | counter += 1 110 | return counter 111 | 112 | sut = MessageBus() 113 | sut.add_handler(EmptyMessage, handler) 114 | 115 | message = EmptyMessage() 116 | handling_result = sut.handle(message) 117 | assert handling_result == [1] 118 | handling_result = sut.handle(message) 119 | assert handling_result == [2] 120 | 121 | 122 | def test_middlewares(): 123 | class MessageWithList(t.NamedTuple): 124 | payload: t.List[str] 125 | 126 | def middleware_one(message: MessageWithList, next: api.CallNextMiddleware): 127 | message.payload.append("middleware one: does something before the handler") 128 | result = next(message) 129 | message.payload.append("middleware one: does something after the handler") 130 | return result 131 | 132 | def middleware_two(message: MessageWithList, next: api.CallNextMiddleware): 133 | message.payload.append("middleware two: does something before the handler") 134 | result = next(message) 135 | message.payload.append("middleware two: does something after the handler") 136 | return result 137 | 138 | def handler_one(message: MessageWithList): 139 | message.payload.append("handler one does something") 140 | return "handler one result" 141 | 142 | def handler_two(message: MessageWithList): 143 | message.payload.append("handler two does something") 144 | return "handler two result" 145 | 146 | # 1. Simplest test: one handler, one middleware 147 | sut1 = MessageBus(middlewares=[middleware_one]) 148 | sut1.add_handler(MessageWithList, handler_one) 149 | 150 | message1 = MessageWithList(payload=[]) 151 | result1 = sut1.handle(message1) 152 | assert result1 == ["handler one result"] 153 | assert message1.payload == [ 154 | "middleware one: does something before the handler", 155 | "handler one does something", 156 | "middleware one: does something after the handler", 157 | ] 158 | 159 | # 2. Next step: one handler, multiple middlewares 160 | sut2 = MessageBus(middlewares=[middleware_one, middleware_two]) 161 | sut2.add_handler(MessageWithList, handler_one) 162 | 163 | message2 = MessageWithList(payload=[]) 164 | result2 = sut2.handle(message2) 165 | assert result2 == ["handler one result"] 166 | assert message2.payload == [ 167 | "middleware one: does something before the handler", 168 | "middleware two: does something before the handler", 169 | "handler one does something", 170 | "middleware two: does something after the handler", 171 | "middleware one: does something after the handler", 172 | ] 173 | 174 | # 3. Ultimate step: multiple handlers, multiple middlewares 175 | sut3 = MessageBus(middlewares=[middleware_one, middleware_two]) 176 | sut3.add_handler(MessageWithList, handler_one) 177 | sut3.add_handler(MessageWithList, handler_two) 178 | 179 | message3 = MessageWithList(payload=["initial message payload"]) 180 | result3 = sut3.handle(message3) 181 | assert result3 == ["handler one result", "handler two result"] 182 | assert message3.payload == [ 183 | "initial message payload", 184 | "middleware one: does something before the handler", 185 | "middleware two: does something before the handler", 186 | "handler one does something", 187 | "handler two does something", 188 | "middleware two: does something after the handler", 189 | "middleware one: does something after the handler", 190 | ] 191 | 192 | 193 | class EmptyMessage: 194 | pass 195 | 196 | 197 | class MessageClassOne: 198 | pass 199 | 200 | 201 | class MessageClassTwo: 202 | pass 203 | 204 | 205 | def identity_handler(message: object) -> object: 206 | return message 207 | 208 | 209 | get_one = lambda _: 1 210 | get_two = lambda _: 2 211 | get_three = lambda _: 3 212 | -------------------------------------------------------------------------------- /tests/default/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olivierphi/pymessagebus/4a64607531816adda2adba246a922b6cf3688adc/tests/default/__init__.py -------------------------------------------------------------------------------- /tests/default/commandbus_test.py: -------------------------------------------------------------------------------- 1 | # pylint: skip-file 2 | 3 | import importlib 4 | from contextlib import contextmanager 5 | import typing as t 6 | 7 | import pytest 8 | 9 | from pymessagebus.api import CommandHandlerAlreadyRegisteredForAType 10 | 11 | 12 | @contextmanager 13 | def _default_commandbus_unloaded_after_test(): 14 | # Since our "default" CommandBus is just a convenient singleton with a handy decorator, 15 | # we have to reload it everytime we finish a test (or we will find the previous used one for the next test) 16 | from pymessagebus.default import commandbus as default_commandbus 17 | 18 | try: 19 | yield default_commandbus 20 | finally: 21 | importlib.reload(default_commandbus) 22 | 23 | 24 | # Since this "default" package is just a convenient wrapper around our (already tested) MessageBus, 25 | # we won't test everything again :-) 26 | # Let's focus on its first specificity: the singleton aspect! 27 | 28 | 29 | def test_its_well_and_truly_a_singleton(): 30 | with _default_commandbus_unloaded_after_test(): 31 | 32 | def test1(): 33 | def handler(msg): 34 | return 10 35 | 36 | from pymessagebus.default import commandbus as default_commandbus 37 | 38 | default_commandbus.add_handler(EmptyMessage, handler) 39 | message = EmptyMessage() 40 | 41 | handling_result = default_commandbus.handle(message) 42 | assert handling_result == 10 43 | 44 | test1() 45 | 46 | def test2(): 47 | def handler(msg): 48 | return 20 49 | 50 | from pymessagebus.default import commandbus as default_commandbus 51 | 52 | # Now we should trigger an error, since we're re-using the same singleton: 53 | with pytest.raises(CommandHandlerAlreadyRegisteredForAType): 54 | default_commandbus.add_handler(EmptyMessage, handler) 55 | 56 | test2() 57 | 58 | 59 | # Let's now focus on its second specificity: the decorator! 60 | 61 | 62 | def test_simplest_decorator(): 63 | with _default_commandbus_unloaded_after_test() as sut: 64 | 65 | @sut.register_handler(EmptyMessage) 66 | def handler(msg): 67 | return "decorated" 68 | 69 | message = EmptyMessage() 70 | handling_result = sut.handle(message) 71 | assert handling_result == "decorated" 72 | 73 | 74 | def test_decorator_receives_message(): 75 | with _default_commandbus_unloaded_after_test() as sut: 76 | 77 | class MessageWithPayload(t.NamedTuple): 78 | number: int 79 | 80 | @sut.register_handler(MessageWithPayload) 81 | def add_five(msg): 82 | return msg.number + 5 83 | 84 | message = MessageWithPayload(number=3) 85 | handling_result = sut.handle(message) 86 | assert handling_result == 8 87 | message = MessageWithPayload(number=18) 88 | handling_result = sut.handle(message) 89 | assert handling_result == 23 90 | 91 | 92 | def test_multiple_decorators(): 93 | with _default_commandbus_unloaded_after_test() as sut: 94 | 95 | @sut.register_handler(MessageClassOne) 96 | def handler_one(msg): 97 | return 1 98 | 99 | @sut.register_handler(MessageClassTwo) 100 | def handler_two(msg): 101 | return 2 102 | 103 | message_one = MessageClassOne() 104 | handling_result = sut.handle(message_one) 105 | assert handling_result == 1 106 | 107 | message_two = MessageClassTwo() 108 | handling_result = sut.handle(message_two) 109 | assert handling_result == 2 110 | 111 | 112 | def test_multiple_decorators_for_same_message_type_triggers_an_error(): 113 | with _default_commandbus_unloaded_after_test() as sut: 114 | 115 | @sut.register_handler(MessageClassOne) 116 | def handler_one(msg): 117 | return 1 118 | 119 | with pytest.raises(CommandHandlerAlreadyRegisteredForAType): 120 | 121 | @sut.register_handler(MessageClassOne) 122 | def handler_two(msg): 123 | return 2 124 | 125 | 126 | class EmptyMessage: 127 | pass 128 | 129 | 130 | class MessageClassOne: 131 | pass 132 | 133 | 134 | class MessageClassTwo: 135 | pass 136 | -------------------------------------------------------------------------------- /tests/default/messagebus_test.py: -------------------------------------------------------------------------------- 1 | # pylint: skip-file 2 | 3 | import importlib 4 | from contextlib import contextmanager 5 | import typing as t 6 | 7 | 8 | @contextmanager 9 | def _default_messagebus_unloaded_after_test(): 10 | # Since our "default" MessageBus is just a convenient singleton with a handy decorator, 11 | # we have to reload it everytime we finish a test (or we will find the previous used one for the next test) 12 | from pymessagebus.default import messagebus as default_messagebus 13 | 14 | try: 15 | yield default_messagebus 16 | finally: 17 | importlib.reload(default_messagebus) 18 | 19 | 20 | # Since this "default" package is just a convenient wrapper around our (already tested) MessageBus, 21 | # we won't test everything again :-) 22 | # Let's focus on its first specificity: the singleton aspect! 23 | 24 | 25 | def test_its_well_and_truly_a_singleton(): 26 | with _default_messagebus_unloaded_after_test(): 27 | 28 | def test1(): 29 | def handler(msg): 30 | return 10 31 | 32 | from pymessagebus.default import messagebus as default_messagebus 33 | 34 | default_messagebus.add_handler(EmptyMessage, handler) 35 | message = EmptyMessage() 36 | 37 | handling_result = default_messagebus.handle(message) 38 | assert handling_result == [10] 39 | 40 | test1() 41 | 42 | def test2(): 43 | def handler(msg): 44 | return 20 45 | 46 | from pymessagebus.default import messagebus as default_messagebus 47 | 48 | default_messagebus.add_handler(EmptyMessage, handler) 49 | message = EmptyMessage() 50 | 51 | handling_result = default_messagebus.handle(message) 52 | # Now we should trigger the handler from the first test as well as our new one: 53 | assert handling_result == [10, 20] 54 | 55 | test2() 56 | 57 | 58 | # Let's now focus on its second specificity: the decorator! 59 | 60 | 61 | def test_simplest_decorator(): 62 | with _default_messagebus_unloaded_after_test() as sut: 63 | 64 | @sut.register_handler(EmptyMessage) 65 | def handler(msg): 66 | return "decorated" 67 | 68 | message = EmptyMessage() 69 | handling_result = sut.handle(message) 70 | assert handling_result == ["decorated"] 71 | 72 | 73 | def test_decorator_receives_message(): 74 | with _default_messagebus_unloaded_after_test() as sut: 75 | 76 | class MessageWithPayload(t.NamedTuple): 77 | number: int 78 | 79 | @sut.register_handler(MessageWithPayload) 80 | def add_five(msg): 81 | return msg.number + 5 82 | 83 | message = MessageWithPayload(number=3) 84 | handling_result = sut.handle(message) 85 | assert handling_result == [8] 86 | message = MessageWithPayload(number=18) 87 | handling_result = sut.handle(message) 88 | assert handling_result == [23] 89 | 90 | 91 | def test_multiple_decorators(): 92 | with _default_messagebus_unloaded_after_test() as sut: 93 | 94 | @sut.register_handler(MessageClassOne) 95 | def handler_one(msg): 96 | return 1 97 | 98 | @sut.register_handler(MessageClassOne) 99 | def handler_two(msg): 100 | return 2 101 | 102 | @sut.register_handler(MessageClassTwo) 103 | def handler_three(msg): 104 | return 3 105 | 106 | message_one = MessageClassOne() 107 | handling_result = sut.handle(message_one) 108 | assert handling_result == [1, 2] 109 | 110 | message_two = MessageClassTwo() 111 | handling_result = sut.handle(message_two) 112 | assert handling_result == [3] 113 | 114 | 115 | class EmptyMessage: 116 | pass 117 | 118 | 119 | class MessageClassOne: 120 | pass 121 | 122 | 123 | class MessageClassTwo: 124 | pass 125 | -------------------------------------------------------------------------------- /tests/middleware/logger_test.py: -------------------------------------------------------------------------------- 1 | # pylint: skip-file 2 | 3 | import logging 4 | import random 5 | 6 | import pytest 7 | 8 | from pymessagebus import MessageBus 9 | from pymessagebus.middleware.logger import ( 10 | get_logger_middleware, 11 | LoggingMiddlewareConfig, 12 | ) 13 | 14 | 15 | def test_middleware_basic(caplog): 16 | logger_name = f"{__name__}.{random.randint(1000, 9999)}" 17 | logger = logging.getLogger(logger_name) 18 | 19 | sut = get_logger_middleware(logger) 20 | message_bus = MessageBus(middlewares=[sut]) 21 | message_bus.add_handler(MessageClassOne, get_one) 22 | 23 | message = MessageClassOne() 24 | with caplog.at_level(logging.DEBUG, logger=logger_name): 25 | result = message_bus.handle(message) 26 | assert result == [1] 27 | log_records = caplog.records 28 | 29 | assert len(log_records) == 2 30 | assert ( 31 | log_records[0].msg 32 | == "Message received: $" 33 | ) 34 | assert log_records[0].levelno == logging.DEBUG 35 | assert ( 36 | log_records[1].msg 37 | == "Message succeeded: $" 38 | ) 39 | assert log_records[1].levelno == logging.DEBUG 40 | 41 | 42 | def test_middleware_with_error(caplog): 43 | logger_name = f"{__name__}.{random.randint(1000, 9999)}" 44 | logger = logging.getLogger(logger_name) 45 | 46 | sut = get_logger_middleware(logger) 47 | message_bus = MessageBus(middlewares=[sut]) 48 | message_bus.add_handler(MessageClassOne, errorful_handler) 49 | message = MessageClassOne() 50 | with caplog.at_level(logging.DEBUG, logger=logger_name): 51 | with pytest.raises(RuntimeError): 52 | message_bus.handle(message) 53 | log_records = caplog.records 54 | 55 | assert len(log_records) == 2 56 | assert ( 57 | log_records[0].msg 58 | == "Message received: $" 59 | ) 60 | assert log_records[0].levelno == logging.DEBUG 61 | assert ( 62 | log_records[1].msg 63 | == "Message failed: $" 64 | ) 65 | assert log_records[1].levelno == logging.ERROR 66 | 67 | 68 | def test_middleware_with_custom_log_levels(caplog): 69 | logger_name = f"{__name__}.{random.randint(1000, 9999)}" 70 | logger = logging.getLogger(logger_name) 71 | 72 | sut_config = LoggingMiddlewareConfig( 73 | mgs_received_level=logging.CRITICAL, mgs_succeeded_level=logging.WARNING 74 | ) 75 | sut = get_logger_middleware(logger, sut_config) 76 | message_bus = MessageBus(middlewares=[sut]) 77 | message_bus.add_handler(MessageClassOne, get_one) 78 | 79 | message = MessageClassOne() 80 | with caplog.at_level(logging.DEBUG, logger=logger_name): 81 | message_bus.handle(message) 82 | log_records = caplog.records 83 | 84 | assert ( 85 | log_records[0].msg 86 | == "Message received: $" 87 | ) 88 | assert log_records[0].levelno == logging.CRITICAL 89 | assert ( 90 | log_records[1].msg 91 | == "Message succeeded: $" 92 | ) 93 | assert log_records[1].levelno == logging.WARNING 94 | 95 | 96 | def test_middleware_with_custom_log_levels_with_error(caplog): 97 | logger_name = f"{__name__}.{random.randint(1000, 9999)}" 98 | logger = logging.getLogger(logger_name) 99 | 100 | sut_config = LoggingMiddlewareConfig( 101 | mgs_received_level=logging.WARNING, mgs_failed_level=logging.INFO 102 | ) 103 | sut = get_logger_middleware(logger, sut_config) 104 | message_bus = MessageBus(middlewares=[sut]) 105 | message_bus.add_handler(MessageClassOne, errorful_handler) 106 | 107 | message = MessageClassOne() 108 | with caplog.at_level(logging.DEBUG, logger=logger_name): 109 | with pytest.raises(RuntimeError): 110 | message_bus.handle(message) 111 | log_records = caplog.records 112 | 113 | assert ( 114 | log_records[0].msg 115 | == "Message received: $" 116 | ) 117 | assert log_records[0].levelno == logging.WARNING 118 | assert ( 119 | log_records[1].msg 120 | == "Message failed: $" 121 | ) 122 | assert log_records[1].levelno == logging.INFO 123 | 124 | 125 | class MessageClassOne: 126 | pass 127 | 128 | 129 | def errorful_handler(message: object) -> object: 130 | raise RuntimeError("test error") 131 | 132 | 133 | get_one = lambda _: 1 134 | --------------------------------------------------------------------------------