├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.md │ ├── config.yml │ └── feature-request.md ├── pull_request_template.md └── workflows │ ├── lock.yaml │ ├── pre-commit.yaml │ ├── publish.yaml │ └── tests.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── CHANGES.rst ├── LICENSE.txt ├── README.md ├── docs ├── _static │ └── blinker-named.png ├── conf.py └── index.rst ├── pyproject.toml ├── src └── blinker │ ├── __init__.py │ ├── _utilities.py │ ├── base.py │ └── py.typed ├── tests ├── test_context.py ├── test_signals.py └── test_symbol.py └── uv.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | end_of_line = lf 9 | charset = utf-8 10 | max_line_length = 88 11 | 12 | [*.{css,html,js,json,jsx,scss,ts,tsx,yaml,yml}] 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug in Blinker (not other projects which depend on Blinker) 4 | --- 5 | 6 | 12 | 13 | 19 | 20 | 23 | 24 | Python version: 25 | Blinker version: 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Questions on Discussions 4 | url: https://github.com/pallets/blinker/discussions/ 5 | about: Ask questions about your own code on the Discussions tab. 6 | - name: Questions on Chat 7 | url: https://discord.gg/pallets 8 | about: Ask questions about your own code on our Discord chat. 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest a new feature for Blinker 4 | --- 5 | 6 | 10 | 11 | 16 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 9 | 10 | 16 | 17 | 26 | -------------------------------------------------------------------------------- /.github/workflows/lock.yaml: -------------------------------------------------------------------------------- 1 | name: Lock inactive closed issues 2 | # Lock closed issues that have not received any further activity for two weeks. 3 | # This does not close open issues, only humans may do that. It is easier to 4 | # respond to new issues with fresh examples rather than continuing discussions 5 | # on old issues. 6 | 7 | on: 8 | schedule: 9 | - cron: '0 0 * * *' 10 | permissions: 11 | issues: write 12 | pull-requests: write 13 | discussions: write 14 | concurrency: 15 | group: lock 16 | jobs: 17 | lock: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1 21 | with: 22 | issue-inactive-days: 14 23 | pr-inactive-days: 14 24 | discussion-inactive-days: 14 25 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yaml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | on: 3 | pull_request: 4 | push: 5 | branches: [main, stable] 6 | jobs: 7 | main: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 11 | - uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # v6.1.0 12 | with: 13 | enable-cache: true 14 | prune-cache: false 15 | - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 16 | id: setup-python 17 | with: 18 | python-version-file: pyproject.toml 19 | - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 20 | with: 21 | path: ~/.cache/pre-commit 22 | key: pre-commit|${{ hashFiles('pyproject.toml', '.pre-commit-config.yaml') }} 23 | - run: uv run --locked --group pre-commit pre-commit run --show-diff-on-failure --color=always --all-files 24 | - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 25 | if: ${{ !cancelled() }} 26 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | push: 4 | tags: ['*'] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | outputs: 9 | hash: ${{ steps.hash.outputs.hash }} 10 | steps: 11 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 12 | - uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # v6.1.0 13 | with: 14 | enable-cache: true 15 | prune-cache: false 16 | - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 17 | with: 18 | python-version-file: pyproject.toml 19 | - run: echo "SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)" >> $GITHUB_ENV 20 | - run: uv build 21 | - name: generate hash 22 | id: hash 23 | run: cd dist && echo "hash=$(sha256sum * | base64 -w0)" >> $GITHUB_OUTPUT 24 | - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 25 | with: 26 | path: ./dist 27 | provenance: 28 | needs: [build] 29 | permissions: 30 | actions: read 31 | id-token: write 32 | contents: write 33 | # Can't pin with hash due to how this workflow works. 34 | uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0 35 | with: 36 | base64-subjects: ${{ needs.build.outputs.hash }} 37 | create-release: 38 | needs: [provenance] 39 | runs-on: ubuntu-latest 40 | permissions: 41 | contents: write 42 | steps: 43 | - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 44 | - name: create release 45 | run: > 46 | gh release create --draft --repo ${{ github.repository }} 47 | ${{ github.ref_name }} 48 | *.intoto.jsonl/* artifact/* 49 | env: 50 | GH_TOKEN: ${{ github.token }} 51 | publish-pypi: 52 | needs: [provenance] 53 | environment: 54 | name: publish 55 | url: https://pypi.org/project/blinker/${{ github.ref_name }} 56 | runs-on: ubuntu-latest 57 | permissions: 58 | id-token: write 59 | steps: 60 | - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 61 | - uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 62 | with: 63 | packages-dir: artifact/ 64 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | pull_request: 4 | paths-ignore: ['docs/**', 'README.md'] 5 | push: 6 | branches: [main, stable] 7 | paths-ignore: ['docs/**', 'README.md'] 8 | jobs: 9 | tests: 10 | name: ${{ matrix.name || matrix.python }} 11 | runs-on: ${{ matrix.os || 'ubuntu-latest' }} 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | include: 16 | - {python: '3.13'} 17 | - {python: '3.12'} 18 | - {python: '3.11'} 19 | - {python: '3.10'} 20 | - {python: '3.9'} 21 | steps: 22 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 23 | - uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # v6.1.0 24 | with: 25 | enable-cache: true 26 | prune-cache: false 27 | - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 28 | with: 29 | python-version: ${{ matrix.python }} 30 | - run: uv run --locked tox run -e ${{ matrix.tox || format('py{0}', matrix.python) }} 31 | typing: 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 35 | - uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # v6.1.0 36 | with: 37 | enable-cache: true 38 | prune-cache: false 39 | - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 40 | with: 41 | python-version-file: pyproject.toml 42 | - name: cache mypy 43 | uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 44 | with: 45 | path: ./.mypy_cache 46 | key: mypy|${{ hashFiles('pyproject.toml') }} 47 | - run: uv run --locked tox run -e typing 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | __pycache__/ 4 | dist/ 5 | .coverage* 6 | htmlcov/ 7 | .tox/ 8 | docs/_build/ 9 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: d19233b89771be2d89273f163f5edc5a39bbc34a # frozen: v0.11.12 4 | hooks: 5 | - id: ruff 6 | - id: ruff-format 7 | - repo: https://github.com/astral-sh/uv-pre-commit 8 | rev: 5763f5f580fa24d9d32c85bc3bae8cca4a8f8945 # frozen: 0.7.9 9 | hooks: 10 | - id: uv-lock 11 | - repo: https://github.com/pre-commit/pre-commit-hooks 12 | rev: cef0300fd0fc4d2a87a85fa2093c6b283ea36f4b # frozen: v5.0.0 13 | hooks: 14 | - id: check-merge-conflict 15 | - id: debug-statements 16 | - id: fix-byte-order-marker 17 | - id: trailing-whitespace 18 | - id: end-of-file-fixer 19 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | build: 3 | os: ubuntu-24.04 4 | tools: 5 | python: '3.13' 6 | commands: 7 | - asdf plugin add uv 8 | - asdf install uv latest 9 | - asdf global uv latest 10 | - uv run --group docs sphinx-build -W -b dirhtml docs $READTHEDOCS_OUTPUT/html 11 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Version 1.10.0 2 | -------------- 3 | 4 | Unreleased 5 | 6 | 7 | Version 1.9.0 8 | ------------- 9 | 10 | Released 2024-11-08 11 | 12 | - Drop support for Python 3.8. :pr:`175` 13 | - Remove previously deprecated ``__version__``, ``receiver_connected``, 14 | ``Signal.temporarily_connected_to`` and ``WeakNamespace``. :pr:`172` 15 | - Skip weakref signal cleanup if the interpreter is shutting down. 16 | :issue:`173` 17 | 18 | 19 | Version 1.8.2 20 | ------------- 21 | 22 | Released 2024-05-06 23 | 24 | - Simplify type for ``_async_wrapper`` and ``_sync_wrapper`` arguments. 25 | :pr:`156` 26 | 27 | 28 | Version 1.8.1 29 | ------------- 30 | 31 | Released 2024-04-28 32 | 33 | - Restore identity handling for ``str`` and ``int`` senders. :pr:`148` 34 | - Fix deprecated ``blinker.base.WeakNamespace`` import. :pr:`149` 35 | - Fix deprecated ``blinker.base.receiver_connected import``. :pr:`153` 36 | - Use types from ``collections.abc`` instead of ``typing``. :pr:`150` 37 | - Fully specify exported types as reported by pyright. :pr:`152` 38 | 39 | 40 | Version 1.8.0 41 | ------------- 42 | 43 | Released 2024-04-27 44 | 45 | - Deprecate the ``__version__`` attribute. Use feature detection, or 46 | ``importlib.metadata.version("blinker")``, instead. :issue:`128` 47 | - Specify that the deprecated ``temporarily_connected_to`` will be removed in 48 | the next version. 49 | - Show a deprecation warning for the deprecated global ``receiver_connected`` 50 | signal and specify that it will be removed in the next version. 51 | - Show a deprecation warning for the deprecated ``WeakNamespace`` and specify 52 | that it will be removed in the next version. 53 | - Greatly simplify how the library uses weakrefs. This is a significant change 54 | internally but should not affect any public API. :pr:`144` 55 | - Expose the namespace used by ``signal()`` as ``default_namespace``. 56 | :pr:`145` 57 | 58 | 59 | Version 1.7.0 60 | ------------- 61 | 62 | Released 2023-11-01 63 | 64 | - Fixed messages printed to standard error about unraisable exceptions during 65 | signal cleanup, typically during interpreter shutdown. :pr:`123` 66 | - Allow the Signal ``set_class`` to be customised, to allow calling of 67 | receivers in registration order. :pr:`116`. 68 | - Drop Python 3.7 and support Python 3.12. :pr:`126` 69 | 70 | 71 | Version 1.6.3 72 | ------------- 73 | 74 | Released 2023-09-23 75 | 76 | - Fix ``SyncWrapperType`` and ``AsyncWrapperType`` :pr:`108` 77 | - Fixed issue where ``connected_to`` would not disconnect the receiver if an 78 | instance of ``BaseException`` was raised. :pr:`114` 79 | 80 | 81 | Version 1.6.2 82 | ------------- 83 | 84 | Released 2023-04-12 85 | 86 | - Type annotations are not evaluated at runtime. typing-extensions is not a 87 | runtime dependency. :pr:`94` 88 | 89 | 90 | Version 1.6.1 91 | ------------- 92 | 93 | Released 2023-04-09 94 | 95 | - Ensure that ``py.typed`` is present in the distributions (to enable other 96 | projects to use Blinker's typing). 97 | - Require typing-extensions > 4.2 to ensure it includes ``ParamSpec``. 98 | :issue:`90` 99 | 100 | 101 | Version 1.6 102 | ----------- 103 | 104 | Released 2023-04-02 105 | 106 | - Add a ``muted`` context manager to temporarily turn off a signal. :pr:`84` 107 | - ``int`` instances with the same value will be treated as the same sender, 108 | the same as ``str`` instances. :pr:`83` 109 | - Add a ``send_async`` method to allow signals to send to coroutine receivers. 110 | :pr:`76` 111 | - Update and modernise the project structure to match that used by the Pallets 112 | projects. :pr:`77` 113 | - Add an initial set of type hints for the project. 114 | 115 | 116 | Version 1.5 117 | ----------- 118 | 119 | Released 2022-07-17 120 | 121 | - Support Python >= 3.7 and PyPy. Python 2, Python < 3.7, and Jython 122 | may continue to work, but the next release will make incompatible 123 | changes. 124 | 125 | 126 | Version 1.4 127 | ----------- 128 | 129 | Released 2015-07-23 130 | 131 | - Verified Python 3.4 support, no changes needed. 132 | - Additional bookkeeping cleanup for non-``ANY`` connections at 133 | disconnect time. 134 | - Added ``Signal._cleanup_bookeeping()`` to prune stale bookkeeping on 135 | demand. 136 | 137 | 138 | Version 1.3 139 | ----------- 140 | 141 | Released 2013-07-03 142 | 143 | - The global signal stash behind ``signal()`` is now backed by a 144 | regular name-to-``Signal`` dictionary. Previously, weak references 145 | were held in the mapping and ephermal usage in code like 146 | ``signal('foo').connect(...)`` could have surprising program 147 | behavior depending on import order of modules. 148 | - ``Namespace`` is now built on a regular dict. Use ``WeakNamespace`` 149 | for the older, weak-referencing behavior. 150 | - ``Signal.connect('text-sender')`` uses an alterate hashing strategy 151 | to avoid sharp edges in text identity. 152 | 153 | 154 | Version 1.2 155 | ----------- 156 | 157 | Released 2011-10-26 158 | 159 | - Added ``Signal.receiver_connected`` and 160 | ``Signal.receiver_disconnected`` per-``Signal`` signals. 161 | - Deprecated the global ``receiver_connected`` signal. 162 | - Verified Python 3.2 support, no changes needed. 163 | 164 | 165 | Version 1.1 166 | ----------- 167 | 168 | Released 2010-07-21 169 | 170 | - Added ``@signal.connect_via(sender)`` decorator 171 | - Added ``signal.connected_to`` shorthand name for the 172 | ``temporarily_connected_to`` context manager. 173 | 174 | 175 | Version 1.0 176 | ----------- 177 | 178 | Released 2010-03-28 179 | 180 | - Python 3.0 and 3.1 compatibility. 181 | 182 | 183 | Version 0.9 184 | ----------- 185 | 186 | Released 2010-02-26 187 | 188 | - Added ``Signal.temporarily_connected_to`` context manager. 189 | - Docs! Sphinx docs, project web site. 190 | 191 | 192 | Version 0.8 193 | ----------- 194 | 195 | Released 2010-02-14 196 | 197 | - Initial release. 198 | - Extracted from ``flatland.util.signals``. 199 | - Added Python 2.4 compatibility. 200 | - Added nearly functional Python 3.1 compatibility. Everything except 201 | connecting to instance methods seems to work. 202 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2010 Jason Kirtland 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a 4 | copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included 12 | in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 15 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Blinker 2 | 3 | Blinker provides a fast dispatching system that allows any number of 4 | interested parties to subscribe to events, or "signals". 5 | 6 | 7 | ## Pallets Community Ecosystem 8 | 9 | > [!IMPORTANT]\ 10 | > This project is part of the Pallets Community Ecosystem. Pallets is the open 11 | > source organization that maintains Flask; Pallets-Eco enables community 12 | > maintenance of related projects. If you are interested in helping maintain 13 | > this project, please reach out on [the Pallets Discord server][discord]. 14 | > 15 | > [discord]: https://discord.gg/pallets 16 | 17 | 18 | ## Example 19 | 20 | Signal receivers can subscribe to specific senders or receive signals 21 | sent by any sender. 22 | 23 | ```pycon 24 | >>> from blinker import signal 25 | >>> started = signal('round-started') 26 | >>> def each(round): 27 | ... print(f"Round {round}") 28 | ... 29 | >>> started.connect(each) 30 | 31 | >>> def round_two(round): 32 | ... print("This is round two.") 33 | ... 34 | >>> started.connect(round_two, sender=2) 35 | 36 | >>> for round in range(1, 4): 37 | ... started.send(round) 38 | ... 39 | Round 1! 40 | Round 2! 41 | This is round two. 42 | Round 3! 43 | ``` 44 | -------------------------------------------------------------------------------- /docs/_static/blinker-named.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pallets-eco/blinker/9d669a63a02f0d3ed69e291f5a03dd49fa3e09ea/docs/_static/blinker-named.png -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | from pallets_sphinx_themes import get_version 2 | from pallets_sphinx_themes import ProjectLink 3 | 4 | project = "Blinker" 5 | copyright = "2010 Jason Kirtland" 6 | release, version = get_version("blinker", placeholder=None) 7 | 8 | default_role = "code" 9 | extensions = [ 10 | "sphinx.ext.autodoc", 11 | "sphinx.ext.extlinks", 12 | "sphinxcontrib.log_cabinet", 13 | "pallets_sphinx_themes", 14 | ] 15 | autodoc_member_order = "groupwise" 16 | autodoc_typehints = "description" 17 | autodoc_preserve_defaults = True 18 | extlinks = { 19 | "issue": ("https://github.com/pallets-eco/blinker/issues/%s", "#%s"), 20 | "pr": ("https://github.com/pallets-eco/blinker/pull/%s", "#%s"), 21 | } 22 | 23 | html_theme = "flask" 24 | html_theme_options = {"index_sidebar_logo": False} 25 | html_context = { 26 | "project_links": [ 27 | ProjectLink("PyPI Releases", "https://pypi.org/project/blinker/"), 28 | ProjectLink("Source Code", "https://github.com/pallets-eco/blinker/"), 29 | ProjectLink("Issue Tracker", "https://github.com/pallets-eco/blinker/issues/"), 30 | ] 31 | } 32 | html_sidebars = { 33 | "index": ["project.html", "localtoc.html", "searchbox.html", "ethicalads.html"], 34 | "**": ["localtoc.html", "relations.html", "searchbox.html", "ethicalads.html"], 35 | } 36 | singlehtml_sidebars = {"index": ["project.html", "localtoc.html", "ethicalads.html"]} 37 | html_static_path = ["_static"] 38 | html_logo = "_static/blinker-named.png" 39 | html_title = f"Blinker Documentation ({version})" 40 | html_show_sourcelink = False 41 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. rst-class:: hide-header 2 | 3 | Blinker Documentation 4 | ===================== 5 | 6 | .. image:: _static/blinker-named.png 7 | :align: center 8 | 9 | .. currentmodule:: blinker 10 | 11 | Blinker provides fast & simple object-to-object and broadcast 12 | signaling for Python objects. 13 | 14 | The core of Blinker is quite small but provides powerful features: 15 | 16 | - a global registry of named signals 17 | - anonymous signals 18 | - custom name registries 19 | - permanently or temporarily connected receivers 20 | - automatically disconnected receivers via weak referencing 21 | - sending arbitrary data payloads 22 | - collecting return values from signal receivers 23 | - thread safety 24 | 25 | Blinker was written by Jason Kirtand and is provided under the MIT 26 | License. The library supports Python 3.9 or later. 27 | 28 | 29 | Decoupling With Named Signals 30 | ----------------------------- 31 | 32 | Named signals are created with :func:`signal`: 33 | 34 | .. code-block:: python 35 | 36 | >>> from blinker import signal 37 | >>> initialized = signal('initialized') 38 | >>> initialized is signal('initialized') 39 | True 40 | 41 | Every call to ``signal('name')`` returns the same signal object, 42 | allowing unconnected parts of code (different modules, plugins, 43 | anything) to all use the same signal without requiring any code 44 | sharing or special imports. 45 | 46 | 47 | Subscribing to Signals 48 | ---------------------- 49 | 50 | :meth:`Signal.connect` registers a function to be invoked each time 51 | the signal is emitted. Connected functions are always passed the 52 | object that caused the signal to be emitted. 53 | 54 | .. code-block:: python 55 | 56 | >>> def subscriber(sender): 57 | ... print(f"Got a signal sent by {sender!r}") 58 | ... 59 | >>> ready = signal('ready') 60 | >>> ready.connect(subscriber) 61 | 62 | 63 | 64 | Emitting Signals 65 | ---------------- 66 | 67 | Code producing events of interest can :meth:`Signal.send` 68 | notifications to all connected receivers. 69 | 70 | Below, a simple ``Processor`` class emits a ``ready`` signal when it's 71 | about to process something, and ``complete`` when it is done. It 72 | passes ``self`` to the :meth:`~Signal.send` method, signifying that 73 | that particular instance was responsible for emitting the signal. 74 | 75 | .. code-block:: python 76 | 77 | >>> class Processor: 78 | ... def __init__(self, name): 79 | ... self.name = name 80 | ... 81 | ... def go(self): 82 | ... ready = signal('ready') 83 | ... ready.send(self) 84 | ... print("Processing.") 85 | ... complete = signal('complete') 86 | ... complete.send(self) 87 | ... 88 | ... def __repr__(self): 89 | ... return f'' 90 | ... 91 | >>> processor_a = Processor('a') 92 | >>> processor_a.go() 93 | Got a signal sent by 94 | Processing. 95 | 96 | Notice the ``complete`` signal in ``go()``? No receivers have 97 | connected to ``complete`` yet, and that's a-ok. Calling 98 | :meth:`~Signal.send` on a signal with no receivers will result in no 99 | notifications being sent, and these no-op sends are optimized to be as 100 | inexpensive as possible. 101 | 102 | 103 | Subscribing to Specific Senders 104 | ------------------------------- 105 | 106 | The default connection to a signal invokes the receiver function when 107 | any sender emits it. The :meth:`Signal.connect` function accepts an 108 | optional argument to restrict the subscription to one specific sending 109 | object: 110 | 111 | .. code-block:: python 112 | 113 | >>> def b_subscriber(sender): 114 | ... print("Caught signal from processor_b.") 115 | ... assert sender.name == 'b' 116 | ... 117 | >>> processor_b = Processor('b') 118 | >>> ready.connect(b_subscriber, sender=processor_b) 119 | 120 | 121 | This function has been subscribed to ``ready`` but only when sent by 122 | ``processor_b``: 123 | 124 | .. code-block:: python 125 | 126 | >>> processor_a.go() 127 | Got a signal sent by 128 | Processing. 129 | >>> processor_b.go() 130 | Got a signal sent by 131 | Caught signal from processor_b. 132 | Processing. 133 | 134 | 135 | Sending and Receiving Data Through Signals 136 | ------------------------------------------ 137 | 138 | Additional keyword arguments can be passed to :meth:`~Signal.send`. 139 | These will in turn be passed to the connected functions: 140 | 141 | .. code-block:: python 142 | 143 | >>> send_data = signal('send-data') 144 | >>> @send_data.connect 145 | ... def receive_data(sender, **kw): 146 | ... print(f"Caught signal from {sender!r}, data {kw!r}") 147 | ... return 'received!' 148 | ... 149 | >>> result = send_data.send('anonymous', abc=123) 150 | Caught signal from 'anonymous', data {'abc': 123} 151 | 152 | The return value of :meth:`~Signal.send` collects the return values of 153 | each connected function as a list of (``receiver function``, ``return 154 | value``) pairs: 155 | 156 | .. code-block:: python 157 | 158 | >>> result 159 | [(, 'received!')] 160 | 161 | 162 | Muting signals 163 | -------------- 164 | 165 | To mute a signal, as may be required when testing, the 166 | :meth:`~Signal.muted` can be used as a context decorator: 167 | 168 | .. code-block:: python 169 | 170 | sig = signal('send-data') 171 | with sig.muted(): 172 | ... 173 | 174 | 175 | Anonymous Signals 176 | ----------------- 177 | 178 | Signals need not be named. The :class:`Signal` constructor creates a 179 | unique signal each time it is invoked. For example, an alternative 180 | implementation of the Processor from above might provide the 181 | processing signals as class attributes: 182 | 183 | .. code-block:: python 184 | 185 | >>> from blinker import Signal 186 | >>> class AltProcessor: 187 | ... on_ready = Signal() 188 | ... on_complete = Signal() 189 | ... 190 | ... def __init__(self, name): 191 | ... self.name = name 192 | ... 193 | ... def go(self): 194 | ... self.on_ready.send(self) 195 | ... print("Alternate processing.") 196 | ... self.on_complete.send(self) 197 | ... 198 | ... def __repr__(self): 199 | ... return f'' 200 | ... 201 | 202 | ``connect`` as a Decorator 203 | -------------------------- 204 | 205 | You may have noticed the return value of :meth:`~Signal.connect` in 206 | the console output in the sections above. This allows ``connect`` to 207 | be used as a decorator on functions: 208 | 209 | .. code-block:: python 210 | 211 | >>> apc = AltProcessor('c') 212 | >>> @apc.on_complete.connect 213 | ... def completed(sender): 214 | ... print f"AltProcessor {sender.name} completed!" 215 | ... 216 | >>> apc.go() 217 | Alternate processing. 218 | AltProcessor c completed! 219 | 220 | While convenient, this form unfortunately does not allow the 221 | ``sender`` or ``weak`` arguments to be customized for the connected 222 | function. For this, :meth:`~Signal.connect_via` can be used: 223 | 224 | .. code-block:: python 225 | 226 | >>> dice_roll = signal('dice_roll') 227 | >>> @dice_roll.connect_via(1) 228 | ... @dice_roll.connect_via(3) 229 | ... @dice_roll.connect_via(5) 230 | ... def odd_subscriber(sender): 231 | ... print(f"Observed dice roll {sender!r}.") 232 | ... 233 | >>> result = dice_roll.send(3) 234 | Observed dice roll 3. 235 | 236 | 237 | Optimizing Signal Sending 238 | ------------------------- 239 | 240 | Signals are optimized to send very quickly, whether receivers are 241 | connected or not. If the keyword data to be sent with a signal is 242 | expensive to compute, it can be more efficient to check to see if any 243 | receivers are connected first by testing the :attr:`~Signal.receivers` 244 | property: 245 | 246 | .. code-block:: python 247 | 248 | >>> bool(signal('ready').receivers) 249 | True 250 | >>> bool(signal('complete').receivers) 251 | False 252 | >>> bool(AltProcessor.on_complete.receivers) 253 | True 254 | 255 | Checking for a receiver listening for a particular sender is also 256 | possible: 257 | 258 | .. code-block:: python 259 | 260 | >>> signal('ready').has_receivers_for(processor_a) 261 | True 262 | 263 | 264 | Documenting Signals 265 | ------------------- 266 | 267 | Both named and anonymous signals can be passed a ``doc`` argument at 268 | construction to set the pydoc help text for the signal. This 269 | documentation will be picked up by most documentation generators (such 270 | as sphinx) and is nice for documenting any additional data parameters 271 | that will be sent down with the signal. 272 | 273 | 274 | Async receivers 275 | --------------- 276 | 277 | Receivers can be coroutine functions which can be called and awaited 278 | via the :meth:`~Signal.send_async` method: 279 | 280 | .. code-block:: python 281 | 282 | sig = blinker.Signal() 283 | 284 | async def receiver(): 285 | ... 286 | 287 | sig.connect(receiver) 288 | await sig.send_async() 289 | 290 | This however requires that all receivers are awaitable which then 291 | precludes the usage of :meth:`~Signal.send`. To mix and match the 292 | :meth:`~Signal.send_async` method takes a ``_sync_wrapper`` argument 293 | such as: 294 | 295 | .. code-block:: python 296 | 297 | sig = blinker.Signal() 298 | 299 | def receiver(): 300 | ... 301 | 302 | sig.connect(receiver) 303 | 304 | def wrapper(func): 305 | 306 | async def inner(*args, **kwargs): 307 | func(*args, **kwargs) 308 | 309 | return inner 310 | 311 | await sig.send_async(_sync_wrapper=wrapper) 312 | 313 | The equivalent usage for :meth:`~Signal.send` is via the 314 | ``_async_wrapper`` argument. This usage is will depend on your event 315 | loop, and in the simple case whereby you aren't running within an 316 | event loop the following example can be used: 317 | 318 | .. code-block:: python 319 | 320 | sig = blinker.Signal() 321 | 322 | async def receiver(): 323 | ... 324 | 325 | sig.connect(receiver) 326 | 327 | def wrapper(func): 328 | 329 | def inner(*args, **kwargs): 330 | asyncio.run(func(*args, **kwargs)) 331 | 332 | return inner 333 | 334 | await sig.send(_async_wrapper=wrapper) 335 | 336 | 337 | Call receivers in order of registration 338 | --------------------------------------- 339 | 340 | It can be advantageous to call a signal's receivers in the order they 341 | were registered. To achieve this the storage class for receivers should 342 | be changed from an (unordered) set to an ordered set, 343 | 344 | .. code-block:: python 345 | 346 | from blinker import Signal 347 | from ordered_set import OrderedSet 348 | 349 | Signal.set_class = OrderedSet 350 | 351 | Please note that ``ordered_set`` is a PyPI package and is not 352 | installed with blinker. 353 | 354 | 355 | API Documentation 356 | ----------------- 357 | 358 | All public API members can (and should) be imported from ``blinker``:: 359 | 360 | from blinker import ANY, signal 361 | 362 | Basic Signals 363 | +++++++++++++ 364 | 365 | .. data:: ANY 366 | 367 | Symbol for "any sender". 368 | 369 | .. autoclass:: Signal 370 | :members: 371 | 372 | Named Signals 373 | +++++++++++++ 374 | 375 | .. function:: signal(name, doc=None) 376 | 377 | Return a :class:`NamedSignal` in :data:`default_namespace` for the given 378 | name, creating it if required. Repeated calls with the same name return the 379 | same signal. 380 | 381 | :param name: The name of the signal. 382 | :type name: str 383 | :param doc: The docstring of the signal. 384 | :type doc: str | None 385 | :rtype: NamedSignal 386 | 387 | .. data:: default_namespace 388 | 389 | A default :class:`Namespace` for creating named signals. :func:`signal` 390 | creates a :class:`NamedSignal` in this namespace. 391 | 392 | .. autoclass:: NamedSignal 393 | :show-inheritance: 394 | 395 | .. autoclass:: Namespace 396 | :show-inheritance: 397 | :members: signal 398 | 399 | 400 | Changes 401 | ======= 402 | 403 | .. include:: ../CHANGES.rst 404 | 405 | 406 | MIT License 407 | =========== 408 | 409 | .. literalinclude:: ../LICENSE.txt 410 | :language: text 411 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "blinker" 3 | version = "1.10.0.dev" 4 | description = "Fast, simple object-to-object and broadcast signaling" 5 | readme = "README.md" 6 | license = "MIT" 7 | license-files = ["LICENSE.txt"] 8 | authors = [{ name = "Jason Kirtland" }] 9 | maintainers = [{ name = "Pallets Ecosystem", email = "contact@palletsprojects.com" }] 10 | classifiers = [ 11 | "Development Status :: 5 - Production/Stable", 12 | "Programming Language :: Python", 13 | "Typing :: Typed", 14 | ] 15 | requires-python = ">=3.9" 16 | 17 | [project.urls] 18 | Documentation = "https://blinker.readthedocs.io" 19 | Source = "https://github.com/pallets-eco/blinker/" 20 | Chat = "https://discord.gg/pallets" 21 | 22 | [dependency-groups] 23 | dev = [ 24 | "ruff", 25 | "tox", 26 | "tox-uv", 27 | ] 28 | docs = [ 29 | "pallets-sphinx-themes", 30 | "sphinx", 31 | "sphinxcontrib-log-cabinet", 32 | ] 33 | docs-auto = [ 34 | "sphinx-autobuild", 35 | ] 36 | gha-update = [ 37 | "gha-update ; python_full_version >= '3.12'", 38 | ] 39 | pre-commit = [ 40 | "pre-commit", 41 | "pre-commit-uv", 42 | ] 43 | tests = [ 44 | "pytest", 45 | "pytest-asyncio", 46 | ] 47 | typing = [ 48 | "mypy", 49 | "pyright", 50 | "pytest", 51 | ] 52 | 53 | [build-system] 54 | requires = ["flit_core<4"] 55 | build-backend = "flit_core.buildapi" 56 | 57 | [tool.flit.module] 58 | name = "blinker" 59 | 60 | [tool.flit.sdist] 61 | include = [ 62 | "docs/", 63 | "tests/", 64 | "CHANGES.rst", 65 | "uv.lock", 66 | ] 67 | exclude = [ 68 | "docs/_build/", 69 | ] 70 | 71 | [tool.uv] 72 | default-groups = ["dev", "pre-commit", "tests", "typing"] 73 | 74 | [tool.pytest.ini_options] 75 | testpaths = ["tests"] 76 | filterwarnings = [ 77 | "error", 78 | ] 79 | asyncio_mode = "auto" 80 | asyncio_default_fixture_loop_scope = "function" 81 | 82 | [tool.coverage.run] 83 | branch = true 84 | source = ["blinker", "tests"] 85 | 86 | [tool.coverage.paths] 87 | source = ["src", "*/site-packages"] 88 | 89 | [tool.coverage.report] 90 | exclude_also = [ 91 | "if t.TYPE_CHECKING", 92 | "raise NotImplementedError", 93 | ": \\.{3}", 94 | ] 95 | 96 | [tool.mypy] 97 | python_version = "3.9" 98 | files = ["src", "tests"] 99 | show_error_codes = true 100 | pretty = true 101 | strict = true 102 | 103 | [tool.pyright] 104 | pythonVersion = "3.9" 105 | include = ["src", "tests"] 106 | typeCheckingMode = "standard" 107 | 108 | [tool.ruff] 109 | src = ["src"] 110 | fix = true 111 | show-fixes = true 112 | output-format = "full" 113 | 114 | [tool.ruff.lint] 115 | select = [ 116 | "B", # flake8-bugbear 117 | "E", # pycodestyle error 118 | "F", # pyflakes 119 | "I", # isort 120 | "UP", # pyupgrade 121 | "W", # pycodestyle warning 122 | ] 123 | ignore = [ 124 | "UP038", # keep isinstance tuple 125 | ] 126 | 127 | [tool.ruff.lint.isort] 128 | force-single-line = true 129 | order-by-type = false 130 | 131 | [tool.gha-update] 132 | tag-only = [ 133 | "slsa-framework/slsa-github-generator", 134 | ] 135 | 136 | [tool.tox] 137 | env_list = [ 138 | "py3.13", "py3.12", "py3.11", "py3.10", "py3.9", 139 | "style", 140 | "typing", 141 | "docs", 142 | ] 143 | 144 | [tool.tox.env_run_base] 145 | description = "pytest on latest dependency versions" 146 | runner = "uv-venv-lock-runner" 147 | package = "wheel" 148 | wheel_build_env = ".pkg" 149 | constrain_package_deps = true 150 | use_frozen_constraints = true 151 | dependency_groups = ["tests"] 152 | commands = [[ 153 | "pytest", "-v", "--tb=short", "--basetemp={env_tmp_dir}", 154 | {replace = "posargs", default = [], extend = true}, 155 | ]] 156 | 157 | [tool.tox.env.style] 158 | description = "run all pre-commit hooks on all files" 159 | dependency_groups = ["pre-commit"] 160 | skip_install = true 161 | commands = [["pre-commit", "run", "--all-files"]] 162 | 163 | [tool.tox.env.typing] 164 | description = "run static type checkers" 165 | dependency_groups = ["typing"] 166 | commands = [ 167 | ["mypy"], 168 | ["pyright"], 169 | ["pyright", "--verifytypes", "blinker", "--ignoreexternal"] 170 | ] 171 | 172 | [tool.tox.env.docs] 173 | description = "build docs" 174 | dependency_groups = ["docs"] 175 | commands = [["sphinx-build", "-E", "-W", "-b", "dirhtml", "docs", "docs/_build/dirhtml"]] 176 | 177 | [tool.tox.env.docs-auto] 178 | description = "continuously rebuild docs and start a local server" 179 | dependency_groups = ["docs", "docs-auto"] 180 | commands = [["sphinx-autobuild", "-W", "-b", "dirhtml", "--watch", "src", "docs", "docs/_build/dirhtml"]] 181 | 182 | [tool.tox.env.update-actions] 183 | description = "update GitHub Actions pins" 184 | labels = ["update"] 185 | dependency_groups = ["gha-update"] 186 | skip_install = true 187 | commands = [["gha-update"]] 188 | 189 | [tool.tox.env.update-pre_commit] 190 | description = "update pre-commit pins" 191 | labels = ["update"] 192 | dependency_groups = ["pre-commit"] 193 | skip_install = true 194 | commands = [["pre-commit", "autoupdate", "--freeze", "-j4"]] 195 | 196 | [tool.tox.env.update-requirements] 197 | description = "update uv lock" 198 | labels = ["update"] 199 | dependency_groups = [] 200 | no_default_groups = true 201 | skip_install = true 202 | commands = [["uv", "lock", {replace = "posargs", default = ["-U"], extend = true}]] 203 | -------------------------------------------------------------------------------- /src/blinker/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .base import ANY 4 | from .base import default_namespace 5 | from .base import NamedSignal 6 | from .base import Namespace 7 | from .base import Signal 8 | from .base import signal 9 | 10 | __all__ = [ 11 | "ANY", 12 | "default_namespace", 13 | "NamedSignal", 14 | "Namespace", 15 | "Signal", 16 | "signal", 17 | ] 18 | -------------------------------------------------------------------------------- /src/blinker/_utilities.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import collections.abc as c 4 | import inspect 5 | import typing as t 6 | from weakref import ref 7 | from weakref import WeakMethod 8 | 9 | T = t.TypeVar("T") 10 | 11 | 12 | class Symbol: 13 | """A constant symbol, nicer than ``object()``. Repeated calls return the 14 | same instance. 15 | 16 | >>> Symbol('foo') is Symbol('foo') 17 | True 18 | >>> Symbol('foo') 19 | foo 20 | """ 21 | 22 | symbols: t.ClassVar[dict[str, Symbol]] = {} 23 | 24 | def __new__(cls, name: str) -> Symbol: 25 | if name in cls.symbols: 26 | return cls.symbols[name] 27 | 28 | obj = super().__new__(cls) 29 | cls.symbols[name] = obj 30 | return obj 31 | 32 | def __init__(self, name: str) -> None: 33 | self.name = name 34 | 35 | def __repr__(self) -> str: 36 | return self.name 37 | 38 | def __getnewargs__(self) -> tuple[t.Any, ...]: 39 | return (self.name,) 40 | 41 | 42 | def make_id(obj: object) -> c.Hashable: 43 | """Get a stable identifier for a receiver or sender, to be used as a dict 44 | key or in a set. 45 | """ 46 | if inspect.ismethod(obj): 47 | # The id of a bound method is not stable, but the id of the unbound 48 | # function and instance are. 49 | return id(obj.__func__), id(obj.__self__) 50 | 51 | if isinstance(obj, (str, int)): 52 | # Instances with the same value always compare equal and have the same 53 | # hash, even if the id may change. 54 | return obj 55 | 56 | # Assume other types are not hashable but will always be the same instance. 57 | return id(obj) 58 | 59 | 60 | def make_ref(obj: T, callback: c.Callable[[ref[T]], None] | None = None) -> ref[T]: 61 | if inspect.ismethod(obj): 62 | return WeakMethod(obj, callback) # type: ignore[arg-type, return-value] 63 | 64 | return ref(obj, callback) 65 | -------------------------------------------------------------------------------- /src/blinker/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import collections.abc as c 4 | import sys 5 | import typing as t 6 | import weakref 7 | from collections import defaultdict 8 | from contextlib import contextmanager 9 | from functools import cached_property 10 | from inspect import iscoroutinefunction 11 | 12 | from ._utilities import make_id 13 | from ._utilities import make_ref 14 | from ._utilities import Symbol 15 | 16 | F = t.TypeVar("F", bound=c.Callable[..., t.Any]) 17 | 18 | ANY = Symbol("ANY") 19 | """Symbol for "any sender".""" 20 | 21 | ANY_ID = 0 22 | 23 | 24 | class Signal: 25 | """A notification emitter. 26 | 27 | :param doc: The docstring for the signal. 28 | """ 29 | 30 | ANY = ANY 31 | """An alias for the :data:`~blinker.ANY` sender symbol.""" 32 | 33 | set_class: type[set[t.Any]] = set 34 | """The set class to use for tracking connected receivers and senders. 35 | Python's ``set`` is unordered. If receivers must be dispatched in the order 36 | they were connected, an ordered set implementation can be used. 37 | 38 | .. versionadded:: 1.7 39 | """ 40 | 41 | @cached_property 42 | def receiver_connected(self) -> Signal: 43 | """Emitted at the end of each :meth:`connect` call. 44 | 45 | The signal sender is the signal instance, and the :meth:`connect` 46 | arguments are passed through: ``receiver``, ``sender``, and ``weak``. 47 | 48 | .. versionadded:: 1.2 49 | """ 50 | return Signal(doc="Emitted after a receiver connects.") 51 | 52 | @cached_property 53 | def receiver_disconnected(self) -> Signal: 54 | """Emitted at the end of each :meth:`disconnect` call. 55 | 56 | The sender is the signal instance, and the :meth:`disconnect` arguments 57 | are passed through: ``receiver`` and ``sender``. 58 | 59 | This signal is emitted **only** when :meth:`disconnect` is called 60 | explicitly. This signal cannot be emitted by an automatic disconnect 61 | when a weakly referenced receiver or sender goes out of scope, as the 62 | instance is no longer be available to be used as the sender for this 63 | signal. 64 | 65 | An alternative approach is available by subscribing to 66 | :attr:`receiver_connected` and setting up a custom weakref cleanup 67 | callback on weak receivers and senders. 68 | 69 | .. versionadded:: 1.2 70 | """ 71 | return Signal(doc="Emitted after a receiver disconnects.") 72 | 73 | def __init__(self, doc: str | None = None) -> None: 74 | if doc: 75 | self.__doc__ = doc 76 | 77 | self.receivers: dict[ 78 | t.Any, weakref.ref[c.Callable[..., t.Any]] | c.Callable[..., t.Any] 79 | ] = {} 80 | """The map of connected receivers. Useful to quickly check if any 81 | receivers are connected to the signal: ``if s.receivers:``. The 82 | structure and data is not part of the public API, but checking its 83 | boolean value is. 84 | """ 85 | 86 | self.is_muted: bool = False 87 | self._by_receiver: dict[t.Any, set[t.Any]] = defaultdict(self.set_class) 88 | self._by_sender: dict[t.Any, set[t.Any]] = defaultdict(self.set_class) 89 | self._weak_senders: dict[t.Any, weakref.ref[t.Any]] = {} 90 | 91 | def connect(self, receiver: F, sender: t.Any = ANY, weak: bool = True) -> F: 92 | """Connect ``receiver`` to be called when the signal is sent by 93 | ``sender``. 94 | 95 | :param receiver: The callable to call when :meth:`send` is called with 96 | the given ``sender``, passing ``sender`` as a positional argument 97 | along with any extra keyword arguments. 98 | :param sender: Any object or :data:`ANY`. ``receiver`` will only be 99 | called when :meth:`send` is called with this sender. If ``ANY``, the 100 | receiver will be called for any sender. A receiver may be connected 101 | to multiple senders by calling :meth:`connect` multiple times. 102 | :param weak: Track the receiver with a :mod:`weakref`. The receiver will 103 | be automatically disconnected when it is garbage collected. When 104 | connecting a receiver defined within a function, set to ``False``, 105 | otherwise it will be disconnected when the function scope ends. 106 | """ 107 | receiver_id = make_id(receiver) 108 | sender_id = ANY_ID if sender is ANY else make_id(sender) 109 | 110 | if weak: 111 | self.receivers[receiver_id] = make_ref( 112 | receiver, self._make_cleanup_receiver(receiver_id) 113 | ) 114 | else: 115 | self.receivers[receiver_id] = receiver 116 | 117 | self._by_sender[sender_id].add(receiver_id) 118 | self._by_receiver[receiver_id].add(sender_id) 119 | 120 | if sender is not ANY and sender_id not in self._weak_senders: 121 | # store a cleanup for weakref-able senders 122 | try: 123 | self._weak_senders[sender_id] = make_ref( 124 | sender, self._make_cleanup_sender(sender_id) 125 | ) 126 | except TypeError: 127 | pass 128 | 129 | if "receiver_connected" in self.__dict__ and self.receiver_connected.receivers: 130 | try: 131 | self.receiver_connected.send( 132 | self, receiver=receiver, sender=sender, weak=weak 133 | ) 134 | except TypeError: 135 | # TODO no explanation or test for this 136 | self.disconnect(receiver, sender) 137 | raise 138 | 139 | return receiver 140 | 141 | def connect_via(self, sender: t.Any, weak: bool = False) -> c.Callable[[F], F]: 142 | """Connect the decorated function to be called when the signal is sent 143 | by ``sender``. 144 | 145 | The decorated function will be called when :meth:`send` is called with 146 | the given ``sender``, passing ``sender`` as a positional argument along 147 | with any extra keyword arguments. 148 | 149 | :param sender: Any object or :data:`ANY`. ``receiver`` will only be 150 | called when :meth:`send` is called with this sender. If ``ANY``, the 151 | receiver will be called for any sender. A receiver may be connected 152 | to multiple senders by calling :meth:`connect` multiple times. 153 | :param weak: Track the receiver with a :mod:`weakref`. The receiver will 154 | be automatically disconnected when it is garbage collected. When 155 | connecting a receiver defined within a function, set to ``False``, 156 | otherwise it will be disconnected when the function scope ends.= 157 | 158 | .. versionadded:: 1.1 159 | """ 160 | 161 | def decorator(fn: F) -> F: 162 | self.connect(fn, sender, weak) 163 | return fn 164 | 165 | return decorator 166 | 167 | @contextmanager 168 | def connected_to( 169 | self, receiver: c.Callable[..., t.Any], sender: t.Any = ANY 170 | ) -> c.Generator[None, None, None]: 171 | """A context manager that temporarily connects ``receiver`` to the 172 | signal while a ``with`` block executes. When the block exits, the 173 | receiver is disconnected. Useful for tests. 174 | 175 | :param receiver: The callable to call when :meth:`send` is called with 176 | the given ``sender``, passing ``sender`` as a positional argument 177 | along with any extra keyword arguments. 178 | :param sender: Any object or :data:`ANY`. ``receiver`` will only be 179 | called when :meth:`send` is called with this sender. If ``ANY``, the 180 | receiver will be called for any sender. 181 | 182 | .. versionadded:: 1.1 183 | """ 184 | self.connect(receiver, sender=sender, weak=False) 185 | 186 | try: 187 | yield None 188 | finally: 189 | self.disconnect(receiver) 190 | 191 | @contextmanager 192 | def muted(self) -> c.Generator[None, None, None]: 193 | """A context manager that temporarily disables the signal. No receivers 194 | will be called if the signal is sent, until the ``with`` block exits. 195 | Useful for tests. 196 | """ 197 | self.is_muted = True 198 | 199 | try: 200 | yield None 201 | finally: 202 | self.is_muted = False 203 | 204 | def send( 205 | self, 206 | sender: t.Any | None = None, 207 | /, 208 | *, 209 | _async_wrapper: c.Callable[ 210 | [c.Callable[..., c.Coroutine[t.Any, t.Any, t.Any]]], c.Callable[..., t.Any] 211 | ] 212 | | None = None, 213 | **kwargs: t.Any, 214 | ) -> list[tuple[c.Callable[..., t.Any], t.Any]]: 215 | """Call all receivers that are connected to the given ``sender`` 216 | or :data:`ANY`. Each receiver is called with ``sender`` as a positional 217 | argument along with any extra keyword arguments. Return a list of 218 | ``(receiver, return value)`` tuples. 219 | 220 | The order receivers are called is undefined, but can be influenced by 221 | setting :attr:`set_class`. 222 | 223 | If a receiver raises an exception, that exception will propagate up. 224 | This makes debugging straightforward, with an assumption that correctly 225 | implemented receivers will not raise. 226 | 227 | :param sender: Call receivers connected to this sender, in addition to 228 | those connected to :data:`ANY`. 229 | :param _async_wrapper: Will be called on any receivers that are async 230 | coroutines to turn them into sync callables. For example, could run 231 | the receiver with an event loop. 232 | :param kwargs: Extra keyword arguments to pass to each receiver. 233 | 234 | .. versionchanged:: 1.7 235 | Added the ``_async_wrapper`` argument. 236 | """ 237 | if self.is_muted: 238 | return [] 239 | 240 | results = [] 241 | 242 | for receiver in self.receivers_for(sender): 243 | if iscoroutinefunction(receiver): 244 | if _async_wrapper is None: 245 | raise RuntimeError("Cannot send to a coroutine function.") 246 | 247 | result = _async_wrapper(receiver)(sender, **kwargs) 248 | else: 249 | result = receiver(sender, **kwargs) 250 | 251 | results.append((receiver, result)) 252 | 253 | return results 254 | 255 | async def send_async( 256 | self, 257 | sender: t.Any | None = None, 258 | /, 259 | *, 260 | _sync_wrapper: c.Callable[ 261 | [c.Callable[..., t.Any]], c.Callable[..., c.Coroutine[t.Any, t.Any, t.Any]] 262 | ] 263 | | None = None, 264 | **kwargs: t.Any, 265 | ) -> list[tuple[c.Callable[..., t.Any], t.Any]]: 266 | """Await all receivers that are connected to the given ``sender`` 267 | or :data:`ANY`. Each receiver is called with ``sender`` as a positional 268 | argument along with any extra keyword arguments. Return a list of 269 | ``(receiver, return value)`` tuples. 270 | 271 | The order receivers are called is undefined, but can be influenced by 272 | setting :attr:`set_class`. 273 | 274 | If a receiver raises an exception, that exception will propagate up. 275 | This makes debugging straightforward, with an assumption that correctly 276 | implemented receivers will not raise. 277 | 278 | :param sender: Call receivers connected to this sender, in addition to 279 | those connected to :data:`ANY`. 280 | :param _sync_wrapper: Will be called on any receivers that are sync 281 | callables to turn them into async coroutines. For example, 282 | could call the receiver in a thread. 283 | :param kwargs: Extra keyword arguments to pass to each receiver. 284 | 285 | .. versionadded:: 1.7 286 | """ 287 | if self.is_muted: 288 | return [] 289 | 290 | results = [] 291 | 292 | for receiver in self.receivers_for(sender): 293 | if not iscoroutinefunction(receiver): 294 | if _sync_wrapper is None: 295 | raise RuntimeError("Cannot send to a non-coroutine function.") 296 | 297 | result = await _sync_wrapper(receiver)(sender, **kwargs) 298 | else: 299 | result = await receiver(sender, **kwargs) 300 | 301 | results.append((receiver, result)) 302 | 303 | return results 304 | 305 | def has_receivers_for(self, sender: t.Any) -> bool: 306 | """Check if there is at least one receiver that will be called with the 307 | given ``sender``. A receiver connected to :data:`ANY` will always be 308 | called, regardless of sender. Does not check if weakly referenced 309 | receivers are still live. See :meth:`receivers_for` for a stronger 310 | search. 311 | 312 | :param sender: Check for receivers connected to this sender, in addition 313 | to those connected to :data:`ANY`. 314 | """ 315 | if not self.receivers: 316 | return False 317 | 318 | if self._by_sender[ANY_ID]: 319 | return True 320 | 321 | if sender is ANY: 322 | return False 323 | 324 | return make_id(sender) in self._by_sender 325 | 326 | def receivers_for( 327 | self, sender: t.Any 328 | ) -> c.Generator[c.Callable[..., t.Any], None, None]: 329 | """Yield each receiver to be called for ``sender``, in addition to those 330 | to be called for :data:`ANY`. Weakly referenced receivers that are not 331 | live will be disconnected and skipped. 332 | 333 | :param sender: Yield receivers connected to this sender, in addition 334 | to those connected to :data:`ANY`. 335 | """ 336 | # TODO: test receivers_for(ANY) 337 | if not self.receivers: 338 | return 339 | 340 | sender_id = make_id(sender) 341 | 342 | if sender_id in self._by_sender: 343 | ids = self._by_sender[ANY_ID] | self._by_sender[sender_id] 344 | else: 345 | ids = self._by_sender[ANY_ID].copy() 346 | 347 | for receiver_id in ids: 348 | receiver = self.receivers.get(receiver_id) 349 | 350 | if receiver is None: 351 | continue 352 | 353 | if isinstance(receiver, weakref.ref): 354 | strong = receiver() 355 | 356 | if strong is None: 357 | self._disconnect(receiver_id, ANY_ID) 358 | continue 359 | 360 | yield strong 361 | else: 362 | yield receiver 363 | 364 | def disconnect(self, receiver: c.Callable[..., t.Any], sender: t.Any = ANY) -> None: 365 | """Disconnect ``receiver`` from being called when the signal is sent by 366 | ``sender``. 367 | 368 | :param receiver: A connected receiver callable. 369 | :param sender: Disconnect from only this sender. By default, disconnect 370 | from all senders. 371 | """ 372 | sender_id: c.Hashable 373 | 374 | if sender is ANY: 375 | sender_id = ANY_ID 376 | else: 377 | sender_id = make_id(sender) 378 | 379 | receiver_id = make_id(receiver) 380 | self._disconnect(receiver_id, sender_id) 381 | 382 | if ( 383 | "receiver_disconnected" in self.__dict__ 384 | and self.receiver_disconnected.receivers 385 | ): 386 | self.receiver_disconnected.send(self, receiver=receiver, sender=sender) 387 | 388 | def _disconnect(self, receiver_id: c.Hashable, sender_id: c.Hashable) -> None: 389 | if sender_id == ANY_ID: 390 | if self._by_receiver.pop(receiver_id, None) is not None: 391 | for bucket in self._by_sender.values(): 392 | bucket.discard(receiver_id) 393 | 394 | self.receivers.pop(receiver_id, None) 395 | else: 396 | self._by_sender[sender_id].discard(receiver_id) 397 | self._by_receiver[receiver_id].discard(sender_id) 398 | 399 | def _make_cleanup_receiver( 400 | self, receiver_id: c.Hashable 401 | ) -> c.Callable[[weakref.ref[c.Callable[..., t.Any]]], None]: 402 | """Create a callback function to disconnect a weakly referenced 403 | receiver when it is garbage collected. 404 | """ 405 | 406 | def cleanup(ref: weakref.ref[c.Callable[..., t.Any]]) -> None: 407 | # If the interpreter is shutting down, disconnecting can result in a 408 | # weird ignored exception. Don't call it in that case. 409 | if not sys.is_finalizing(): 410 | self._disconnect(receiver_id, ANY_ID) 411 | 412 | return cleanup 413 | 414 | def _make_cleanup_sender( 415 | self, sender_id: c.Hashable 416 | ) -> c.Callable[[weakref.ref[t.Any]], None]: 417 | """Create a callback function to disconnect all receivers for a weakly 418 | referenced sender when it is garbage collected. 419 | """ 420 | assert sender_id != ANY_ID 421 | 422 | def cleanup(ref: weakref.ref[t.Any]) -> None: 423 | self._weak_senders.pop(sender_id, None) 424 | 425 | for receiver_id in self._by_sender.pop(sender_id, ()): 426 | self._by_receiver[receiver_id].discard(sender_id) 427 | 428 | return cleanup 429 | 430 | def _cleanup_bookkeeping(self) -> None: 431 | """Prune unused sender/receiver bookkeeping. Not threadsafe. 432 | 433 | Connecting & disconnecting leaves behind a small amount of bookkeeping 434 | data. Typical workloads using Blinker, for example in most web apps, 435 | Flask, CLI scripts, etc., are not adversely affected by this 436 | bookkeeping. 437 | 438 | With a long-running process performing dynamic signal routing with high 439 | volume, e.g. connecting to function closures, senders are all unique 440 | object instances. Doing all of this over and over may cause memory usage 441 | to grow due to extraneous bookkeeping. (An empty ``set`` for each stale 442 | sender/receiver pair.) 443 | 444 | This method will prune that bookkeeping away, with the caveat that such 445 | pruning is not threadsafe. The risk is that cleanup of a fully 446 | disconnected receiver/sender pair occurs while another thread is 447 | connecting that same pair. If you are in the highly dynamic, unique 448 | receiver/sender situation that has lead you to this method, that failure 449 | mode is perhaps not a big deal for you. 450 | """ 451 | for mapping in (self._by_sender, self._by_receiver): 452 | for ident, bucket in list(mapping.items()): 453 | if not bucket: 454 | mapping.pop(ident, None) 455 | 456 | def _clear_state(self) -> None: 457 | """Disconnect all receivers and senders. Useful for tests.""" 458 | self._weak_senders.clear() 459 | self.receivers.clear() 460 | self._by_sender.clear() 461 | self._by_receiver.clear() 462 | 463 | 464 | class NamedSignal(Signal): 465 | """A named generic notification emitter. The name is not used by the signal 466 | itself, but matches the key in the :class:`Namespace` that it belongs to. 467 | 468 | :param name: The name of the signal within the namespace. 469 | :param doc: The docstring for the signal. 470 | """ 471 | 472 | def __init__(self, name: str, doc: str | None = None) -> None: 473 | super().__init__(doc) 474 | 475 | #: The name of this signal. 476 | self.name: str = name 477 | 478 | def __repr__(self) -> str: 479 | base = super().__repr__() 480 | return f"{base[:-1]}; {self.name!r}>" # noqa: E702 481 | 482 | 483 | class Namespace(dict[str, NamedSignal]): 484 | """A dict mapping names to signals.""" 485 | 486 | def signal(self, name: str, doc: str | None = None) -> NamedSignal: 487 | """Return the :class:`NamedSignal` for the given ``name``, creating it 488 | if required. Repeated calls with the same name return the same signal. 489 | 490 | :param name: The name of the signal. 491 | :param doc: The docstring of the signal. 492 | """ 493 | if name not in self: 494 | self[name] = NamedSignal(name, doc) 495 | 496 | return self[name] 497 | 498 | 499 | class _PNamespaceSignal(t.Protocol): 500 | def __call__(self, name: str, doc: str | None = None) -> NamedSignal: ... 501 | 502 | 503 | default_namespace: Namespace = Namespace() 504 | """A default :class:`Namespace` for creating named signals. :func:`signal` 505 | creates a :class:`NamedSignal` in this namespace. 506 | """ 507 | 508 | signal: _PNamespaceSignal = default_namespace.signal 509 | """Return a :class:`NamedSignal` in :data:`default_namespace` with the given 510 | ``name``, creating it if required. Repeated calls with the same name return the 511 | same signal. 512 | """ 513 | -------------------------------------------------------------------------------- /src/blinker/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pallets-eco/blinker/9d669a63a02f0d3ed69e291f5a03dd49fa3e09ea/src/blinker/py.typed -------------------------------------------------------------------------------- /tests/test_context.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import pytest 6 | 7 | from blinker import Signal 8 | 9 | 10 | def test_temp_connection() -> None: 11 | sig = Signal() 12 | 13 | canary = [] 14 | 15 | def receiver(sender: t.Any) -> None: 16 | canary.append(sender) 17 | 18 | sig.send(1) 19 | with sig.connected_to(receiver): 20 | sig.send(2) 21 | sig.send(3) 22 | 23 | assert canary == [2] 24 | assert not sig.receivers 25 | 26 | 27 | def test_temp_connection_for_sender() -> None: 28 | sig = Signal() 29 | 30 | canary = [] 31 | 32 | def receiver(sender: t.Any) -> None: 33 | canary.append(sender) 34 | 35 | with sig.connected_to(receiver, sender=2): 36 | sig.send(1) 37 | sig.send(2) 38 | 39 | assert canary == [2] 40 | assert not sig.receivers 41 | 42 | 43 | class Failure(Exception): 44 | pass 45 | 46 | 47 | class BaseFailure(BaseException): 48 | pass 49 | 50 | 51 | @pytest.mark.parametrize("exc_type", [Failure, BaseFailure]) 52 | def test_temp_connection_failure(exc_type: type[BaseException]) -> None: 53 | sig = Signal() 54 | 55 | canary = [] 56 | 57 | def receiver(sender: t.Any) -> None: 58 | canary.append(sender) 59 | 60 | with pytest.raises(exc_type): 61 | sig.send(1) 62 | with sig.connected_to(receiver): 63 | sig.send(2) 64 | raise exc_type 65 | sig.send(3) 66 | 67 | assert canary == [2] 68 | assert not sig.receivers 69 | -------------------------------------------------------------------------------- /tests/test_signals.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import collections.abc as c 4 | import gc 5 | import sys 6 | import typing as t 7 | 8 | import pytest 9 | 10 | import blinker 11 | 12 | 13 | def collect_acyclic_refs() -> None: 14 | # cpython releases these immediately without a collection 15 | if sys.implementation.name == "pypy": 16 | gc.collect() 17 | 18 | 19 | class Sentinel(list): # type: ignore[type-arg] 20 | """A signal receipt accumulator.""" 21 | 22 | def make_receiver(self, key: t.Any) -> c.Callable[..., t.Any]: 23 | """Return a generic signal receiver function logging as *key* 24 | 25 | When connected to a signal, appends (key, sender, kw) to the Sentinel. 26 | 27 | """ 28 | 29 | def receiver(*sentby: t.Any, **kw: t.Any) -> None: 30 | self.append((key, sentby[0], kw)) 31 | 32 | return receiver 33 | 34 | 35 | def _test_signal_signals(sender: t.Any) -> None: 36 | sentinel = Sentinel() 37 | sig = blinker.Signal() 38 | 39 | connected = sentinel.make_receiver("receiver_connected") 40 | disconnected = sentinel.make_receiver("receiver_disconnected") 41 | receiver1 = sentinel.make_receiver("receiver1") 42 | receiver2 = sentinel.make_receiver("receiver2") 43 | 44 | assert not sig.receiver_connected.receivers 45 | assert not sig.receiver_disconnected.receivers 46 | sig.receiver_connected.connect(connected) 47 | sig.receiver_disconnected.connect(disconnected) 48 | 49 | assert sig.receiver_connected.receivers 50 | assert not sentinel 51 | 52 | for receiver, weak in [(receiver1, True), (receiver2, False)]: 53 | sig.connect(receiver, sender=sender, weak=weak) 54 | 55 | expected = ( 56 | "receiver_connected", 57 | sig, 58 | {"receiver": receiver, "sender": sender, "weak": weak}, 59 | ) 60 | 61 | assert sentinel[-1] == expected 62 | 63 | # disconnect from explicit sender 64 | sig.disconnect(receiver1, sender=sender) 65 | 66 | expected = ("receiver_disconnected", sig, {"receiver": receiver1, "sender": sender}) 67 | assert sentinel[-1] == expected 68 | 69 | # disconnect from ANY and all senders (implicit disconnect signature) 70 | sig.disconnect(receiver2) 71 | assert sentinel[-1] == ( 72 | "receiver_disconnected", 73 | sig, 74 | {"receiver": receiver2, "sender": blinker.ANY}, 75 | ) 76 | 77 | 78 | def test_signal_signals_any_sender() -> None: 79 | _test_signal_signals(blinker.ANY) 80 | 81 | 82 | def test_signal_signals_strong_sender() -> None: 83 | _test_signal_signals("squiznart") 84 | 85 | 86 | def test_signal_weak_receiver_vanishes() -> None: 87 | # non-edge-case path for weak receivers is exercised in the ANY sender 88 | # test above. 89 | sentinel = Sentinel() 90 | sig = blinker.Signal() 91 | 92 | connected = sentinel.make_receiver("receiver_connected") 93 | disconnected = sentinel.make_receiver("receiver_disconnected") 94 | receiver1 = sentinel.make_receiver("receiver1") 95 | receiver2 = sentinel.make_receiver("receiver2") 96 | 97 | sig.receiver_connected.connect(connected) 98 | sig.receiver_disconnected.connect(disconnected) 99 | 100 | # explicit disconnect on a weak does emit the signal 101 | sig.connect(receiver1, weak=True) 102 | sig.disconnect(receiver1) 103 | 104 | assert len(sentinel) == 2 105 | assert sentinel[-1][2]["receiver"] is receiver1 106 | 107 | del sentinel[:] 108 | sig.connect(receiver2, weak=True) 109 | assert len(sentinel) == 1 110 | 111 | del sentinel[:] # holds a ref to receiver2 112 | del receiver2 113 | collect_acyclic_refs() 114 | 115 | # no disconnect signal is fired 116 | assert len(sentinel) == 0 117 | 118 | # and everything really is disconnected 119 | sig.send("abc") 120 | assert len(sentinel) == 0 121 | 122 | 123 | def test_signal_signals_weak_sender() -> None: 124 | sentinel = Sentinel() 125 | sig = blinker.Signal() 126 | 127 | connected = sentinel.make_receiver("receiver_connected") 128 | disconnected = sentinel.make_receiver("receiver_disconnected") 129 | receiver1 = sentinel.make_receiver("receiver1") 130 | receiver2 = sentinel.make_receiver("receiver2") 131 | 132 | class Sender: 133 | """A weakref-able object.""" 134 | 135 | sig.receiver_connected.connect(connected) 136 | sig.receiver_disconnected.connect(disconnected) 137 | 138 | sender1 = Sender() 139 | sig.connect(receiver1, sender=sender1, weak=False) 140 | # regular disconnect of weak-able sender works fine 141 | sig.disconnect(receiver1, sender=sender1) 142 | 143 | assert len(sentinel) == 2 144 | 145 | del sentinel[:] 146 | sender2 = Sender() 147 | sig.connect(receiver2, sender=sender2, weak=False) 148 | 149 | # force sender2 to go out of scope 150 | del sender2 151 | collect_acyclic_refs() 152 | 153 | # no disconnect signal is fired 154 | assert len(sentinel) == 1 155 | 156 | # and everything really is disconnected 157 | sig.send("abc") 158 | assert len(sentinel) == 1 159 | 160 | 161 | def test_empty_bucket_growth() -> None: 162 | def senders() -> tuple[int, int]: 163 | return ( 164 | len(sig._by_sender), 165 | sum(len(i) for i in sig._by_sender.values()), 166 | ) 167 | 168 | def receivers() -> tuple[int, int]: 169 | return ( 170 | len(sig._by_receiver), 171 | sum(len(i) for i in sig._by_receiver.values()), 172 | ) 173 | 174 | sentinel = Sentinel() 175 | sig = blinker.Signal() 176 | receiver1 = sentinel.make_receiver("receiver1") 177 | receiver2 = sentinel.make_receiver("receiver2") 178 | 179 | class Sender: 180 | """A weakref-able object.""" 181 | 182 | sender = Sender() 183 | sig.connect(receiver1, sender=sender) 184 | sig.connect(receiver2, sender=sender) 185 | 186 | assert senders() == (1, 2) 187 | assert receivers() == (2, 2) 188 | 189 | sig.disconnect(receiver1, sender=sender) 190 | 191 | assert senders() == (1, 1) 192 | assert receivers() == (2, 1) 193 | 194 | sig.disconnect(receiver2, sender=sender) 195 | 196 | assert senders() == (1, 0) 197 | assert receivers() == (2, 0) 198 | 199 | sig._cleanup_bookkeeping() 200 | assert senders() == (0, 0) 201 | assert receivers() == (0, 0) 202 | 203 | 204 | def test_namespace() -> None: 205 | ns = blinker.Namespace() 206 | assert not ns 207 | s1 = ns.signal("abc") 208 | assert s1 is ns.signal("abc") 209 | assert s1 is not ns.signal("def") 210 | assert "abc" in ns 211 | 212 | del s1 213 | collect_acyclic_refs() 214 | 215 | assert "def" in ns 216 | assert "abc" in ns 217 | 218 | 219 | def test_weak_receiver() -> None: 220 | sentinel = [] 221 | 222 | def received(sender: t.Any, **kw: t.Any) -> None: 223 | sentinel.append(kw) 224 | 225 | sig = blinker.Signal() 226 | 227 | sig.connect(received, weak=True) 228 | 229 | del received 230 | collect_acyclic_refs() 231 | 232 | assert not sentinel 233 | sig.send() 234 | assert not sentinel 235 | assert not sig.receivers 236 | values_are_empty_sets_(sig._by_receiver) 237 | values_are_empty_sets_(sig._by_sender) 238 | 239 | 240 | def test_strong_receiver() -> None: 241 | sentinel = [] 242 | 243 | def received(sender: t.Any) -> None: 244 | sentinel.append(sender) 245 | 246 | fn_id = id(received) 247 | 248 | sig = blinker.Signal() 249 | sig.connect(received, weak=False) 250 | 251 | del received 252 | collect_acyclic_refs() 253 | 254 | assert not sentinel 255 | sig.send() 256 | assert sentinel 257 | assert [id(fn) for fn in sig.receivers.values()] == [fn_id] 258 | 259 | 260 | async def test_async_receiver() -> None: 261 | sentinel = [] 262 | 263 | async def received_async(sender: t.Any) -> None: 264 | sentinel.append(sender) 265 | 266 | def received(sender: t.Any) -> None: 267 | sentinel.append(sender) 268 | 269 | def wrapper(func: c.Callable[..., t.Any]) -> c.Callable[..., None]: 270 | async def inner(*args: t.Any, **kwargs: t.Any) -> None: 271 | func(*args, **kwargs) 272 | 273 | return inner # type: ignore[return-value] 274 | 275 | sig = blinker.Signal() 276 | sig.connect(received) 277 | sig.connect(received_async) 278 | 279 | await sig.send_async(_sync_wrapper=wrapper) # type: ignore[arg-type] 280 | assert len(sentinel) == 2 281 | 282 | with pytest.raises(RuntimeError): 283 | sig.send() 284 | 285 | 286 | def test_instancemethod_receiver() -> None: 287 | sentinel: list[t.Any] = [] 288 | 289 | class Receiver: 290 | def __init__(self, bucket: list[t.Any]) -> None: 291 | self.bucket = bucket 292 | 293 | def received(self, sender: t.Any) -> None: 294 | self.bucket.append(sender) 295 | 296 | receiver = Receiver(sentinel) 297 | 298 | sig = blinker.Signal() 299 | sig.connect(receiver.received) 300 | 301 | assert not sentinel 302 | sig.send() 303 | assert sentinel 304 | del receiver 305 | collect_acyclic_refs() 306 | 307 | sig.send() 308 | assert len(sentinel) == 1 309 | 310 | 311 | def test_filtered_receiver() -> None: 312 | sentinel = [] 313 | 314 | def received(sender: t.Any) -> None: 315 | sentinel.append(sender) 316 | 317 | sig = blinker.Signal() 318 | 319 | sig.connect(received, 123) 320 | 321 | assert not sentinel 322 | sig.send() 323 | assert not sentinel 324 | sig.send(123) 325 | assert sentinel == [123] 326 | sig.send() 327 | assert sentinel == [123] 328 | 329 | sig.disconnect(received, 123) 330 | sig.send(123) 331 | assert sentinel == [123] 332 | 333 | sig.connect(received, 123) 334 | sig.send(123) 335 | assert sentinel == [123, 123] 336 | 337 | sig.disconnect(received) 338 | sig.send(123) 339 | assert sentinel == [123, 123] 340 | 341 | 342 | def test_filtered_receiver_weakref() -> None: 343 | sentinel = [] 344 | 345 | def received(sender: t.Any) -> None: 346 | sentinel.append(sender) 347 | 348 | class Object: 349 | pass 350 | 351 | obj = Object() 352 | 353 | sig = blinker.Signal() 354 | sig.connect(received, obj) 355 | 356 | assert not sentinel 357 | sig.send(obj) 358 | assert sentinel == [obj] 359 | del sentinel[:] 360 | del obj 361 | collect_acyclic_refs() 362 | 363 | # general index isn't cleaned up 364 | assert sig.receivers 365 | # but receiver/sender pairs are 366 | values_are_empty_sets_(sig._by_receiver) 367 | values_are_empty_sets_(sig._by_sender) 368 | 369 | 370 | def test_decorated_receiver() -> None: 371 | sentinel = [] 372 | 373 | class Object: 374 | pass 375 | 376 | obj = Object() 377 | 378 | sig = blinker.Signal() 379 | 380 | @sig.connect_via(obj) 381 | def receiver(sender: t.Any, **kw: t.Any) -> None: 382 | sentinel.append(kw) 383 | 384 | assert not sentinel 385 | sig.send() 386 | assert not sentinel 387 | sig.send(1) 388 | assert not sentinel 389 | sig.send(obj) 390 | assert sig.receivers 391 | 392 | del receiver 393 | collect_acyclic_refs() 394 | assert sig.receivers 395 | 396 | 397 | def test_no_double_send() -> None: 398 | sentinel = [] 399 | 400 | def received(sender: t.Any) -> None: 401 | sentinel.append(sender) 402 | 403 | sig = blinker.Signal() 404 | 405 | sig.connect(received, 123) 406 | sig.connect(received) 407 | 408 | assert not sentinel 409 | sig.send() 410 | assert sentinel == [None] 411 | sig.send(123) 412 | assert sentinel == [None, 123] 413 | sig.send() 414 | assert sentinel == [None, 123, None] 415 | 416 | 417 | def test_has_receivers() -> None: 418 | def received(_: t.Any) -> None: 419 | return None 420 | 421 | sig = blinker.Signal() 422 | assert not sig.has_receivers_for(None) 423 | assert not sig.has_receivers_for(blinker.ANY) 424 | 425 | sig.connect(received, "xyz") 426 | assert not sig.has_receivers_for(None) 427 | assert not sig.has_receivers_for(blinker.ANY) 428 | assert sig.has_receivers_for("xyz") 429 | 430 | class Object: 431 | pass 432 | 433 | o = Object() 434 | 435 | sig.connect(received, o) 436 | assert sig.has_receivers_for(o) 437 | 438 | del received 439 | collect_acyclic_refs() 440 | 441 | assert not sig.has_receivers_for("xyz") 442 | assert list(sig.receivers_for("xyz")) == [] 443 | assert list(sig.receivers_for(o)) == [] 444 | 445 | sig.connect(lambda sender: None, weak=False) 446 | assert sig.has_receivers_for("xyz") 447 | assert sig.has_receivers_for(o) 448 | assert sig.has_receivers_for(None) 449 | assert sig.has_receivers_for(blinker.ANY) 450 | assert sig.has_receivers_for("xyz") 451 | 452 | 453 | def test_instance_doc() -> None: 454 | sig = blinker.Signal(doc="x") 455 | assert sig.__doc__ == "x" 456 | 457 | sig = blinker.Signal("x") 458 | assert sig.__doc__ == "x" 459 | 460 | 461 | def test_named_blinker() -> None: 462 | sig = blinker.NamedSignal("squiznart") 463 | assert "squiznart" in repr(sig) 464 | 465 | 466 | def test_mute_signal() -> None: 467 | sentinel = [] 468 | 469 | def received(sender: t.Any) -> None: 470 | sentinel.append(sender) 471 | 472 | sig = blinker.Signal() 473 | sig.connect(received) 474 | 475 | sig.send(123) 476 | assert 123 in sentinel 477 | 478 | with sig.muted(): 479 | sig.send(456) 480 | assert 456 not in sentinel 481 | 482 | 483 | def values_are_empty_sets_(dictionary: dict[t.Any, t.Any]) -> None: 484 | for val in dictionary.values(): 485 | assert val == set() 486 | 487 | 488 | def test_int_sender() -> None: 489 | count = 0 490 | 491 | def received(sender: t.Any) -> None: 492 | nonlocal count 493 | count += 1 494 | 495 | sig = blinker.Signal() 496 | sig.connect(received, sender=123456789) 497 | sig.send(123456789) # Python compiler uses same instance for same literal value. 498 | assert count == 1 499 | sig.send(int("123456789")) # Force different instance with same value. 500 | assert count == 2 501 | -------------------------------------------------------------------------------- /tests/test_symbol.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pickle 4 | 5 | from blinker._utilities import Symbol 6 | 7 | 8 | def test_symbols() -> None: 9 | foo = Symbol("foo") 10 | assert foo.name == "foo" 11 | assert foo is Symbol("foo") 12 | 13 | bar = Symbol("bar") 14 | assert foo is not bar 15 | assert foo != bar 16 | assert not foo == bar 17 | 18 | assert repr(foo) == "foo" 19 | 20 | 21 | def test_pickled_symbols() -> None: 22 | foo = Symbol("foo") 23 | 24 | for _ in 0, 1, 2: 25 | roundtrip = pickle.loads(pickle.dumps(foo)) 26 | assert roundtrip is foo 27 | --------------------------------------------------------------------------------