├── .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 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
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 |
--------------------------------------------------------------------------------