├── .github ├── dependabot.yml └── workflows │ ├── lint.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── async_signals ├── __init__.py ├── dispatcher.py ├── license.txt ├── py.typed └── utils.py ├── commitlint.config.js ├── justfile ├── pyproject.toml ├── tests ├── test_dispatcher.py └── test_utils.py └── tox.ini /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | assignees: 8 | - "ddanier" 9 | - package-ecosystem: "pip" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | assignees: 14 | - "ddanier" 15 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: "LINT: Run ruff & pyright" 2 | on: 3 | push: 4 | pull_request: 5 | schedule: 6 | - cron: '0 7 * * 1' 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Set up Python 13 | uses: actions/setup-python@v5 14 | with: 15 | python-version: "3.x" 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade poetry 19 | poetry install 20 | - name: Lint with ruff & pyright 21 | run: | 22 | poetry run ruff check async_signals tests 23 | poetry run pyright async_signals 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "RELEASE: Upload Python Package to PyPI" 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | release: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | id-token: write 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Set up Python 13 | uses: actions/setup-python@v5 14 | with: 15 | python-version: '3.x' 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade poetry 19 | poetry install 20 | - name: Build package 21 | run: poetry build 22 | - name: Publish package 23 | uses: pypa/gh-action-pypi-publish@release/v1 24 | with: 25 | password: ${{ secrets.PYPI_API_TOKEN }} 26 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: "TEST: Run pytest using tox" 2 | on: 3 | push: 4 | pull_request: 5 | schedule: 6 | - cron: '0 7 * * 1' 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade poetry 22 | poetry install 23 | - name: Test with pytest 24 | run: | 25 | poetry run tox -e py 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /poetry.lock 2 | /dist/* 3 | /.coverage 4 | /.tox 5 | 6 | __pycache__ 7 | *.pyc 8 | *.pyo 9 | 10 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: end-of-file-fixer 6 | - id: check-added-large-files 7 | - id: check-merge-conflict 8 | - id: check-docstring-first 9 | - id: debug-statements 10 | - repo: https://github.com/astral-sh/ruff-pre-commit 11 | rev: v0.11.7 12 | hooks: 13 | - id: ruff 14 | args: [--fix, --exit-non-zero-on-fix] 15 | - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook 16 | rev: v9.22.0 17 | hooks: 18 | - id: commitlint 19 | stages: [commit-msg] 20 | additional_dependencies: 21 | - "@commitlint/config-conventional" 22 | default_stages: 23 | - pre-commit 24 | default_install_hook_types: 25 | - pre-commit 26 | - commit-msg 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 TEAM23 GmbH and individual contributors. 2 | (Based on work by the Django Software Foundation, Patrick K. O'Brien and 3 | individual contributors, see async_signals/license.txt) 4 | 5 | Redistribution and use in source and binary forms, with or without modification, 6 | are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, 9 | this list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright 12 | notice, this list of conditions and the following disclaimer in the 13 | documentation and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived 17 | from this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 22 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE 23 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 24 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 25 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 26 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 27 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 28 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF 29 | THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # async-signals 2 | 3 | Easy library to implement the observer pattern in async code. 4 | 5 | **Note:** This library is a copy of the signals library from 6 | [Django](https://docs.djangoproject.com/en/4.1/topics/signals/). I always felt 7 | like using the observer pattern in Django is pretty well crafted and liked 8 | the way Django did implement this. But when switching to 9 | [FastAPI](https://fastapi.tiangolo.com/) I missed this feature. So I decided 10 | to copy the signals library from Django and implement it for FastAPI and other 11 | async frameworks. 12 | A big thanks to the nice people why built Django! And for using a BSD license 13 | to make this possible. 14 | 15 | ## Changes from the original Django signals library 16 | 17 | * `Signal.send(...)` and `Signal.send_robust(...)` are now async functions 🚀 18 | * I added type annotations to all functions and classes, mypy is happy now 🧐 19 | * I created tests for the signals library - without using any Django models 😎 20 | 21 | ## Installation 22 | 23 | Just use `pip install async-signals` to install the library. 24 | 25 | ## Usage 26 | 27 | ```python 28 | from async_signals import Signal 29 | 30 | # Create a signal 31 | my_signal = Signal() 32 | 33 | # Connect a function to the signal (can be async or sync, needs to receive **kwargs) 34 | async def my_handler(sender, **kwargs): 35 | print("Signal received!") 36 | 37 | my_signal.connect(my_handler) 38 | 39 | # Send the signal 40 | await my_signal.send("sender") 41 | ``` 42 | 43 | `signal.send(...)` will return a list of all called receivers and their return 44 | values. 45 | 46 | ## About **kwargs 47 | 48 | The `**kwargs` are mandatory for your receivers. This is because the signal 49 | will pass any arguments it receives to the receivers. This is useful if you 50 | want to pass additional information to the receivers. To allow adding 51 | additional arguments to the signal in the future, the receivers should is 52 | required to accept `**kwargs`. 53 | 54 | ## About weak signals 55 | 56 | The signal class will automatically remove signals when the receiver is 57 | garbage collected. This is done by using weak references. This means that 58 | you can use signals in long running applications without having to worry 59 | about memory leaks. 60 | 61 | If you want to disable this behaviour you can set the `weak` parameter to 62 | `False` when connecting the receiver. 63 | 64 | ```python 65 | my_signal.connect(my_handler, weak=False) 66 | 67 | # or 68 | 69 | my_signal.connect(my_handler, weak=True) # the default 70 | ``` 71 | 72 | ## About async signals 73 | 74 | The signal class will automatically await async receivers. If your receiver 75 | is sync it will be executed normally. 76 | 77 | ## About the sender 78 | 79 | The sender is the object that sends the signal. It can be anything. It is 80 | passed to the receiver as the first argument. This is useful if you want to 81 | have multiple signals in your application and you want to know which signal 82 | was sent. Normally the sender is the object that triggers the signal. 83 | 84 | You may also pass the sender when connecting a receiver. This is useful if 85 | you want to connect a receiver to a specific sender. If you do this the 86 | receiver will only be called when the sender is the same as the one you 87 | passed when connecting the receiver. 88 | 89 | **Note:** I normally tend to use Pydantic models as the sender in FastAPI. But 90 | feel free to use whatever you want. 91 | 92 | ```python 93 | my_signal.connect(my_handler, sender="sender") 94 | 95 | # This will not call the receiver 96 | await my_signal.send("other_sender") 97 | ``` 98 | 99 | ## Using the receiver decorator 100 | 101 | You can also use the `receiver` decorator to connect a receiver to a signal. 102 | 103 | ```python 104 | @receiver(my_signal) 105 | async def my_handler(sender, **kwargs): 106 | print("Signal received!") 107 | ``` 108 | 109 | Or if you want to limit the receiver to a specific sender. 110 | 111 | ```python 112 | @receiver(my_signal, sender="sender") 113 | async def my_handler(sender, **kwargs): 114 | print("Signal received!") 115 | ``` 116 | 117 | ## Handle exceptions 118 | 119 | By default the signal class will raise exceptions raised by receivers. If 120 | you want the signal to catch the exceptions and continue to call the other 121 | receivers you can use `send_robust(..)` instead of `send()`. The return value 122 | will be a list of tuples containing the receiver and the return or the 123 | exception raised by the receiver. You will need to check the type of the 124 | return value to see if it is an exception or not. 125 | 126 | ```python 127 | await my_signal.send_robust("sender") 128 | ``` 129 | 130 | # Contributing 131 | 132 | If you want to contribute to this project, feel free to just fork the project, 133 | create a dev branch in your fork and then create a pull request (PR). If you 134 | are unsure about whether your changes really suit the project please create an 135 | issue first, to talk about this. 136 | -------------------------------------------------------------------------------- /async_signals/__init__.py: -------------------------------------------------------------------------------- 1 | """Multi-consumer multi-producer dispatching mechanism 2 | 3 | Originally based on pydispatch (BSD) https://pypi.org/project/PyDispatcher/2.0.1/ 4 | See license.txt for original license. 5 | 6 | Heavily modified for Django's purposes. 7 | 8 | And again modified to be fully async. 9 | """ 10 | 11 | from .dispatcher import Signal as Signal 12 | from .dispatcher import receiver as receiver 13 | -------------------------------------------------------------------------------- /async_signals/dispatcher.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import threading 4 | import weakref 5 | from collections.abc import Hashable 6 | from typing import Any, Callable, Optional, Union 7 | 8 | from .utils import func_accepts_kwargs 9 | 10 | logger = logging.getLogger("async_signals.dispatch") 11 | 12 | 13 | def _make_id( 14 | target: Union[Hashable, Callable, None], 15 | ) -> Union[Hashable, tuple[Hashable, ...]]: 16 | if hasattr(target, "__self__") and hasattr(target, "__func__"): 17 | return id(target.__self__), id(target.__func__) # type: ignore 18 | return id(target) 19 | 20 | 21 | NONE_ID = _make_id(None) 22 | 23 | # A marker for caching 24 | NO_RECEIVERS = object() 25 | 26 | 27 | class Signal: 28 | """ 29 | Base class for all signals 30 | 31 | Internal attributes: 32 | 33 | receivers 34 | { receiverkey (id) : weakref(receiver) } 35 | """ 36 | 37 | receivers: list[tuple[tuple[Hashable, Hashable], Callable]] 38 | use_caching: bool 39 | debug: bool 40 | 41 | def __init__( 42 | self, 43 | use_caching: bool = False, 44 | debug: bool = False, 45 | ) -> None: 46 | """ 47 | Create a new signal. 48 | """ 49 | 50 | self.receivers = [] 51 | self.lock = threading.Lock() 52 | self.use_caching = use_caching 53 | self.debug = debug 54 | # For convenience we create empty caches even if they are not used. 55 | # A note about caching: if use_caching is defined, then for each 56 | # distinct sender we cache the receivers that sender has in 57 | # 'sender_receivers_cache'. The cache is cleaned when .connect() or 58 | # .disconnect() is called and populated on send(). 59 | self.sender_receivers_cache: Union[weakref.WeakKeyDictionary, dict] \ 60 | = weakref.WeakKeyDictionary() if use_caching else {} 61 | self._dead_receivers = False 62 | 63 | def connect( 64 | self, 65 | receiver: Callable, 66 | sender: Optional[Hashable] = None, 67 | weak: bool = True, 68 | dispatch_uid: Optional[Hashable] = None, 69 | ) -> None: 70 | """ 71 | Connect receiver to sender for signal. 72 | 73 | Arguments: 74 | 75 | receiver 76 | A function or an instance method which is to receive signals. 77 | Receivers must be hashable objects. 78 | 79 | If weak is True, then receiver must be weak referenceable. 80 | 81 | Receivers must be able to accept keyword arguments. 82 | 83 | If a receiver is connected with a dispatch_uid argument, it 84 | will not be added if another receiver was already connected 85 | with that dispatch_uid. 86 | 87 | sender 88 | The sender to which the receiver should respond. Must either be 89 | a Python object, or None to receive events from any sender. 90 | 91 | weak 92 | Whether to use weak references to the receiver. By default, the 93 | module will attempt to use weak references to the receiver 94 | objects. If this parameter is false, then strong references will 95 | be used. 96 | 97 | dispatch_uid 98 | An identifier used to uniquely identify a particular instance of 99 | a receiver. This will usually be a string, though it may be 100 | anything hashable. 101 | """ 102 | 103 | # If debug is on, check that we got a good receiver 104 | if self.debug: 105 | if not callable(receiver): 106 | raise TypeError("Signal receivers must be callable.") 107 | # Check for **kwargs 108 | if not func_accepts_kwargs(receiver): 109 | raise ValueError( 110 | "Signal receivers must accept keyword arguments (**kwargs).", 111 | ) 112 | 113 | if dispatch_uid: 114 | lookup_key = (dispatch_uid, _make_id(sender)) 115 | else: 116 | lookup_key = (_make_id(receiver), _make_id(sender)) 117 | 118 | if weak: 119 | ref: Union[type[weakref.WeakMethod[Any]], type[weakref.ReferenceType[Any]]] \ 120 | = weakref.ref 121 | receiver_object = receiver 122 | # Check for bound methods 123 | if hasattr(receiver, "__self__") and hasattr(receiver, "__func__"): 124 | ref = weakref.WeakMethod 125 | receiver_object = receiver.__self__ # type: ignore 126 | receiver = ref(receiver) 127 | weakref.finalize(receiver_object, self._remove_receiver) 128 | 129 | with self.lock: 130 | self._clear_dead_receivers() 131 | if not any(r_key == lookup_key for r_key, _ in self.receivers): 132 | self.receivers.append((lookup_key, receiver)) 133 | self.sender_receivers_cache.clear() 134 | 135 | def disconnect( 136 | self, 137 | receiver: Optional[Callable] = None, 138 | sender: Optional[Hashable] = None, 139 | dispatch_uid: Optional[Hashable] = None, 140 | ) -> bool: 141 | """ 142 | Disconnect receiver from sender for signal. 143 | 144 | If weak references are used, disconnect need not be called. The receiver 145 | will be removed from dispatch automatically. 146 | 147 | Arguments: 148 | 149 | receiver 150 | The registered receiver to disconnect. May be none if 151 | dispatch_uid is specified. 152 | 153 | sender 154 | The registered sender to disconnect 155 | 156 | dispatch_uid 157 | the unique identifier of the receiver to disconnect 158 | """ 159 | 160 | if dispatch_uid: 161 | lookup_key = (dispatch_uid, _make_id(sender)) 162 | else: 163 | lookup_key = (_make_id(receiver), _make_id(sender)) 164 | 165 | disconnected = False 166 | with self.lock: 167 | self._clear_dead_receivers() 168 | for index in range(len(self.receivers)): 169 | (r_key, _) = self.receivers[index] 170 | if r_key == lookup_key: 171 | disconnected = True 172 | del self.receivers[index] 173 | break 174 | self.sender_receivers_cache.clear() 175 | return disconnected 176 | 177 | def has_listeners(self, sender: Union[Hashable, None] = None) -> bool: 178 | return bool(self._live_receivers(sender)) 179 | 180 | @classmethod 181 | async def _call_receiver( 182 | cls, 183 | receiver: Callable, 184 | signal: "Signal", 185 | sender: Hashable, 186 | **named: Any, 187 | ) -> Any: 188 | if asyncio.iscoroutinefunction(receiver): 189 | return await receiver( 190 | signal=signal, 191 | sender=sender, 192 | **named, 193 | ) 194 | else: 195 | return receiver( 196 | signal=signal, 197 | sender=sender, 198 | **named, 199 | ) 200 | 201 | async def send( 202 | self, 203 | sender: Hashable, 204 | **named: Any, 205 | ) -> list[tuple[Callable, Any]]: 206 | """ 207 | Send signal from sender to all connected receivers. 208 | 209 | If any receiver raises an error, the error propagates back through send, 210 | terminating the dispatch loop. So it's possible that all receivers 211 | won't be called if an error is raised. 212 | 213 | Arguments: 214 | 215 | sender 216 | The sender of the signal. Either a specific object or None. 217 | 218 | named 219 | Named arguments which will be passed to receivers. 220 | 221 | Return a list of tuple pairs [(receiver, response), ... ]. 222 | """ 223 | 224 | if ( 225 | not self.receivers 226 | or self.sender_receivers_cache.get(sender) is NO_RECEIVERS 227 | ): 228 | return [] 229 | 230 | return [ 231 | ( 232 | receiver, 233 | await self._call_receiver( 234 | receiver=receiver, 235 | signal=self, 236 | sender=sender, 237 | **named, 238 | ), 239 | ) 240 | for receiver in self._live_receivers(sender) 241 | ] 242 | 243 | async def send_robust( 244 | self, 245 | sender: Hashable, 246 | **named: Any, 247 | ) -> list[tuple[Callable, Any]]: 248 | """ 249 | Send signal from sender to all connected receivers catching errors. 250 | 251 | Arguments: 252 | 253 | sender 254 | The sender of the signal. Can be any Python object (normally one 255 | registered with a connect if you actually want something to 256 | occur). 257 | 258 | named 259 | Named arguments which will be passed to receivers. 260 | 261 | Return a list of tuple pairs [(receiver, response), ... ]. 262 | 263 | If any receiver raises an error (specifically any subclass of 264 | Exception), return the error instance as the result for that receiver. 265 | """ 266 | 267 | if ( 268 | not self.receivers 269 | or self.sender_receivers_cache.get(sender) is NO_RECEIVERS 270 | ): 271 | return [] 272 | 273 | # Call each receiver with whatever arguments it can accept. 274 | # Return a list of tuple pairs [(receiver, response), ... ]. 275 | responses = [] 276 | for receiver in self._live_receivers(sender): 277 | try: 278 | response = await self._call_receiver( 279 | receiver=receiver, 280 | signal=self, 281 | sender=sender, 282 | **named, 283 | ) 284 | except Exception as err: 285 | logger.error( 286 | "Error calling %s in Signal.send_robust() (%s)", 287 | receiver.__qualname__, 288 | err, 289 | exc_info=err, 290 | ) 291 | responses.append((receiver, err)) 292 | else: 293 | responses.append((receiver, response)) 294 | return responses 295 | 296 | def _clear_dead_receivers(self) -> None: 297 | # Note: caller is assumed to hold self.lock. 298 | if self._dead_receivers: 299 | self._dead_receivers = False 300 | self.receivers = [ 301 | r 302 | for r in self.receivers 303 | if not (isinstance(r[1], weakref.ReferenceType) and r[1]() is None) 304 | ] 305 | 306 | def _live_receivers(self, sender: Union[Hashable, None]) -> list[Callable]: 307 | """ 308 | Filter sequence of receivers to get resolved, live receivers. 309 | 310 | This checks for weak references and resolves them, then returning only 311 | live receivers. 312 | """ 313 | 314 | receivers = None 315 | if self.use_caching and not self._dead_receivers and sender is not None: 316 | receivers = self.sender_receivers_cache.get(sender) 317 | # We could end up here with NO_RECEIVERS even if we do check this case in 318 | # .send() prior to calling _live_receivers() due to concurrent .send() call. 319 | if receivers is NO_RECEIVERS: 320 | return [] 321 | if receivers is None: 322 | with self.lock: 323 | self._clear_dead_receivers() 324 | senderkey = _make_id(sender) 325 | receivers = [] 326 | for (_receiverkey, r_senderkey), receiver in self.receivers: 327 | if r_senderkey == NONE_ID or r_senderkey == senderkey: 328 | receivers.append(receiver) 329 | if self.use_caching and sender is not None: 330 | if not receivers: 331 | self.sender_receivers_cache[sender] = NO_RECEIVERS 332 | else: 333 | # Note, we must cache the weakref versions. 334 | self.sender_receivers_cache[sender] = receivers 335 | non_weak_receivers = [] 336 | for receiver in receivers: 337 | if isinstance(receiver, weakref.ReferenceType): 338 | # Dereference the weak reference. 339 | receiver = receiver() 340 | if receiver is not None: 341 | non_weak_receivers.append(receiver) 342 | else: 343 | non_weak_receivers.append(receiver) 344 | return non_weak_receivers 345 | 346 | def _remove_receiver(self, receiver: Optional[Callable] = None) -> None: # noqa: ARG002 347 | # Mark that the self.receivers list has dead weakrefs. If so, we will 348 | # clean those up in connect, disconnect and _live_receivers while 349 | # holding self.lock. Note that doing the cleanup here isn't a good 350 | # idea, _remove_receiver() will be called as side effect of garbage 351 | # collection, and so the call can happen while we are already holding 352 | # self.lock. 353 | self._dead_receivers = True 354 | 355 | 356 | def receiver( 357 | signal: Union[Signal, list[Signal], tuple[Signal, ...]], 358 | *, 359 | sender: Optional[Hashable] = None, 360 | weak: bool = True, 361 | dispatch_uid: Optional[Hashable] = None, 362 | ) -> Callable: 363 | """ 364 | A decorator for connecting receivers to signals. Used by passing in the 365 | signal (or list of signals) and keyword arguments to connect:: 366 | 367 | @receiver(post_save, sender=MyModel) 368 | def signal_receiver(sender, **kwargs): 369 | ... 370 | 371 | @receiver([post_save, post_delete], sender=MyModel) 372 | def signals_receiver(sender, **kwargs): 373 | ... 374 | """ 375 | 376 | def _decorator(func: Callable) -> Callable: 377 | if isinstance(signal, (list, tuple)): 378 | for s in signal: 379 | s.connect( 380 | func, 381 | sender=sender, 382 | weak=weak, 383 | dispatch_uid=dispatch_uid, 384 | ) 385 | else: 386 | signal.connect( 387 | func, 388 | sender=sender, 389 | weak=weak, 390 | dispatch_uid=dispatch_uid, 391 | ) 392 | return func 393 | 394 | return _decorator 395 | -------------------------------------------------------------------------------- /async_signals/license.txt: -------------------------------------------------------------------------------- 1 | async_signals.dispatcher was forked from django.dispatcher which was 2 | originally forked from PyDispatcher. 3 | 4 | Django License: 5 | 6 | Copyright (c) Django Software Foundation and individual contributors. 7 | All rights reserved. 8 | 9 | Redistribution and use in source and binary forms, with or without modification, 10 | are permitted provided that the following conditions are met: 11 | 12 | 1. Redistributions of source code must retain the above copyright notice, 13 | this list of conditions and the following disclaimer. 14 | 15 | 2. Redistributions in binary form must reproduce the above copyright 16 | notice, this list of conditions and the following disclaimer in the 17 | documentation and/or other materials provided with the distribution. 18 | 19 | 3. Neither the name of Django nor the names of its contributors may be used 20 | to endorse or promote products derived from this software without 21 | specific prior written permission. 22 | 23 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 24 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 25 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 26 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 27 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 28 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 29 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 30 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 31 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 32 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 33 | 34 | PyDispatcher License: 35 | 36 | Copyright (c) 2001-2003, Patrick K. O'Brien and Contributors 37 | All rights reserved. 38 | 39 | Redistribution and use in source and binary forms, with or without 40 | modification, are permitted provided that the following conditions 41 | are met: 42 | 43 | Redistributions of source code must retain the above copyright 44 | notice, this list of conditions and the following disclaimer. 45 | 46 | Redistributions in binary form must reproduce the above 47 | copyright notice, this list of conditions and the following 48 | disclaimer in the documentation and/or other materials 49 | provided with the distribution. 50 | 51 | The name of Patrick K. O'Brien, or the name of any Contributor, 52 | may not be used to endorse or promote products derived from this 53 | software without specific prior written permission. 54 | 55 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 56 | ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 57 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 58 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 59 | COPYRIGHT HOLDERS AND CONTRIBUTORS BE LIABLE FOR ANY DIRECT, 60 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 61 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 62 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 63 | HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 64 | STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 65 | ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED 66 | OF THE POSSIBILITY OF SUCH DAMAGE. 67 | -------------------------------------------------------------------------------- /async_signals/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/team23/async-signals/405007bb9e9de883e0f799488ca09bed0222f9bf/async_signals/py.typed -------------------------------------------------------------------------------- /async_signals/utils.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import inspect 3 | from typing import Callable 4 | 5 | # Those functions are copied from 6 | # https://github.com/django/django/blob/main/django/utils/inspect.py 7 | 8 | 9 | @functools.lru_cache(maxsize=512) 10 | def _get_func_parameters( 11 | func: Callable, 12 | remove_first: bool, 13 | ) -> tuple[inspect.Parameter, ...]: 14 | parameters = tuple(inspect.signature(func).parameters.values()) 15 | if remove_first: 16 | parameters = parameters[1:] 17 | return parameters 18 | 19 | 20 | def _get_callable_parameters( 21 | meth_or_func: Callable, 22 | ) -> tuple[inspect.Parameter, ...]: 23 | is_method = inspect.ismethod(meth_or_func) 24 | func = meth_or_func.__func__ if is_method else meth_or_func # type: ignore 25 | return _get_func_parameters(func, remove_first=is_method) 26 | 27 | 28 | def func_accepts_kwargs(func: Callable) -> bool: 29 | """Return True if function 'func' accepts keyword arguments **kwargs.""" 30 | 31 | return any(p for p in _get_callable_parameters(func) if p.kind == p.VAR_KEYWORD) 32 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // See https://github.com/conventional-changelog/commitlint/blob/master/%40commitlint/config-conventional/index.js 3 | extends: ['@commitlint/config-conventional'], 4 | // Own rules 5 | rules: { 6 | 'subject-case': [ 7 | 2, 8 | 'never', 9 | ['start-case', 'pascal-case', 'upper-case'], 10 | ], 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | default: 2 | just --list 3 | 4 | [unix] 5 | _install-pre-commit: 6 | #!/usr/bin/env bash 7 | if ( which pre-commit > /dev/null 2>&1 ) 8 | then 9 | pre-commit install --install-hooks 10 | else 11 | echo "-----------------------------------------------------------------" 12 | echo "pre-commit is not installed - cannot enable pre-commit hooks!" 13 | echo "Recommendation: Install pre-commit ('brew install pre-commit')." 14 | echo "-----------------------------------------------------------------" 15 | fi 16 | 17 | [windows] 18 | _install-pre-commit: 19 | #!powershell.exe 20 | Write-Host "Please ensure pre-commit hooks are installed using 'pre-commit install --install-hooks'" 21 | 22 | install: (poetry "install") && _install-pre-commit 23 | 24 | update: (poetry "install") 25 | 26 | poetry *args: 27 | poetry {{args}} 28 | 29 | test *args: (poetry "run" "pytest" "--cov=async_signals" "--cov-report" "term-missing:skip-covered" args) 30 | 31 | test-all: (poetry "run" "tox") 32 | 33 | ruff *args: (poetry "run" "ruff" "check" "async_signals" "tests" args) 34 | 35 | pyright *args: (poetry "run" "pyright" "async_signals" args) 36 | 37 | lint: ruff pyright 38 | 39 | publish: (poetry "publish" "--build") 40 | 41 | release version: (poetry "version" version) 42 | git add pyproject.toml 43 | git commit -m "release: 🔖 v$(poetry version --short)" --no-verify 44 | git tag "v$(poetry version --short)" 45 | git push 46 | git push --tags 47 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "async-signals" 3 | version = "0.2.0" 4 | description = "Async version of the Django signals class - for usage in for example FastAPI." 5 | authors = ["TEAM23 GmbH "] 6 | license = "BSD-3-Clause" 7 | repository = "https://github.com/team23/async-signals" 8 | readme = "README.md" 9 | packages = [{include = "async_signals"}] 10 | 11 | [tool.poetry.dependencies] 12 | python = "^3.9" 13 | 14 | [tool.poetry.group.dev.dependencies] 15 | pytest = ">=7.1.3,<9.0.0" 16 | pytest-cov = ">=3,<7" 17 | pytest-mock = "^3.8.2" 18 | anyio = {extras = ["trio"], version = ">=3.6.1,<5.0.0"} 19 | tox = ">=3.26,<5.0" 20 | ruff = ">=0.5.0,<0.12.0" 21 | pyright = ">=1.1.350,<1.2" 22 | 23 | [tool.ruff] 24 | line-length = 115 25 | target-version = "py39" 26 | output-format = "grouped" 27 | 28 | [tool.ruff.lint] 29 | select = ["F","E","W","C","I","N","UP","ANN","S","B","A","COM","C4","T20","PT","ARG","TD","RUF"] 30 | ignore = ["A001","A002","A003","ANN101","ANN102","ANN401","C901","N8","B008","F405","F821"] 31 | 32 | [tool.ruff.lint.per-file-ignores] 33 | "__init__.py" = ["F401"] 34 | "conftest.py" = ["S101","ANN","F401"] 35 | "test_*.py" = ["S101","ANN","F401","ARG001"] 36 | 37 | [build-system] 38 | requires = ["poetry-core"] 39 | build-backend = "poetry.core.masonry.api" 40 | -------------------------------------------------------------------------------- /tests/test_dispatcher.py: -------------------------------------------------------------------------------- 1 | import gc 2 | import re 3 | 4 | import pytest 5 | 6 | from async_signals import Signal, receiver 7 | 8 | 9 | @pytest.fixture 10 | def signal() -> Signal: 11 | return Signal() 12 | 13 | 14 | @pytest.fixture 15 | def signal2() -> Signal: 16 | return Signal() 17 | 18 | 19 | @pytest.fixture 20 | def debug_signal() -> Signal: 21 | return Signal(debug=True) 22 | 23 | 24 | @pytest.fixture 25 | def cached_signal() -> Signal: 26 | return Signal(use_caching=True) 27 | 28 | 29 | def test_signal_connect_disconnect(signal: Signal): 30 | def receiver_function(**kwargs): 31 | pass 32 | 33 | assert len(signal.receivers) == 0 34 | 35 | signal.connect(receiver_function) 36 | assert signal.has_listeners() 37 | assert len(signal.receivers) == 1 38 | 39 | signal.disconnect(receiver_function) 40 | assert not signal.has_listeners() 41 | assert len(signal.receivers) == 0 42 | 43 | 44 | def test_signal_connect_weakref_removal(signal: Signal): 45 | def receiver_function(**kwargs): 46 | pass 47 | 48 | assert len(signal.receivers) == 0 49 | 50 | signal.connect(receiver_function) 51 | assert signal.has_listeners() 52 | assert len(signal.receivers) == 1 53 | 54 | del receiver_function 55 | gc.collect() 56 | assert not signal.has_listeners() 57 | assert len(signal.receivers) == 0 58 | 59 | 60 | def test_signal_connect_with_weakref_is_false(signal: Signal): 61 | def receiver_function(**kwargs): 62 | pass 63 | 64 | assert len(signal.receivers) == 0 65 | 66 | signal.connect(receiver_function, weak=False) 67 | assert signal.has_listeners() 68 | assert len(signal.receivers) == 1 69 | 70 | del receiver_function 71 | gc.collect() 72 | assert signal.has_listeners() 73 | assert len(signal.receivers) == 1 74 | 75 | 76 | def test_signal_connect_disconnect_for_methods(signal: Signal): 77 | class Receiver: 78 | def handler(self, **kwargs): pass 79 | 80 | receiver_obj = Receiver() 81 | 82 | assert len(signal.receivers) == 0 83 | 84 | signal.connect(receiver_obj.handler) 85 | assert signal.has_listeners() 86 | assert len(signal.receivers) == 1 87 | 88 | signal.disconnect(receiver_obj.handler) 89 | assert not signal.has_listeners() 90 | assert len(signal.receivers) == 0 91 | 92 | 93 | def test_signal_connect_disconnect_with_dispatch_uid(signal: Signal): 94 | def receiver_function(**kwargs): 95 | pass 96 | 97 | dispatch_uid = "some_id" 98 | 99 | assert len(signal.receivers) == 0 100 | 101 | signal.connect(receiver_function, dispatch_uid=dispatch_uid) 102 | assert signal.has_listeners() 103 | assert len(signal.receivers) == 1 104 | 105 | signal.disconnect(receiver_function, dispatch_uid=dispatch_uid) 106 | assert not signal.has_listeners() 107 | assert len(signal.receivers) == 0 108 | 109 | 110 | def test_signal_connect_disconnect_with_caching(cached_signal: Signal): 111 | def receiver_function(**kwargs): 112 | pass 113 | 114 | assert len(cached_signal.receivers) == 0 115 | 116 | cached_signal.connect(receiver_function) 117 | assert cached_signal.has_listeners() 118 | assert len(cached_signal.receivers) == 1 119 | 120 | cached_signal.disconnect(receiver_function) 121 | assert not cached_signal.has_listeners() 122 | assert len(cached_signal.receivers) == 0 123 | 124 | 125 | def test_signal_connect_disconnect_with_caching_and_sender_name(cached_signal: Signal): 126 | def receiver_function(**kwargs): 127 | pass 128 | 129 | assert len(cached_signal.receivers) == 0 130 | 131 | cached_signal.connect(receiver_function) 132 | assert cached_signal.has_listeners(test_signal_connect_disconnect_with_caching_and_sender_name) 133 | assert len(cached_signal.receivers) == 1 134 | 135 | cached_signal.disconnect(receiver_function) 136 | assert not cached_signal.has_listeners(test_signal_connect_disconnect_with_caching_and_sender_name) 137 | assert len(cached_signal.receivers) == 0 138 | 139 | 140 | def test_signal_with_debug_ensures_callable(debug_signal: Signal): 141 | with pytest.raises(TypeError): 142 | debug_signal.connect("invalid") # type: ignore 143 | 144 | 145 | def test_signal_with_debug_ensures_kwargs(debug_signal: Signal): 146 | def invalid_receiver_function(): 147 | pass 148 | 149 | with pytest.raises( 150 | ValueError, 151 | match=re.escape("Signal receivers must accept keyword arguments (**kwargs)."), 152 | ): 153 | debug_signal.connect(invalid_receiver_function) 154 | 155 | 156 | @pytest.mark.anyio 157 | async def test_signal_send_without_receivers(signal: Signal, mocker): 158 | result = await signal.send(sender=test_signal_send_without_receivers, x="a", y="b") 159 | 160 | assert len(result) == 0 161 | 162 | 163 | @pytest.mark.anyio 164 | async def test_signal_send_without_receivers_and_caching(signal: Signal, mocker): 165 | signal.use_caching = True 166 | 167 | result = await signal.send(sender=test_signal_send_without_receivers, x="a", y="b") 168 | 169 | assert len(result) == 0 170 | 171 | 172 | @pytest.mark.anyio 173 | async def test_signal_send_sync(signal: Signal, mocker): 174 | receiver_function = mocker.Mock() 175 | 176 | signal.connect(receiver_function) 177 | 178 | result = await signal.send(sender=test_signal_send_sync, x="a", y="b") 179 | 180 | assert len(result) == 1 181 | receiver_function.assert_called_once_with( 182 | sender=test_signal_send_sync, 183 | signal=signal, 184 | x="a", 185 | y="b", 186 | ) 187 | 188 | 189 | @pytest.mark.anyio 190 | async def test_signal_send_async(signal: Signal, mocker): 191 | receiver_function = mocker.AsyncMock() 192 | 193 | signal.connect(receiver_function) 194 | 195 | result = await signal.send(sender=test_signal_send_async, x="a", y="b") 196 | 197 | assert len(result) == 1 198 | receiver_function.assert_called_once_with( 199 | sender=test_signal_send_async, 200 | signal=signal, 201 | x="a", 202 | y="b", 203 | ) 204 | 205 | 206 | @pytest.mark.anyio 207 | async def test_signal_send_will_raise_exception(signal: Signal, mocker): 208 | receiver_function = mocker.AsyncMock( 209 | side_effect=RuntimeError("Boom!"), 210 | ) 211 | 212 | signal.connect(receiver_function) 213 | 214 | with pytest.raises(RuntimeError, match="Boom!"): 215 | await signal.send(sender=test_signal_send_will_raise_exception, x="a", y="b") 216 | 217 | 218 | @pytest.mark.anyio 219 | async def test_signal_send_robust_without_receivers(signal: Signal, mocker): 220 | result = await signal.send_robust(sender=test_signal_send_without_receivers, x="a", y="b") 221 | 222 | assert len(result) == 0 223 | 224 | 225 | @pytest.mark.anyio 226 | async def test_signal_send_robust_works_normally(signal: Signal, mocker): 227 | receiver_function = mocker.AsyncMock() 228 | 229 | signal.connect(receiver_function) 230 | 231 | result = await signal.send_robust(sender=test_signal_send_async, x="a", y="b") 232 | 233 | assert len(result) == 1 234 | receiver_function.assert_called_once_with( 235 | sender=test_signal_send_async, 236 | signal=signal, 237 | x="a", 238 | y="b", 239 | ) 240 | 241 | 242 | @pytest.mark.anyio 243 | async def test_signal_send_robust_will_catch_exception(signal: Signal, mocker): 244 | receiver_function = mocker.AsyncMock( 245 | side_effect=Exception("Boom!"), 246 | __qualname__="receiver_function", 247 | ) 248 | 249 | signal.connect(receiver_function) 250 | 251 | await signal.send_robust(sender=test_signal_send_robust_will_catch_exception, x="a", y="b") 252 | 253 | 254 | def test_receiver(signal: Signal): 255 | @receiver(signal) 256 | def receiver_function(**kwargs): 257 | pass 258 | 259 | assert len(signal.receivers) == 1 260 | 261 | 262 | def test_receiver_for_signal_list(signal: Signal, signal2: Signal): 263 | @receiver([signal, signal2]) 264 | def receiver_function(**kwargs): 265 | pass 266 | 267 | assert len(signal.receivers) == 1 268 | assert len(signal2.receivers) == 1 269 | 270 | 271 | @pytest.mark.anyio 272 | async def test_receivers_only_called_when_sender_matches(signal: Signal, mocker): 273 | receiver_function1 = mocker.AsyncMock() 274 | receiver_function2 = mocker.AsyncMock() 275 | 276 | signal.connect(receiver_function1, sender="sender1") 277 | signal.connect(receiver_function2, sender="sender2") 278 | 279 | await signal.send("sender1") 280 | 281 | receiver_function1.assert_called_once() 282 | receiver_function2.assert_not_called() 283 | receiver_function1.reset_mock() 284 | receiver_function2.reset_mock() 285 | 286 | await signal.send("sender2") 287 | 288 | receiver_function1.assert_not_called() 289 | receiver_function2.assert_called_once() 290 | receiver_function1.reset_mock() 291 | receiver_function2.reset_mock() 292 | 293 | await signal.send("sender3") 294 | 295 | receiver_function1.assert_not_called() 296 | receiver_function2.assert_not_called() 297 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from async_signals.utils import _get_callable_parameters, _get_func_parameters, func_accepts_kwargs 2 | 3 | 4 | def the_valid_function(arg1, arg2, **kwargs): 5 | pass 6 | 7 | 8 | def the_invalid_function(arg1, arg2): 9 | pass 10 | 11 | 12 | class TheTestClass: 13 | def the_valid_method(self, arg1, arg2, **kwargs): 14 | pass 15 | 16 | def the_invalid_method(self, arg1, arg2): 17 | pass 18 | 19 | 20 | the_test_instance = TheTestClass() 21 | 22 | 23 | def test_get_func_parameters(): 24 | params = _get_func_parameters(the_valid_function, remove_first=False) 25 | assert len(params) == 3 26 | 27 | params = _get_func_parameters(the_invalid_function, remove_first=False) 28 | assert len(params) == 2 29 | 30 | params = _get_func_parameters(TheTestClass.the_valid_method, remove_first=False) 31 | assert len(params) == 4 32 | 33 | params = _get_func_parameters(TheTestClass.the_invalid_method, remove_first=False) 34 | assert len(params) == 3 35 | 36 | params = _get_func_parameters(TheTestClass.the_valid_method, remove_first=True) 37 | assert len(params) == 3 38 | 39 | params = _get_func_parameters(TheTestClass.the_invalid_method, remove_first=True) 40 | assert len(params) == 2 41 | 42 | 43 | def test_get_callable_parameters(): 44 | params = _get_callable_parameters(the_valid_function) 45 | assert len(params) == 3 46 | 47 | params = _get_callable_parameters(the_invalid_function) 48 | assert len(params) == 2 49 | 50 | params = _get_callable_parameters(the_test_instance.the_valid_method) 51 | assert len(params) == 3 52 | 53 | params = _get_callable_parameters(the_test_instance.the_invalid_method) 54 | assert len(params) == 2 55 | 56 | 57 | def test_func_accepts_kwargs(): 58 | assert func_accepts_kwargs(the_valid_function) 59 | assert not func_accepts_kwargs(the_invalid_function) 60 | assert func_accepts_kwargs(the_test_instance.the_valid_method) 61 | assert not func_accepts_kwargs(the_test_instance.the_invalid_method) 62 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | isolated_build = True 3 | envlist = 4 | py39, 5 | py310, 6 | py311, 7 | py312, 8 | py313 9 | 10 | [testenv] 11 | deps = 12 | pytest 13 | anyio[trio] 14 | pytest-anyio 15 | pytest-mock 16 | commands = pytest 17 | --------------------------------------------------------------------------------