├── .flake8 ├── .gitattributes ├── .github └── workflows │ ├── lint_and_test.yml │ └── publish_pypi.yml ├── .gitignore ├── .readthedocs.yaml ├── LICENSE ├── MANIFEST.in ├── README.md ├── assets ├── favicon.ico ├── logo.png └── logo250.png ├── codecov.yml ├── docs ├── Makefile ├── _static │ └── style.css ├── conf.py ├── index.rst └── requirements.txt ├── examples.py ├── pymitter ├── __init__.py └── py.typed ├── pyproject.toml ├── requirements.txt ├── requirements_dev.txt └── tests ├── __init__.py └── test_all.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | 3 | max-line-length = 101 4 | 5 | # codes of errors to ignore 6 | ignore = E128, E306, E402, E722, E731, W504 7 | 8 | # enforce double quotes 9 | inline-quotes = double 10 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.pdf filter=lfs diff=lfs merge=lfs -text 2 | *.png filter=lfs diff=lfs merge=lfs -text 3 | *.jpg filter=lfs diff=lfs merge=lfs -text 4 | *.jpeg filter=lfs diff=lfs merge=lfs -text 5 | *.ico filter=lfs diff=lfs merge=lfs -text 6 | -------------------------------------------------------------------------------- /.github/workflows/lint_and_test.yml: -------------------------------------------------------------------------------- 1 | name: Lint and test 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 🛎️ 12 | uses: actions/checkout@v4 13 | with: 14 | persist-credentials: false 15 | 16 | - name: Setup python 3.9 🐍 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: 3.9 20 | 21 | - name: Install dependencies ☕️ 22 | run: | 23 | pip install -U pip 24 | pip install -r requirements.txt 25 | pip install -r requirements_dev.txt 26 | 27 | - name: Lint 🔍 28 | run: flake8 pymitter tests examples.py 29 | 30 | pypi: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - name: Checkout 🛎️ 34 | uses: actions/checkout@v4 35 | with: 36 | persist-credentials: false 37 | 38 | - name: Setup python 3.9 🐍 39 | uses: actions/setup-python@v5 40 | with: 41 | python-version: 3.9 42 | 43 | - name: Install dependencies ☕️ 44 | run: | 45 | pip install -U pip setuptools 46 | pip install -U twine build 47 | 48 | - name: Check bundling 📦 49 | run: python -m build 50 | 51 | - name: Check setup 🚦 52 | run: twine check "dist/pymitter-*.tar.gz" 53 | 54 | typecheck: 55 | strategy: 56 | fail-fast: false 57 | matrix: 58 | version: 59 | - {python: "3.7", os: ubuntu-22.04} 60 | - {python: "3.8", os: ubuntu-latest} 61 | - {python: "3.9", os: ubuntu-latest} 62 | - {python: "3.10", os: ubuntu-latest} 63 | - {python: "3.11", os: ubuntu-latest} 64 | - {python: "3.12", os: ubuntu-latest} 65 | - {python: "3.13", os: ubuntu-latest} 66 | name: typecheck (python ${{ matrix.version.python }}) 67 | runs-on: ${{ matrix.version.os }} 68 | steps: 69 | - name: Checkout 🛎️ 70 | uses: actions/checkout@v4 71 | with: 72 | persist-credentials: false 73 | 74 | - name: Setup Python ${{ matrix.version.python }} 🐍 75 | uses: actions/setup-python@v5 76 | with: 77 | python-version: ${{ matrix.version.python }} 78 | 79 | - name: Install dependencies ☕️ 80 | run: | 81 | pip install -U pip setuptools 82 | pip install -r requirements.txt 83 | pip install -r requirements_dev.txt 84 | 85 | - name: Typecheck 🥊 86 | run: mypy --config-file pyproject.toml pymitter 87 | 88 | test: 89 | strategy: 90 | fail-fast: false 91 | matrix: 92 | version: 93 | - {python: "3.7", os: ubuntu-22.04} 94 | - {python: "3.8", os: ubuntu-latest} 95 | - {python: "3.9", os: ubuntu-latest} 96 | - {python: "3.10", os: ubuntu-latest} 97 | - {python: "3.11", os: ubuntu-latest} 98 | - {python: "3.12", os: ubuntu-latest} 99 | - {python: "3.13", os: ubuntu-latest} 100 | name: test (python ${{ matrix.version.python }}) 101 | runs-on: ${{ matrix.version.os }} 102 | steps: 103 | - name: Checkout 🛎️ 104 | uses: actions/checkout@v4 105 | with: 106 | persist-credentials: false 107 | 108 | - name: Setup Python ${{ matrix.version.python }} 🐍 109 | uses: actions/setup-python@v5 110 | with: 111 | python-version: ${{ matrix.version.python }} 112 | 113 | - name: Install dependencies ☕️ 114 | run: | 115 | pip install -U pip setuptools 116 | pip install -r requirements.txt 117 | pip install -r requirements_dev.txt 118 | 119 | - name: Test 🎢 120 | run: pytest tests 121 | 122 | coverage: 123 | runs-on: ubuntu-latest 124 | steps: 125 | - name: Checkout 🛎️ 126 | uses: actions/checkout@v4 127 | with: 128 | persist-credentials: false 129 | 130 | - name: Setup python 3.9 🐍 131 | uses: actions/setup-python@v5 132 | with: 133 | python-version: 3.9 134 | 135 | - name: Install dependencies ☕️ 136 | run: | 137 | pip install -U pip setuptools 138 | pip install -r requirements.txt 139 | pip install -r requirements_dev.txt 140 | 141 | - name: Run coverage test 🎢 142 | run: pytest --cov=pymitter --cov-report xml:coverage_39.xml tests 143 | 144 | - name: Upload report 🔝 145 | uses: codecov/codecov-action@v5 146 | with: 147 | token: ${{ secrets.CODECOV_TOKEN }} 148 | files: ./coverage_39.xml 149 | flags: unittests 150 | -------------------------------------------------------------------------------- /.github/workflows/publish_pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish on PyPI 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | publish: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout ⬇️ 11 | uses: actions/checkout@v4 12 | with: 13 | persist-credentials: false 14 | 15 | - name: Setup python 🐍 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: 3.9 19 | 20 | - name: Install dependencies ☕️ 21 | run: | 22 | pip install -U pip setuptools 23 | pip install -U twine build 24 | 25 | - name: Bundle 📦 26 | run: python -m build 27 | 28 | - name: Publish package 🐍 29 | uses: pypa/gh-action-pypi-publish@release/v1 30 | with: 31 | user: __token__ 32 | password: ${{ secrets.PYPI_TOKEN }} 33 | skip-existing: true 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.sublime-project 2 | *.sublime-workspace 3 | *.pyc 4 | *.log 5 | *.DS_Store 6 | dist 7 | __pycache__ 8 | MANIFEST 9 | .coverage 10 | coverage.xml 11 | docs/_build 12 | .python-version 13 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.11" 7 | 8 | submodules: 9 | include: all 10 | recursive: true 11 | 12 | sphinx: 13 | configuration: docs/conf.py 14 | 15 | python: 16 | install: 17 | - requirements: docs/requirements.txt 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2025, Marcel Rieger 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the copyright holder nor the names of its contributors 15 | may be used to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft pymitter 2 | include requirements.txt requirements_dev.txt README.md LICENSE .flake8 3 | global-exclude *.py[cod] __pycache__ .github tests/* 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | 5 |

6 | 7 | 8 | 9 | 10 | 11 |

12 | 13 | Documentation status 14 | 15 | Python version 16 | 17 | Package version 18 | 19 | 20 | Package downloads 21 | 22 | 23 | Code coverge 24 | 25 | 26 | Build status 27 | 28 | 29 | License 30 | 31 |

32 | 33 | 34 | 35 | 36 | 37 | Python port of the extended Node.js EventEmitter 2 approach of https://github.com/asyncly/EventEmitter2 providing namespaces, wildcards and TTL. 38 | 39 | Original source hosted at [GitHub](https://github.com/riga/pymitter). 40 | 41 | 42 | 43 | 44 | 45 | ## Features 46 | 47 | - Namespaces with wildcards 48 | - Times to listen (TTL) 49 | - Usage via decorators or callbacks 50 | - Coroutine support 51 | - Lightweight implementation, good performance 52 | 53 | 54 | ## Installation 55 | 56 | Simply install via [pip](https://pypi.python.org/pypi/pymitter): 57 | 58 | ```shell 59 | pip install pymitter 60 | ``` 61 | 62 | The last version with Python 2 support was [v0.3.2](https://github.com/riga/pymitter/tree/v0.3.2) ([PyPI](https://pypi.org/project/pymitter/0.3.2)). 63 | 64 | 65 | ## Examples 66 | 67 | ### Basic usage 68 | 69 | ```python 70 | from pymitter import EventEmitter 71 | 72 | 73 | ee = EventEmitter() 74 | 75 | 76 | # decorator usage 77 | @ee.on("my_event") 78 | def handler1(arg): 79 | print("handler1 called with", arg) 80 | 81 | 82 | # callback usage 83 | def handler2(arg): 84 | print("handler2 called with", arg) 85 | 86 | 87 | ee.on("my_other_event", handler2) 88 | 89 | 90 | # support for coroutine functions 91 | @ee.on("my_third_event") 92 | async def handler3(arg): 93 | print("handler3 called with", arg) 94 | 95 | 96 | # emit 97 | ee.emit("my_event", "foo") 98 | # -> "handler1 called with foo" 99 | 100 | ee.emit("my_other_event", "bar") 101 | # -> "handler2 called with bar" 102 | 103 | ee.emit("my_third_event", "baz") 104 | # -> "handler3 called with baz" 105 | ``` 106 | 107 | 108 | ### Coroutines 109 | 110 | Wrapping `async` functions outside an event loop will start an internal event loop and calls to `emit` return synchronously. 111 | 112 | ```python 113 | from pymitter import EventEmitter 114 | 115 | 116 | ee = EventEmitter() 117 | 118 | 119 | # register an async function 120 | @ee.on("my_event") 121 | async def handler1(arg): 122 | print("handler1 called with", arg) 123 | 124 | 125 | # emit 126 | ee.emit("my_event", "foo") 127 | # -> "handler1 called with foo" 128 | ``` 129 | 130 | Wrapping `async` functions inside an event loop will use the same loop and `emit_async` is awaitable. 131 | 132 | ```python 133 | from pymitter import EventEmitter 134 | 135 | 136 | ee = EventEmitter() 137 | 138 | 139 | async def main(): 140 | # emit_async 141 | awaitable = ee.emit_async("my_event", "foo") 142 | # -> nothing printed yet 143 | 144 | await awaitable 145 | # -> "handler1 called with foo" 146 | ``` 147 | 148 | Use `emit_future` to not return awaitable objects but to place them at the end of the existing event loop (using `asyncio.ensure_future` internally). 149 | 150 | 151 | ### TTL (times to listen) 152 | 153 | ```python 154 | from pymitter import EventEmitter 155 | 156 | 157 | ee = EventEmitter() 158 | 159 | 160 | @ee.once("my_event") 161 | def handler1(): 162 | print("handler1 called") 163 | 164 | 165 | @ee.on("my_event", ttl=2) 166 | def handler2(): 167 | print("handler2 called") 168 | 169 | 170 | ee.emit("my_event") 171 | # -> "handler1 called" 172 | # -> "handler2 called" 173 | 174 | ee.emit("my_event") 175 | # -> "handler2 called" 176 | 177 | ee.emit("my_event") 178 | # nothing called anymore 179 | ``` 180 | 181 | 182 | ### Wildcards 183 | 184 | ```python 185 | from pymitter import EventEmitter 186 | 187 | 188 | ee = EventEmitter(wildcard=True) 189 | 190 | 191 | @ee.on("my_event.foo") 192 | def handler1(): 193 | print("handler1 called") 194 | 195 | 196 | @ee.on("my_event.bar") 197 | def handler2(): 198 | print("handler2 called") 199 | 200 | 201 | @ee.on("my_event.*") 202 | def hander3(): 203 | print("handler3 called") 204 | 205 | 206 | ee.emit("my_event.foo") 207 | # -> "handler1 called" 208 | # -> "handler3 called" 209 | 210 | ee.emit("my_event.bar") 211 | # -> "handler2 called" 212 | # -> "handler3 called" 213 | 214 | ee.emit("my_event.*") 215 | # -> "handler1 called" 216 | # -> "handler2 called" 217 | # -> "handler3 called" 218 | ``` 219 | 220 | 221 | ## API 222 | 223 | ### `EventEmitter(*, wildcard=False, delimiter=".", new_listener=False, max_listeners=-1)` 224 | 225 | EventEmitter constructor. **Note**: always use *kwargs* for configuration. 226 | When *wildcard* is *True*, wildcards are used as shown in [this example](#wildcards). 227 | *delimiter* is used to seperate namespaces within events. 228 | If *new_listener* is *True*, the *"new_listener"* event is emitted every time a new listener is registered. 229 | Functions listening to this event are passed `(func, event=None)`. 230 | *max_listeners* defines the maximum number of listeners per event. 231 | Negative values mean infinity. 232 | 233 | - #### `on(event, func=None, ttl=-1)` 234 | Registers a function to an event. 235 | When *func* is *None*, decorator usage is assumed. 236 | *ttl* defines the times to listen. Negative values mean infinity. 237 | Returns the function. 238 | 239 | - #### `once(event, func=None)` 240 | Registers a function to an event with `ttl = 1`. 241 | When *func* is *None*, decorator usage is assumed. 242 | Returns the function. 243 | 244 | - #### `on_any(func=None)` 245 | Registers a function that is called every time an event is emitted. 246 | When *func* is *None*, decorator usage is assumed. 247 | Returns the function. 248 | 249 | - #### `off(event, func=None)` 250 | Removes a function that is registered to an event. 251 | When *func* is *None*, decorator usage is assumed. 252 | Returns the function. 253 | 254 | - #### `off_any(func=None)` 255 | Removes a function that was registered via `on_any()`. 256 | When *func* is *None*, decorator usage is assumed. 257 | Returns the function. 258 | 259 | - #### `off_all()` 260 | Removes all functions of all events. 261 | 262 | - #### `listeners(event)` 263 | Returns all functions that are registered to an event. 264 | Wildcards are not applied. 265 | 266 | - #### `listeners_any()` 267 | Returns all functions that were registered using `on_any()`. 268 | 269 | - #### `listeners_all()` 270 | Returns all registered functions. 271 | 272 | - #### `emit(event, *args, **kwargs)` 273 | Emits an event. 274 | All functions of events that match *event* are invoked with *args* and *kwargs* in the exact order of their registeration. 275 | Async functions are called in a new event loop. 276 | There is no return value. 277 | 278 | - #### `(async) emit_async(event, *args, **kwargs)` 279 | Emits an event. 280 | All functions of events that match *event* are invoked with *args* and *kwargs* in the exact order of their registeration. 281 | Awaitable objects returned by async functions are awaited in the outer event loop. 282 | Returns an `Awaitable`. 283 | 284 | - #### `emit_future(event, *args, **kwargs)` 285 | Emits an event. 286 | All functions of events that match *event* are invoked with *args* and *kwargs* in the exact order of their registeration. 287 | Awaitable objects returned by async functions are placed at the end of the event loop using `asyncio.ensure_future`. 288 | There is no return value. 289 | 290 | 291 | ## Development 292 | 293 | - Source hosted at [GitHub](https://github.com/riga/pymitter) 294 | - Python module hosted at [PyPI](https://pypi.python.org/pypi/pymitter) 295 | - Report issues, questions, feature requests on [GitHub Issues](https://github.com/riga/pymitter/issues) 296 | 297 | 298 | -------------------------------------------------------------------------------- /assets/favicon.ico: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:5c3c97c4f063bfe803546793e0f67ff403d3c482de356350e515fc3e035a7e6c 3 | size 106592 4 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:db463304533da9feecf4cb851a879c87df0a9e7bd1a70e66e72ac7119bd027a3 3 | size 32313 4 | -------------------------------------------------------------------------------- /assets/logo250.png: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:45219b644ba096cf0435ae5389a38b929ee35462bf2c3db4ed9a1f06cf24c064 3 | size 15998 4 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | # main branch 4 | project: 5 | default: 6 | target: auto 7 | informational: true 8 | 9 | # PRs 10 | patch: 11 | default: 12 | target: auto 13 | informational: true 14 | 15 | comment: false 16 | 17 | github_checks: 18 | annotations: false 19 | 20 | ignore: 21 | - ".github" 22 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | 3 | # You can set these variables from the command line. 4 | SPHINXOPTS = 5 | SPHINXBUILD = sphinx-build 6 | PAPER = 7 | BUILDDIR = _build 8 | 9 | # User-friendly check for sphinx-build 10 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 11 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 12 | endif 13 | 14 | # Internal variables. 15 | PAPEROPT_a4 = -D latex_paper_size=a4 16 | PAPEROPT_letter = -D latex_paper_size=letter 17 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 18 | # the i18n builder cannot share the environment and doctrees with the others 19 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 20 | 21 | .PHONY: help clean html 22 | 23 | help: 24 | @echo "Please use \`make ' where is one of" 25 | @echo " clean to cleanup all build files" 26 | @echo " html to make standalone HTML files" 27 | 28 | clean: 29 | rm -rf $(BUILDDIR)/* 30 | 31 | html: 32 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 33 | @echo 34 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 35 | -------------------------------------------------------------------------------- /docs/_static/style.css: -------------------------------------------------------------------------------- 1 | /* custom styles */ 2 | 3 | div#site-navigation img.logo { 4 | width: 250px !important; 5 | height: 55px !important; 6 | max-height: none !important; 7 | } 8 | 9 | div#site-navigation div.navbar_extra_footer { 10 | display: none; 11 | } 12 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import sys 4 | import os 5 | 6 | 7 | thisdir = os.path.dirname(os.path.abspath(__file__)) 8 | sys.path.insert(0, os.path.dirname(thisdir)) 9 | 10 | import pymitter as pm 11 | 12 | 13 | project = pm.__name__ 14 | author = pm.__author__ 15 | copyright = pm.__copyright__ 16 | copyright = copyright[10:] if copyright.startswith("Copyright ") else copyright 17 | copyright = copyright.split(",", 1)[0] 18 | version = pm.__version__[:pm.__version__.index(".", 2)] 19 | release = pm.__version__ 20 | language = "en" 21 | 22 | templates_path = ["_templates"] 23 | html_static_path = ["_static"] 24 | master_doc = "index" 25 | source_suffix = ".rst" 26 | exclude_patterns = [] 27 | pygments_style = "sphinx" 28 | add_module_names = False 29 | 30 | html_title = "{} v{}".format(project, version) 31 | html_logo = "../assets/logo.png" 32 | html_favicon = "../assets/favicon.ico" 33 | html_theme = "sphinx_book_theme" 34 | html_theme_options = { 35 | "show_navbar_depth": 2, 36 | "repository_url": "https://github.com/riga/pymitter", 37 | "use_repository_button": True, 38 | "use_issues_button": True, 39 | "use_edit_page_button": True, 40 | } 41 | 42 | extensions = [ 43 | "sphinx.ext.autodoc", 44 | "sphinx.ext.intersphinx", 45 | "sphinx.ext.viewcode", 46 | "sphinx.ext.autosectionlabel", 47 | "autodocsumm", 48 | "myst_parser", 49 | "sphinx_lfs_content", 50 | ] 51 | 52 | autodoc_member_order = "bysource" 53 | 54 | 55 | def setup(app): 56 | app.add_css_file("styles.css") 57 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | **pymitter** 2 | ============ 3 | 4 | .. include:: ../README.md 5 | :parser: myst_parser.sphinx_ 6 | :start-after: 7 | :end-before: 8 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # main packages 2 | -r ../requirements.txt 3 | 4 | # dev packages 5 | -r ../requirements_dev.txt 6 | 7 | # documentation packages 8 | sphinx~=6.2.1 9 | sphinx-autodoc-typehints~=1.22,<1.23 10 | sphinx-book-theme~=1.0.1 11 | sphinx-lfs-content~=1.1.3 12 | autodocsumm~=0.2.11 13 | myst-parser~=2.0.0 14 | -------------------------------------------------------------------------------- /examples.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | # python imports 4 | import os 5 | import sys 6 | 7 | # adjust the path to import pymitter 8 | base = os.path.normpath(os.path.join(os.path.abspath(__file__), "../..")) 9 | sys.path.insert(0, base) 10 | 11 | # create an EventEmitter instance 12 | from pymitter import EventEmitter 13 | ee = EventEmitter(wildcard=True, new_listener=True, max_listeners=-1) 14 | 15 | 16 | @ee.on("new_listener") 17 | def on_new(func, event=None): 18 | print("added listener", event, func) 19 | 20 | 21 | @ee.on("foo") 22 | def handler_foo1(arg): 23 | print("foo handler 1 called with", arg) 24 | 25 | 26 | @ee.on("foo") 27 | def handler_foo2(arg): 28 | print("foo handler 2 called with", arg) 29 | 30 | 31 | @ee.on("foo.*", ttl=1) 32 | def handler_fooall(arg): 33 | print("foo.* handler called with", arg) 34 | 35 | 36 | @ee.on("foo.bar") 37 | def handler_foobar(arg): 38 | print("foo.bar handler called with", arg) 39 | 40 | 41 | @ee.on_any() 42 | def handler_any(*args, **kwargs): 43 | print("called every time") 44 | 45 | 46 | print("emit foo") 47 | ee.emit("foo", "test") 48 | print(10 * "-") 49 | 50 | print("emit foo.bar") 51 | ee.emit("foo.bar", "test") 52 | print(10 * "-") 53 | 54 | print("emit foo.*") 55 | ee.emit("foo.*", "test") 56 | print(10 * "-") 57 | -------------------------------------------------------------------------------- /pymitter/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | """ 4 | Python port of the extended Node.js EventEmitter 2 approach providing namespaces, wildcards and TTL. 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | __author__ = "Marcel Rieger" 10 | __author_email__ = "github.riga@icloud.com" 11 | __copyright__ = "Copyright 2014-2025, Marcel Rieger" 12 | __credits__ = ["Marcel Rieger"] 13 | __contact__ = "https://github.com/riga/pymitter" 14 | __license__ = "BSD-3-Clause" 15 | __status__ = "Development" 16 | __version__ = "1.0.0" 17 | __all__ = ["EventEmitter", "Listener"] 18 | 19 | import time 20 | import fnmatch 21 | import asyncio 22 | from collections.abc import Iterator, Awaitable 23 | from typing import Any, Callable, List, Optional, TypeVar, overload, Dict, Tuple 24 | 25 | F = TypeVar("F", bound=Callable[..., Any]) 26 | T = TypeVar("T") 27 | 28 | 29 | class EventEmitter: 30 | """ 31 | The EventEmitter class, ported from Node.js EventEmitter 2. 32 | 33 | When *wildcard* is *True*, wildcards in event names are taken into account. Event names have 34 | namespace support with each namspace being separated by a *delimiter* which defaults to ``"."``. 35 | 36 | When *new_listener* is *True*, a ``"new_listener"`` event is emitted every time a new listener 37 | is registered with arguments ``(func, event=None)``. *max_listeners* configures the total 38 | maximum number of event listeners. A negative numbers means that this number is unlimited. 39 | """ 40 | 41 | new_listener_event = "new_listener" 42 | 43 | def __init__( 44 | self, 45 | *, 46 | delimiter: str = ".", 47 | wildcard: bool = False, 48 | new_listener: bool = False, 49 | max_listeners: int = -1, 50 | ) -> None: 51 | # store attributes 52 | self.new_listener = new_listener 53 | self.max_listeners = max_listeners 54 | 55 | # tree of nodes keeping track of nested events 56 | self._event_tree = Tree(wildcard=wildcard, delimiter=delimiter) 57 | 58 | # flat list of listeners triggerd on "any" event 59 | self._any_listeners: List[Listener] = [] 60 | 61 | @property 62 | def num_listeners(self) -> int: 63 | return self._event_tree.num_listeners() + len(self._any_listeners) 64 | 65 | @overload 66 | def on(self, event: str, func: F, *, ttl: int = -1) -> F: 67 | ... 68 | 69 | @overload 70 | def on(self, event: str, *, ttl: int = -1) -> Callable[[F], F]: 71 | ... 72 | 73 | def on( 74 | self, 75 | event: str, 76 | func: Optional[F] = None, 77 | *, 78 | ttl: int = -1, 79 | ): 80 | """ 81 | Registers a function to an event. *ttl* defines the times to listen with negative values 82 | meaning infinity. When *func* is *None*, decorator usage is assumed. Returns the wrapped 83 | function. 84 | """ 85 | def on(func: F) -> F: 86 | # do not register the function when the maximum would be exceeded 87 | if 0 <= self.max_listeners <= self.num_listeners: 88 | return func 89 | 90 | # create a new listener and add it 91 | self._event_tree.add_listener(event, Listener(func, event, ttl)) 92 | 93 | if self.new_listener and event != self.new_listener_event: 94 | self.emit(self.new_listener_event, func, event) 95 | 96 | return func 97 | 98 | return on(func) if func else on 99 | 100 | @overload 101 | def once(self, event: str, func: F) -> F: 102 | ... 103 | 104 | @overload 105 | def once(self, event: str) -> Callable[[F], F]: 106 | ... 107 | 108 | def once(self, event: str, func: Optional[F] = None): 109 | """ 110 | Registers a function to an event that is called once. When *func* is *None*, decorator usage 111 | is assumed. Returns the wrapped function. 112 | """ 113 | return self.on(event, func, ttl=1) if func else self.on(event, ttl=1) 114 | 115 | @overload 116 | def on_any(self, func: F, *, ttl: int = -1) -> F: 117 | ... 118 | 119 | @overload 120 | def on_any(self, *, ttl: int = -1) -> Callable[[F], F]: 121 | ... 122 | 123 | def on_any(self, func: Optional[F] = None, *, ttl: int = -1): 124 | """ 125 | Registers a function that is called every time an event is emitted. *ttl* defines the times 126 | to listen with negative values meaning infinity. When *func* is *None*, decorator usage is 127 | assumed. Returns the wrapped function. 128 | """ 129 | def on_any(func: F) -> F: 130 | # do not register the function when the maximum would be exceeded 131 | if 0 <= self.max_listeners <= self.num_listeners: 132 | return func 133 | 134 | # create a new listener and add it 135 | self._any_listeners.append(Listener(func, "", ttl)) 136 | 137 | if self.new_listener: 138 | self.emit(self.new_listener_event, func) 139 | 140 | return func 141 | 142 | return on_any(func) if func else on_any 143 | 144 | @overload 145 | def off(self, event: str, func: F) -> F: 146 | ... 147 | 148 | @overload 149 | def off(self, event: str) -> Callable[[F], F]: 150 | ... 151 | 152 | def off(self, event: str, func: Optional[F] = None): 153 | """ 154 | Removes a function that is registered to an event. When *func* is *None*, decorator usage is 155 | assumed. Returns the wrapped function. 156 | """ 157 | def off(func: Callable) -> Callable: 158 | self._event_tree.remove_listeners_by_func(event, func) 159 | 160 | return func 161 | 162 | return off(func) if func else off 163 | 164 | @overload 165 | def off_any(self, func: F) -> F: 166 | ... 167 | 168 | @overload 169 | def off_any(self) -> Callable[[F], F]: 170 | ... 171 | 172 | def off_any(self, func: Optional[F] = None): 173 | """ 174 | Removes a function that was registered via :py:meth:`on_any`. When *func* is *None*, 175 | decorator usage is assumed. Returns the wrapped function. 176 | """ 177 | def off_any(func: F) -> F: 178 | self._any_listeners[:] = [ 179 | listener 180 | for listener in self._any_listeners 181 | if listener.func != func 182 | ] 183 | 184 | return func 185 | 186 | return off_any(func) if func else off_any 187 | 188 | def off_all(self) -> None: 189 | """ 190 | Removes all registered functions. 191 | """ 192 | self._event_tree.clear() 193 | del self._any_listeners[:] 194 | 195 | def listeners(self, event: str) -> List[Callable[..., Any]]: 196 | """ 197 | Returns all functions that are registered to an event. 198 | """ 199 | return [listener.func for listener in self._event_tree.find_listeners(event)] 200 | 201 | def listeners_any(self) -> List[Callable[..., Any]]: 202 | """ 203 | Returns all functions that were registered using :py:meth:`on_any`. 204 | """ 205 | return [listener.func for listener in self._any_listeners] 206 | 207 | def listeners_all(self) -> List[Callable[..., Any]]: 208 | """ 209 | Returns all registered functions, ordered by their registration time. 210 | """ 211 | listeners = list(self._any_listeners) 212 | nodes = list(self._event_tree.nodes.values()) 213 | while nodes: 214 | node = nodes.pop(0) 215 | nodes.extend(node.nodes.values()) 216 | listeners.extend(node.listeners) 217 | 218 | # sort them 219 | listeners = sorted(listeners, key=lambda listener: listener.time) 220 | 221 | return [listener.func for listener in listeners] 222 | 223 | def _emit(self, event: str, *args: Any, **kwargs: Any) -> List[Awaitable]: 224 | listeners = self._event_tree.find_listeners(event) 225 | if event != self.new_listener_event: 226 | listeners.extend(self._any_listeners) 227 | listeners = sorted(listeners, key=lambda listener: listener.time) 228 | 229 | # call listeners in order, keep track of awaitables from coroutines functions 230 | awaitables = [] 231 | for listener in listeners: 232 | # since listeners can emit events themselves, 233 | # deregister them before calling if needed 234 | if listener.ttl == 1: 235 | self.off(listener.event, func=listener.func) 236 | 237 | res = listener(*args, **kwargs) 238 | if listener.is_coroutine or listener.is_async_callable: 239 | awaitables.append(res) 240 | 241 | return awaitables 242 | 243 | def emit(self, event: str, *args: Any, **kwargs: Any) -> None: 244 | """ 245 | Emits an *event*. All functions of events that match *event* are invoked with *args* and 246 | *kwargs* in the exact order of their registration, with the exception of async functions 247 | that are invoked in a separate event loop. 248 | """ 249 | # emit normal functions and get awaitables of async ones 250 | awaitables = self._emit(event, *args, **kwargs) 251 | 252 | # handle awaitables 253 | if awaitables: 254 | async def start() -> None: 255 | await asyncio.gather(*awaitables) 256 | asyncio.run(start()) 257 | 258 | async def emit_async(self, event: str, *args: Any, **kwargs: Any) -> None: 259 | """ 260 | Awaitable version of :py:meth:`emit`. However, this method does not start a new event loop 261 | but uses the existing one. 262 | """ 263 | # emit normal functions and get awaitables of async ones 264 | awaitables = self._emit(event, *args, **kwargs) 265 | 266 | # handle awaitables 267 | if awaitables: 268 | await asyncio.gather(*awaitables) 269 | 270 | def emit_future(self, event: str, *args: Any, **kwargs: Any) -> None: 271 | """ 272 | Deferred version of :py:meth:`emit` with all awaitable events being places at the end of the 273 | existing event loop (using :py:func:`asyncio.ensure_future`). 274 | """ 275 | # emit normal functions and get awaitables of async ones 276 | awaitables = self._emit(event, *args, **kwargs) 277 | 278 | # handle awaitables 279 | if awaitables: 280 | asyncio.ensure_future(asyncio.gather(*awaitables)) 281 | 282 | 283 | class BaseNode: 284 | def __init__(self, wildcard: bool, delimiter: str) -> None: 285 | self.wildcard = wildcard 286 | self.delimiter = delimiter 287 | self.parent: "Optional[BaseNode]" = None 288 | self.nodes: Dict[str, "Node"] = {} 289 | 290 | def clear(self) -> None: 291 | self.nodes.clear() 292 | 293 | def add_node(self, node: "Node") -> "Node": 294 | # when there is a node with the exact same name (pattern), merge listeners 295 | if node.name in self.nodes: 296 | _node = self.nodes[node.name] 297 | _node.listeners.extend(node.listeners) 298 | return _node 299 | 300 | # otherwise add it and set its parent 301 | self.nodes[node.name] = node 302 | node.parent = self 303 | 304 | return node 305 | 306 | def walk_nodes(self) -> Iterator[Tuple[str, Tuple[str, ...], List[str]]]: 307 | queue = [ 308 | (name, [name], node) 309 | for name, node in self.nodes.items() 310 | ] 311 | while queue: 312 | name, path, node = queue.pop(0) 313 | 314 | # get names of child names 315 | child_names = list(node.nodes) 316 | 317 | # yield the name, the path leading to the node and names of child nodes that can be 318 | # adjusted in place by the outer context to change the traversal (similar to os.walk) 319 | yield (name, tuple(path), child_names) 320 | 321 | # add remaining child nodes 322 | queue.extend([ 323 | (child_name, path + [child_name], node.nodes[child_name]) 324 | for child_name in child_names 325 | ]) 326 | 327 | 328 | class Node(BaseNode): 329 | """ 330 | Actual named nodes containing listeners. 331 | """ 332 | 333 | @classmethod 334 | def str_is_pattern(cls, s: str) -> bool: 335 | return "*" in s or "?" in s 336 | 337 | def __init__(self, name: str, *args: Any) -> None: 338 | super().__init__(*args) 339 | 340 | self.name = name 341 | self.listeners: List[Listener] = [] 342 | 343 | def num_listeners(self, recursive: bool = True) -> int: 344 | n = len(self.listeners) 345 | 346 | if recursive: 347 | n += sum(node.num_listeners(recursive=recursive) for node in self.nodes.values()) 348 | 349 | return n 350 | 351 | def remove_listeners_by_func(self, func: Callable[..., Any]) -> None: 352 | self.listeners[:] = [listener for listener in self.listeners if listener.func != func] 353 | 354 | def add_listener(self, listener: Listener) -> None: 355 | self.listeners.append(listener) 356 | 357 | def check_name(self, pattern: str) -> bool: 358 | if self.wildcard: 359 | if self.str_is_pattern(pattern): 360 | return fnmatch.fnmatch(self.name, pattern) 361 | if self.str_is_pattern(self.name): 362 | return fnmatch.fnmatch(pattern, self.name) 363 | 364 | return self.name == pattern 365 | 366 | def find_nodes(self, event: str | List[str]) -> List[Node]: 367 | # trivial case 368 | if not event: 369 | return [] 370 | 371 | # parse event 372 | if isinstance(event, str): 373 | pattern, *sub_patterns = event.split(self.delimiter) 374 | else: 375 | pattern, sub_patterns = event[0], event[1:] 376 | 377 | # first make sure that pattern matches _this_ name 378 | if not self.check_name(pattern): 379 | return [] 380 | 381 | # when there are no sub patterns, return this one 382 | if not sub_patterns: 383 | return [self] 384 | 385 | # recursively match sub names with nodes 386 | return sum((node.find_nodes(sub_patterns) for node in self.nodes.values()), []) 387 | 388 | 389 | class Tree(BaseNode): 390 | """ 391 | Top-level node without a name or listeners, but providing higher-level node access. 392 | """ 393 | 394 | def num_listeners(self) -> int: 395 | return sum(node.num_listeners(recursive=True) for node in self.nodes.values()) 396 | 397 | def find_nodes(self, *args: Any, **kwargs: Any) -> List[Node]: 398 | return sum((node.find_nodes(*args, **kwargs) for node in self.nodes.values()), []) 399 | 400 | def add_listener(self, event: str, listener: Listener) -> None: 401 | # add nodes without evaluating wildcards, this is done during node lookup only 402 | names = event.split(self.delimiter) 403 | 404 | # lookup the deepest existing parent 405 | node = self 406 | while names: 407 | name = names.pop(0) 408 | if name in node.nodes: 409 | node = node.nodes[name] # type: ignore[assignment] 410 | else: 411 | new_node = Node(name, self.wildcard, self.delimiter) 412 | node.add_node(new_node) 413 | node = new_node # type: ignore[assignment] 414 | 415 | # add the listeners 416 | node.add_listener(listener) # type: ignore[arg-type, call-arg] 417 | 418 | def remove_listeners_by_func(self, event: str, func: Callable[..., Any]) -> None: 419 | for node in self.find_nodes(event): 420 | node.remove_listeners_by_func(func) 421 | 422 | def find_listeners(self, event: str, sort: bool = True) -> List[Listener]: 423 | listeners: List[Listener] = sum((node.listeners for node in self.find_nodes(event)), []) 424 | 425 | # sort by registration time 426 | if sort: 427 | listeners = sorted(listeners, key=lambda listener: listener.time) 428 | 429 | return listeners 430 | 431 | 432 | class Listener: 433 | """ 434 | A simple event listener class that wraps a function *func* for a specific *event* and that keeps 435 | track of the times to listen left. 436 | """ 437 | 438 | def __init__(self, func: Callable[..., Any], event: str, ttl: int) -> None: 439 | self.func = func 440 | self.event = event 441 | self.ttl = ttl 442 | 443 | # store the registration time 444 | self.time = time.monotonic() 445 | 446 | @property 447 | def is_coroutine(self) -> bool: 448 | return asyncio.iscoroutinefunction(self.func) 449 | 450 | @property 451 | def is_async_callable(self) -> bool: 452 | return asyncio.iscoroutinefunction(getattr(self.func, "__call__", None)) 453 | 454 | def __call__(self, *args: Any, **kwargs: Any) -> Any: 455 | """ 456 | Invokes the wrapped function when ttl is non-zero, decreases the ttl value when positive and 457 | returns its return value. 458 | """ 459 | result = None 460 | if self.ttl != 0: 461 | result = self.func(*args, **kwargs) 462 | 463 | if self.ttl > 0: 464 | self.ttl -= 1 465 | 466 | return result 467 | -------------------------------------------------------------------------------- /pymitter/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riga/pymitter/bcc4daee774b0a7bde9f5960bc97bc879d19ad71/pymitter/py.typed -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | 3 | requires = ["setuptools"] 4 | build-backend = "setuptools.build_meta" 5 | 6 | 7 | [project] 8 | 9 | name = "pymitter" 10 | description = "Python port of the extended Node.js EventEmitter 2 approach providing namespaces, wildcards and TTL." 11 | authors = [ 12 | {name = "Marcel Rieger", email = "github.riga@icloud.com"}, 13 | ] 14 | keywords = [ 15 | "event", 16 | "emitter", 17 | "eventemitter", 18 | "wildcard", 19 | "node", 20 | "nodejs", 21 | ] 22 | classifiers = [ 23 | "Programming Language :: Python", 24 | "Programming Language :: Python :: 3", 25 | "Programming Language :: Python :: 3.7", 26 | "Programming Language :: Python :: 3.8", 27 | "Programming Language :: Python :: 3.9", 28 | "Programming Language :: Python :: 3.10", 29 | "Programming Language :: Python :: 3.11", 30 | "Programming Language :: Python :: 3.12", 31 | "Programming Language :: Python :: 3.13", 32 | "Operating System :: OS Independent", 33 | "License :: OSI Approved :: BSD License", 34 | "Intended Audience :: Developers", 35 | "Intended Audience :: Science/Research", 36 | "Intended Audience :: Information Technology", 37 | ] 38 | license = {file = "LICENSE"} 39 | requires-python = ">=3.7" 40 | dynamic = ["version", "readme", "dependencies", "optional-dependencies"] 41 | 42 | 43 | [project.urls] 44 | 45 | Homepage = "https://github.com/riga/pymitter" 46 | Documentation = "https://pymitter.readthedocs.io" 47 | Repository = "https://github.com/riga/pymitter.git" 48 | 49 | 50 | [tool.setuptools.dynamic] 51 | 52 | version = {attr = "pymitter.__init__.__version__"} 53 | readme = {file = ["README.md"], content-type = "text/markdown"} 54 | dependencies = {file = ["requirements.txt"]} 55 | optional-dependencies = {dev = {file = ["requirements_dev.txt"]}} 56 | 57 | 58 | [tool.setuptools.packages.find] 59 | 60 | include = ["pymitter"] 61 | exclude = ["tests/*"] 62 | 63 | 64 | [tool.mypy] 65 | 66 | exclude = '(?x)(docs|tests)' 67 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riga/pymitter/bcc4daee774b0a7bde9f5960bc97bc879d19ad71/requirements.txt -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | flake8~=7.0.0;python_version>="3.8" 2 | flake8~=5.0.0;python_version<"3.8" 3 | flake8-commas~=2.1.0 4 | flake8-quotes~=3.3.2 5 | types-docutils~=0.20.0 6 | pytest-cov>=3.0 7 | mypy>=1.4.1 8 | typing-extensions>=4.7.1 9 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # flake8: noqa 3 | 4 | __all__ = [] 5 | 6 | # adjust the path to import pymitter 7 | import os 8 | import sys 9 | base = os.path.normpath(os.path.join(os.path.abspath(__file__), "../..")) 10 | sys.path.append(base) 11 | from pymitter import * 12 | 13 | # import all tests 14 | from .test_all import * 15 | -------------------------------------------------------------------------------- /tests/test_all.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import unittest 4 | import sys 5 | import asyncio 6 | 7 | from pymitter import EventEmitter 8 | 9 | 10 | class SyncTestCase(unittest.TestCase): 11 | 12 | def test_callback_usage(self): 13 | ee = EventEmitter() 14 | stack = [] 15 | 16 | def handler(arg): 17 | stack.append("callback_usage_" + arg) 18 | 19 | ee.on("callback_usage", handler) 20 | 21 | ee.emit("callback_usage", "foo") 22 | self.assertTrue(tuple(stack) == ("callback_usage_foo",)) 23 | 24 | def test_decorator_usage(self): 25 | ee = EventEmitter() 26 | stack = [] 27 | 28 | @ee.on("decorator_usage") 29 | def handler(arg): 30 | stack.append("decorator_usage_" + arg) 31 | 32 | ee.emit("decorator_usage", "bar") 33 | self.assertTrue(tuple(stack) == ("decorator_usage_bar",)) 34 | 35 | def test_ttl_on(self): 36 | ee = EventEmitter() 37 | stack = [] 38 | 39 | @ee.on("ttl_on", ttl=1) 40 | def handler(arg): 41 | stack.append("ttl_on_" + arg) 42 | 43 | ee.emit("ttl_on", "foo") 44 | self.assertTrue(tuple(stack) == ("ttl_on_foo",)) 45 | 46 | ee.emit("ttl_on", "bar") 47 | self.assertTrue(tuple(stack) == ("ttl_on_foo",)) 48 | 49 | def test_ttl_once(self): 50 | ee = EventEmitter() 51 | stack = [] 52 | 53 | @ee.once("ttl_once") 54 | def handler(arg): 55 | stack.append("ttl_once_" + arg) 56 | 57 | ee.emit("ttl_once", "foo") 58 | self.assertTrue(tuple(stack) == ("ttl_once_foo",)) 59 | 60 | ee.emit("ttl_once", "bar") 61 | self.assertTrue(tuple(stack) == ("ttl_once_foo",)) 62 | 63 | def test_walk_nodes(self): 64 | # helper to recursively convert itertables to tuples 65 | def t(iterable): 66 | return tuple((obj if isinstance(obj, str) else t(obj)) for obj in iterable) 67 | 68 | ee = EventEmitter() 69 | self.assertEqual( 70 | t(ee._event_tree.walk_nodes()), 71 | (), 72 | ) 73 | 74 | # normal traversal 75 | ee.on("foo.bar.test")(lambda: None) 76 | ee.on("foo.bar.test2")(lambda: None) 77 | self.assertEqual( 78 | t(ee._event_tree.walk_nodes()), 79 | ( 80 | ("foo", ("foo",), ("bar",)), 81 | ("bar", ("foo", "bar"), ("test", "test2")), 82 | ("test", ("foo", "bar", "test"), ()), 83 | ("test2", ("foo", "bar", "test2"), ()), 84 | ), 85 | ) 86 | 87 | # empty tree after removing all listeners 88 | ee.off_all() 89 | self.assertEqual( 90 | t(ee._event_tree.walk_nodes()), 91 | (), 92 | ) 93 | 94 | # traversal with in-place updates 95 | ee.on("foo.bar.test")(lambda: None) 96 | ee.on("foo.bar.test2")(lambda: None) 97 | stack = [] 98 | for name, path, children in ee._event_tree.walk_nodes(): 99 | stack.append(t((name, path, children))) 100 | if name == "bar": 101 | children.remove("test2") 102 | self.assertEqual( 103 | t(stack), 104 | ( 105 | ("foo", ("foo",), ("bar",)), 106 | ("bar", ("foo", "bar"), ("test", "test2")), 107 | ("test", ("foo", "bar", "test"), ()), 108 | ), 109 | ) 110 | 111 | def test_on_wildcards(self): 112 | def hits(handle: str, emit: str) -> bool: 113 | ee = EventEmitter(wildcard=True) 114 | stack = [] 115 | token = object() 116 | 117 | @ee.on(handle) 118 | def handler(): 119 | stack.append(token) 120 | 121 | ee.emit(emit) 122 | return tuple(stack) == (token,) 123 | 124 | self.assertTrue(hits("on_all.*", "on_all.foo")) 125 | self.assertTrue(hits("on_all.foo", "on_all.*")) 126 | self.assertTrue(hits("on_all.*", "on_all.*")) 127 | 128 | self.assertTrue(hits("on_all.foo.bar", "on_all.*.bar")) 129 | self.assertTrue(hits("on_all.*.bar", "on_all.foo.bar")) 130 | 131 | self.assertTrue(hits("on_all.fo?", "on_all.foo")) 132 | self.assertTrue(hits("on_all.foo", "on_all.fo?")) 133 | self.assertTrue(hits("on_all.?", "on_all.?")) 134 | 135 | self.assertFalse(hits("on_all.f?", "on_all.foo")) 136 | self.assertFalse(hits("on_all.foo", "on_all.f?")) 137 | self.assertFalse(hits("on_all.f?", "on_all.?")) 138 | self.assertFalse(hits("on_all.?", "on_all.f?")) 139 | 140 | self.assertFalse(hits("on_all.foo.bar", "on_all.*")) 141 | self.assertFalse(hits("on_all.*", "on_all.foo.bar")) 142 | 143 | def test_on_any(self): 144 | ee = EventEmitter() 145 | stack = [] 146 | 147 | @ee.on("foo") 148 | def handler1(): 149 | stack.append("foo") 150 | 151 | @ee.on_any() 152 | def handler2(): 153 | stack.append("bar") 154 | 155 | ee.emit("foo") 156 | self.assertEqual(tuple(stack), ("foo", "bar")) 157 | 158 | def test_off_any(self): 159 | ee = EventEmitter() 160 | stack = [] 161 | 162 | @ee.on_any 163 | def handler1(): 164 | stack.append("foo") 165 | 166 | ee.emit("xyz") 167 | self.assertEqual(tuple(stack), ("foo",)) 168 | 169 | del stack[:] 170 | ee.off_any(handler1) 171 | 172 | ee.emit("xyz") 173 | self.assertEqual(tuple(stack), ()) 174 | self.assertEqual(ee.num_listeners, 0) 175 | 176 | def test_off_all(self): 177 | ee = EventEmitter() 178 | 179 | @ee.on_any 180 | def handler1(): 181 | pass 182 | 183 | @ee.on("foo") 184 | def handler2(): 185 | pass 186 | 187 | self.assertEqual(ee.num_listeners, 2) 188 | 189 | ee.off_all() 190 | self.assertEqual(ee.num_listeners, 0) 191 | 192 | def test_listeners(self): 193 | ee = EventEmitter(wildcard=True) 194 | 195 | @ee.on("foo") 196 | def h1(): 197 | pass 198 | 199 | @ee.on("foo") 200 | def h2(): 201 | pass 202 | 203 | @ee.on("bar") 204 | def h3(): 205 | pass 206 | 207 | @ee.once("baz") 208 | def h4(): 209 | pass 210 | 211 | @ee.on_any 212 | def h5(): 213 | pass 214 | 215 | self.assertEqual(tuple(ee.listeners_any()), (h5,)) 216 | self.assertEqual(tuple(ee.listeners_all()), (h1, h2, h3, h4, h5)) 217 | self.assertEqual(tuple(ee.listeners("foo")), (h1, h2)) 218 | self.assertEqual(tuple(ee.listeners("bar")), (h3,)) 219 | self.assertEqual(tuple(ee.listeners("ba?")), (h3, h4)) 220 | 221 | def test_emit_all(self): 222 | ee = EventEmitter(wildcard=True) 223 | stack = [] 224 | 225 | @ee.on("emit_all.foo") 226 | def handler(): 227 | stack.append("emit_all.foo") 228 | 229 | ee.emit("emit_all.*") 230 | self.assertTrue(stack[-1] == "emit_all.foo") 231 | 232 | def test_on_reverse_pattern(self): 233 | ee = EventEmitter(wildcard=True) 234 | stack = [] 235 | 236 | @ee.on("foo.bar") 237 | def handler1(): 238 | stack.append("on_foo_bar") 239 | 240 | @ee.on("foo.baz") 241 | def handler2(): 242 | stack.append("on_foo_baz") 243 | 244 | @ee.on("foo.bar.baz.test") 245 | def handler3(): 246 | stack.append("on_foo_bar_baz_test") 247 | 248 | ee.emit("foo.ba?") 249 | self.assertTrue(tuple(stack) == ("on_foo_bar", "on_foo_baz")) 250 | 251 | del stack[:] 252 | ee.emit("foo.bar.*.test") 253 | self.assertTrue(tuple(stack) == ("on_foo_bar_baz_test",)) 254 | 255 | def test_delimiter(self): 256 | ee = EventEmitter(wildcard=True, delimiter=":") 257 | stack = [] 258 | 259 | @ee.on("delimiter:*") 260 | def handler(): 261 | stack.append("delimiter") 262 | 263 | ee.emit("delimiter:foo") 264 | self.assertTrue(tuple(stack) == ("delimiter",)) 265 | 266 | def test_new(self): 267 | ee = EventEmitter(new_listener=True) 268 | stack = [] 269 | 270 | @ee.on("new_listener") 271 | def handler(func, event=None): 272 | stack.append((func, event)) 273 | 274 | def newhandler(): 275 | pass 276 | ee.on("new", newhandler) 277 | ee.on_any(newhandler) 278 | 279 | self.assertTrue(tuple(stack) == ((newhandler, "new"), (newhandler, None))) 280 | 281 | def test_max(self): 282 | ee = EventEmitter(max_listeners=1) 283 | stack = [] 284 | 285 | @ee.on("max") 286 | def handler1(): 287 | stack.append("max_1") 288 | 289 | @ee.on("max") 290 | def handler2(): 291 | stack.append("max_2") 292 | 293 | ee.emit("max") 294 | self.assertTrue(tuple(stack) == ("max_1",)) 295 | 296 | def test_tree(self): 297 | ee = EventEmitter() 298 | stack = [] 299 | 300 | @ee.on("max") 301 | def handler1(): 302 | stack.append("max_1") 303 | 304 | @ee.once("max") 305 | def handler2(): 306 | stack.append("max_2") 307 | 308 | self.assertEqual(ee.num_listeners, 2) 309 | self.assertEqual(len(ee._event_tree.nodes["max"].listeners), 2) 310 | 311 | ee.emit("max") 312 | self.assertTrue(tuple(stack) == ("max_1", "max_2")) 313 | del stack[:] 314 | 315 | ee.emit("max") 316 | self.assertTrue(tuple(stack) == ("max_1",)) 317 | del stack[:] 318 | 319 | self.assertEqual(ee.num_listeners, 1) 320 | self.assertTrue("max" in ee._event_tree.nodes) 321 | self.assertEqual(len(ee._event_tree.nodes["max"].listeners), 1) 322 | 323 | ee.off("max", handler1) 324 | self.assertEqual(ee.num_listeners, 0) 325 | 326 | 327 | if sys.version_info[:2] >= (3, 8): 328 | 329 | class AsyncTestCase(unittest.IsolatedAsyncioTestCase): 330 | 331 | def test_async_callback_usage(self): 332 | ee = EventEmitter() 333 | stack = [] 334 | 335 | async def handler(arg): 336 | stack.append("async_callback_usage_" + arg) 337 | 338 | ee.on("async_callback_usage", handler) 339 | 340 | ee.emit("async_callback_usage", "foo") 341 | self.assertEqual(tuple(stack), ("async_callback_usage_foo",)) 342 | 343 | def test_async_decorator_usage(self): 344 | ee = EventEmitter() 345 | stack = [] 346 | 347 | @ee.on("async_decorator_usage") 348 | async def handler(arg): 349 | stack.append("async_decorator_usage_" + arg) 350 | 351 | ee.emit("async_decorator_usage", "bar") 352 | self.assertEqual(tuple(stack), ("async_decorator_usage_bar",)) 353 | 354 | async def test_await_async_callback_usage(self): 355 | ee = EventEmitter() 356 | stack = [] 357 | 358 | async def handler(arg): 359 | stack.append("await_async_callback_usage_" + arg) 360 | 361 | ee.on("await_async_callback_usage", handler) 362 | 363 | res = ee.emit_async("await_async_callback_usage", "foo") 364 | self.assertEqual(len(stack), 0) 365 | 366 | await res 367 | self.assertEqual(tuple(stack), ("await_async_callback_usage_foo",)) 368 | 369 | async def test_await_async_decorator_usage(self): 370 | ee = EventEmitter() 371 | stack = [] 372 | 373 | @ee.on("await_async_decorator_usage") 374 | async def handler(arg): 375 | stack.append("await_async_decorator_usage_" + arg) 376 | 377 | res = ee.emit_async("await_async_decorator_usage", "bar") 378 | self.assertEqual(len(stack), 0) 379 | 380 | await res 381 | self.assertEqual(tuple(stack), ("await_async_decorator_usage_bar",)) 382 | 383 | async def test_emit_future(self): 384 | ee = EventEmitter() 385 | stack = [] 386 | 387 | @ee.on("emit_future") 388 | async def handler(arg): 389 | stack.append("emit_future_" + arg) 390 | 391 | async def test(): 392 | ee.emit_future("emit_future", "bar") 393 | self.assertEqual(len(stack), 0) 394 | 395 | # let all non-deferred events on the event loop pass 396 | await asyncio.sleep(0) 397 | 398 | self.assertEqual(tuple(stack), ("emit_future_bar",)) 399 | 400 | await test() 401 | 402 | def test_supports_async_callables(self): 403 | ee = EventEmitter() 404 | stack = [] 405 | 406 | class EventHandler: 407 | async def __call__(self, arg): 408 | stack.append(arg) 409 | 410 | ee.on("event", EventHandler()) 411 | 412 | ee.emit("event", "arg") 413 | self.assertEqual(stack, ["arg"]) 414 | --------------------------------------------------------------------------------