aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoruael <uael@google.com>2023-02-28 19:28:13 +0000
committerAutomerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>2023-02-28 19:28:13 +0000
commitd92a09e29802087aa99e68907f01b8e22ebf64b5 (patch)
treef8212a46b7996e853daab468a74a7619c457f38f
parent44d17fd923adddf255c2750de2e4faeef2a27cc7 (diff)
parent52e70c9fc394d2924c69d596f7012691d577fe0f (diff)
downloadpyee-d92a09e29802087aa99e68907f01b8e22ebf64b5.tar.gz
Merge remote-tracking branch 'aosp/upstream-main' into master am: cb4c0971bc am: f125ae6e90 am: 5a4b11e827 am: 52e70c9fc3
Original change: https://android-review.googlesource.com/c/platform/external/python/pyee/+/2441564 Change-Id: Ia040d6914eb25bab2402a990456fb300e1d5e6bd Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
-rw-r--r--.github/workflows/qa.yaml34
-rw-r--r--.gitignore15
-rw-r--r--.readthedocs.yaml12
-rw-r--r--CHANGELOG.rst240
-rw-r--r--CONTRIBUTORS.rst16
-rw-r--r--DEVELOPMENT.rst123
-rw-r--r--LICENSE21
-rw-r--r--MANIFEST.in8
-rw-r--r--Makefile48
-rw-r--r--README.rst38
-rw-r--r--docs/Makefile225
-rw-r--r--docs/conf.py343
-rw-r--r--docs/index.rst63
-rw-r--r--environment.yml14
-rw-r--r--package-lock.json37
-rw-r--r--package.json22
-rw-r--r--pyee/__init__.py138
-rw-r--r--pyee/asyncio.py73
-rw-r--r--pyee/base.py239
-rw-r--r--pyee/cls.py112
-rw-r--r--pyee/executor.py79
-rw-r--r--pyee/py.typed0
-rw-r--r--pyee/trio.py129
-rw-r--r--pyee/twisted.py93
-rw-r--r--pyee/uplift.py178
-rw-r--r--pyproject.toml6
-rw-r--r--pytest.ini3
-rw-r--r--requirements.txt1
-rw-r--r--requirements_dev.txt14
-rw-r--r--requirements_docs.txt3
-rw-r--r--setup.cfg3
-rw-r--r--setup.py40
-rw-r--r--tests/conftest.py11
-rw-r--r--tests/test_async.py190
-rw-r--r--tests/test_cls.py47
-rw-r--r--tests/test_executor.py61
-rw-r--r--tests/test_sync.py280
-rw-r--r--tests/test_trio.py112
-rw-r--r--tests/test_twisted.py80
-rw-r--r--tests/test_uplift.py201
-rw-r--r--tox.ini9
-rw-r--r--typings/twisted/python/failure.pyi5
42 files changed, 3366 insertions, 0 deletions
diff --git a/.github/workflows/qa.yaml b/.github/workflows/qa.yaml
new file mode 100644
index 0000000..f81ebab
--- /dev/null
+++ b/.github/workflows/qa.yaml
@@ -0,0 +1,34 @@
+name: QA
+on: pull_request
+jobs:
+ qa:
+ name: Run QA checks
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ python-version: ["3.7", "3.8", "3.9", "3.10"]
+ steps:
+ - uses: actions/checkout@v2
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v2
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Set up Node.js @latest
+ uses: actions/setup-node@v2
+ with:
+ node-version: 16
+ - name: Install the world
+ run: |
+ python -m pip install --upgrade pip wheel
+ pip install -r requirements.txt
+ pip install -r requirements_dev.txt
+ pip install -e .
+ npm i
+ - name: Run linting
+ run: |
+ make lint
+ - name: Run type checking
+ run: |
+ make check
+ - name: Run tests
+ run: make test
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..84cde9e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+*.pyc
+docs/_build
+dist/*
+build/*
+MANIFEST
+README
+.cache
+.eggs
+.python-version
+pyee.egg-info/
+version.txt
+scratchpad.ipynb
+.tox/
+node_modules
+venv
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 0000000..6bf9a2d
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,12 @@
+version: 2
+
+sphinx:
+ configuration: docs/conf.py
+
+formats:
+ - pdf
+
+python:
+ version: "3.8"
+ install:
+ - requirements: requirements_docs.txt
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
new file mode 100644
index 0000000..7b2e750
--- /dev/null
+++ b/CHANGELOG.rst
@@ -0,0 +1,240 @@
+2022/02/04 Version 9.0.4
+------------------------
+
+- Add ``py.typed`` file to ``MANIFEST.in`` (ensures mypy actually respects the
+ type annotations)
+
+2022/01/18 Version 9.0.3
+------------------------
+
+- Improve type safety of ``EventEmitter#on``, ``EventEmitter#add_listener``
+ and ``EventEmitter#listens_to`` by parameterizing the ``Handler``
+- Minor fixes to documentation
+
+2022/01/17 Version 9.0.2
+------------------------
+
+- Add ``tests_require`` to setup.py, fixing COPR build
+- Install as an editable package in ``environment.yml`` and
+ ``requirements_docs.txt``, fixing Conda workflows and ReadTheDocs
+ respectively
+
+2022/01/17 Version 9.0.1
+------------------------
+
+- Fix regression where ``EventEmitter#listeners`` began crashing when called
+ with uninitialized listeners
+
+2022/01/17 Version 9.0.0
+------------------------
+
+Compatibility:
+
+- Drop 3.6 support
+
+New features:
+
+- New ``EventEmitter.event_names()`` method (see PR #96)
+- Type annotations and type checking with ``pyright``
+- Exprimental ``pyee.cls`` module exposing an ``@evented`` class decorator
+ and a ``@on`` method decorator (see PR #84)
+
+Moved/deprecated interfaces:
+
+- ``pyee.TwistedEventEmitter`` -> ``pyee.twisted.TwistedEventEmitter``
+- ``pyee.AsyncIOEventEmitter`` -> ``pyee.asyncio.AsyncIOEventEmitter``
+- ``pyee.ExecutorEventEmitter`` -> ``pyee.executor.ExecutorEventEmitter``
+- ``pyee.TrioEventEmitter`` -> ``pyee.trio.TrioEventEmitter``
+
+Removed interfaces:
+
+- ``pyee.CompatEventEmitter``
+
+Documentation fixes:
+
+- Add docstring to ``BaseEventEmitter``
+- Update docstrings to reference ``EventEmitter`` instead of ``BaseEventEmitter``
+ throughout
+
+Developer Setup & CI:
+
+- Migrated builds from Travis to GitHub Actions
+- Refactor developer setup to use a local virtualenv
+
+2021/8/14 Version 8.2.2
+-----------------------
+
+- Correct version in docs
+
+2021/8/14 Version 8.2.1
+-----------------------
+
+- Add .readthedocs.yaml file
+- Remove vcversioner dependency from docs build
+
+
+2021/8/14 Version 8.2.0
+-----------------------
+
+- Remove test_requires and setup_requires directives from setup.py (closing #82)
+- Remove vcversioner from dependencies
+- Streamline requirements.txt and environment.yml files
+- Update and extend CONTRIBUTING.rst
+- CI with GitHub Actions instead of Travis (closing #56)
+- Format all code with black
+- Switch default branch to ``main``
+- Add the CHANGELOG to Sphinx docs (closing #51)
+- Updated copyright information
+
+2020/10/08 Version 8.1.0
+------------------------
+- Improve thread safety in base EventEmitter
+- Documentation fix in ExecutorEventEmitter
+
+2020/09/20 Version 8.0.1
+------------------------
+- Update README to reflect new API
+
+2020/09/20 Version 8.0.0
+------------------------
+- Drop support for Python 2.7
+- Remove CompatEventEmitter and rename BaseEventEmitter to EventEmitter
+- Create an alias for BaseEventEmitter with a deprecation warning
+
+2020/09/20 Version 7.0.4
+------------------------
+- setup_requires vs tests_require now correct
+- tests_require updated to pass in tox
+- 3.7 testing removed from tox
+- 2.7 testing removed from Travis
+
+2020/09/04 Version 7.0.3
+------------------------
+- Tag license as MIT in setup.py
+- Update requirements and environment to pip -e the package
+
+2020/05/12 Version 7.0.2
+------------------------
+- Support Python 3.8 by attempting to import TimeoutError from
+ ``asyncio.exceptions``
+- Add LICENSE to package manifest
+- Add trio testing to tox
+- Add Python 3.8 to tox
+- Fix Python 2.7 in tox
+
+2020/01/30 Version 7.0.1
+------------------------
+- Some tweaks to the docs
+
+2020/01/30 Version 7.0.0
+------------------------
+- Added a ``TrioEventEmitter`` class for intended use with trio
+- ``AsyncIOEventEmitter`` now correctly handles cancellations
+- Add a new experimental ``pyee.uplift`` API for adding new functionality to
+ existing event emitters
+
+2019/04/11 Version 6.0.0
+------------------------
+- Added a ``BaseEventEmitter`` class which is entirely synchronous and
+ intended for simple use and for subclassing
+- Added an ``AsyncIOEventEmitter`` class for intended use with asyncio
+- Added a ``TwistedEventEmitter`` class for intended use with twisted
+- Added an ``ExecutorEventEmitter`` class which runs events in an executor
+- Deprecated ``EventEmitter`` (use one of the new classes)
+
+
+2017/11/18 Version 5.0.0
+------------------------
+
+- CHANGELOG.md reformatted to CHANGELOG.rst
+- Added CONTRIBUTORS.rst
+- The `listeners` method no longer returns the raw list of listeners, and
+ instead returns a list of unwrapped listeners; This means that mutating
+ listeners on the EventEmitter by mutating the list returned by
+ this method isn't possible anymore, and that for once handlers this method
+ returns the unwrapped handler rather than the wrapped handler
+- `once` API now returns the unwrapped handler in both decorator and
+ non-decorator cases
+- Possible to remove once handlers with unwrapped handlers
+- Internally, listeners are now stored on a OrderedDict rather than a list
+- Minor stylistic tweaks to make code more pythonic
+
+2017/11/17 Version 4.0.1
+------------------------
+
+- Fix bug in setup.py; Now publishable
+
+2017/11/17 Version 4.0.0
+------------------------
+
+- Coroutines now work with .once
+- Wrapped listener is removed prior to hook execution rather than after for
+ synchronous .once handlers
+
+2017/02/12 Version 3.0.3
+------------------------
+
+- Add universal wheel
+
+2017/02/10 Version 3.0.2
+------------------------
+
+- EventEmitter now inherits from object
+
+2016/10/02 Version 3.0.1
+------------------------
+
+- Fixes/Updates to pyee docs
+- Uses vcversioner for managing version information
+
+2016/10/02 Version 3.0.0
+------------------------
+
+- Errors resulting from async functions are now proxied to the "error"
+ event, rather than being lost into the aether.
+
+2016/10/01 Version 2.0.3
+------------------------
+
+- Fix setup.py broken in python 2.7
+- Add link to CHANGELOG in README
+
+2016/10/01 Version 2.0.2
+------------------------
+
+- Fix RST render warnings in README
+
+2016/10/01 Version 2.0.1
+------------------------
+
+- Add README contents as long\_description inside setup.py
+
+2016/10/01 Version 2.0.0
+------------------------
+
+- Drop support for pythons 3.2, 3.3 and 3.4 (support 2.7 and 3.5)
+- Use pytest instead of nose
+- Removed Event\_emitter alias
+- Code passes flake8
+- Use setuptools (no support for users without setuptools)
+- Reogranized docs, hosted on readthedocs.org
+- Support for scheduling coroutine functions passed to `@ee.on`
+
+2016/02/15 Version 1.0.2
+------------------------
+
+- Make copy of event handlers array before iterating on emit
+
+2015/09/21 Version 1.0.1
+------------------------
+
+- Change URLs to reference jfhbrook
+
+2015/09/20 Version 1.0.0
+------------------------
+
+- Decorators return original function for `on` and `once`
+- Explicit python 3 support
+- Addition of legit license file
+- Addition of CHANGELOG.md
+- Now properly using semver
diff --git a/CONTRIBUTORS.rst b/CONTRIBUTORS.rst
new file mode 100644
index 0000000..231b35e
--- /dev/null
+++ b/CONTRIBUTORS.rst
@@ -0,0 +1,16 @@
+General format is: contributor, github handle, email.
+
+Listed in no particular order:
+
+- Josh Holbrook @jfhbrook <josh.holbrook@gmail.com>
+- Gleicon Moraes @gleicon <gleicon@gmail.com>
+- Zack Do @doboy <doboy0@gmail.com>
+- @Zearin
+- René Kijewski @Kijewski
+- Gabe Appleton @gappleto97
+- Daniel M. Capella @polyzen <polyzen@archlinux.org>
+- Fabian Affolter @fabaff <mail@fabian-affolter.ch>
+- Anton Bolshakov @blshkv
+- Åke Forslund @forslund <ake.forslund@gmail.com>
+- Ivan Gretchka @leirons
+- Max Schmitt @mxschmitt
diff --git a/DEVELOPMENT.rst b/DEVELOPMENT.rst
new file mode 100644
index 0000000..b350f46
--- /dev/null
+++ b/DEVELOPMENT.rst
@@ -0,0 +1,123 @@
+Development And Publishing
+==========================
+
+Environment Setup
+-----------------
+
+To create a local virtualenv, run::
+
+ make setup
+
+This will create a virtualenv at ``./venv``, install dependencies with pip,
+and install pyright using npm.
+
+To activate the environment in your shell::
+
+ . ./venv/bin/activate
+
+Alternately, run everything with the make tasks, which source the activate
+script before running commands.
+
+conda
+~~~~~
+
+To create a Conda environment, run::
+
+ conda env create
+ npm i
+
+To update the environment, run::
+
+ conda env update
+ npm i --update
+
+To activate the environment, run::
+
+ conda activate pyee
+
+The other Makefile tasks should operate normally if the environment is
+activated.
+
+Formatting, Linting and Testing
+-------------------------------
+
+The basics are wrapped with a Makefile::
+
+ make format # runs black
+ make lint # runs flake8
+ make test # runs pytest
+
+Generating Docs
+---------------
+
+Docs for published projects are automatically generated by readthedocs, but
+you can also preview them locally by running::
+
+ make build_docs
+
+Then, you can serve them with Python's dev server with::
+
+ make serve_docs
+
+Publishing
+----------
+
+Do a Final Check
+~~~~~~~~~~~~~~~~
+
+Make sure that formatting looks good and that linting and testing are passing.
+
+Update the Changelog
+~~~~~~~~~~~~~~~~~~~~
+
+Update the CHANGELOG.rst file to detail the changes being rolled into the new
+version.
+
+Update the Version in setup.py
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+This project *used* to use ``vcversioner`` and versioning of the package
+would automatically leverage the appropriate git tag, but that is no longer the
+case.
+
+I do my best to follow `semver <https://semver.org>` when updating versions.
+
+Add a Git Tag
+~~~~~~~~~~~~~
+
+This project uses git tags to tag versions::
+
+ git tag -a {version} -m 'Release {version}'
+
+You don't need to prefix the version with a ``v``.
+
+Build and Publish
+~~~~~~~~~~~~~~~~~
+
+To package everything, run::
+
+ make package
+
+To publish::
+
+ make publish
+
+Push the Tag to GitHub
+~~~~~~~~~~~~~~~~~~~~~~
+
+::
+
+ git push origin main --tags
+
+Check on RTD
+~~~~~~~~~~~~
+
+RTD should build automatically but I find there's a delay so I like to kick it
+off manually. Log into `RTD <https://readthedocs.org>`, log in, then go
+to `the pyee project page <https://readthedocs.org/projects/pyee/>` and build
+latest and stable.
+
+Announce on Twitter
+~~~~~~~~~~~~~~~~~~~
+
+It's not official, but I like to announce the release on Twitter.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..67dd129
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2021 Josh Holbrook
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..91b0e6b
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,8 @@
+include LICENSE
+include README.rst
+include CHANGELOG.rst
+include CONTRIBUTORS.rst
+include DEVELOPMENT.rst
+include version.txt
+include pyee/py.typed
+recursive-include tests *.py
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..9eba7d8
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,48 @@
+.PHONY: setup setup-conda package upload check test tox lint format build_docs serve_docs clean
+
+setup:
+ python3 -m venv venv
+ if [ -d venv ]; then . ./venv/bin/activate; fi; pip install pip wheel --upgrade
+ if [ -d venv ]; then . ./venv/bin/activate; fi; pip install -r requirements.txt
+ if [ -d venv ]; then . ./venv/bin/activate; fi; pip install -r requirements_dev.txt
+ if [ -d venv ]; then . ./venv/bin/activate; fi; pip install -e .
+ npm i
+
+package: test lint
+ if [ -d venv ]; then . ./venv/bin/activate; fi; python setup.py check
+ if [ -d venv ]; then . ./venv/bin/activate; fi; python setup.py sdist
+ if [ -d venv ]; then . ./venv/bin/activate; fi; python setup.py bdist_wheel --universal
+
+upload:
+ if [ -d venv ]; then . ./venv/bin/activate; fi; twine upload dist/*
+
+check:
+ if [ -d venv ]; then . ./venv/bin/activate; fi; npm run pyright
+
+test:
+ if [ -d venv ]; then . ./venv/bin/activate; fi; pytest ./tests
+
+tox:
+ if [ -d venv ]; then . ./venv/bin/activate; fi; tox
+
+lint:
+ if [ -d venv ]; then . ./venv/bin/activate; fi; flake8 ./pyee setup.py ./tests ./docs
+
+format:
+ if [ -d venv ]; then . ./venv/bin/activate; fi; black ./pyee setup.py ./tests ./docs
+ if [ -d venv ]; then . ./venv/bin/activate; fi; isort ./pyee setup.py ./tests ./docs
+
+build_docs:
+ if [ -d venv ]; then . ./venv/bin/activate; fi; cd docs && make html
+
+serve_docs: build_docs
+ if [ -d venv ]; then . ./venv/bin/activate; fi; cd docs/_build/html && python -m http.server
+
+clean:
+ rm -rf .tox
+ rm -rf dist
+ rm -rf pyee.egg-info
+ rm -rf pyee/*.pyc
+ rm -rf pyee/__pycache__
+ rm -rf pytest_runner-*.egg
+ rm -rf tests/__pycache__
diff --git a/README.rst b/README.rst
new file mode 100644
index 0000000..a31a220
--- /dev/null
+++ b/README.rst
@@ -0,0 +1,38 @@
+pyee
+====
+
+.. image:: https://travis-ci.org/jfhbrook/pyee.png
+ :target: https://travis-ci.org/jfhbrook/pyee
+.. image:: https://readthedocs.org/projects/pyee/badge/?version=latest
+ :target: https://pyee.readthedocs.io
+
+pyee supplies a ``EventEmitter`` object that is similar to the
+``EventEmitter`` class from Node.js. It also supplies a number of subclasses
+with added support for async and threaded programming in python, such as
+async/await as seen in python 3.5+.
+
+Docs:
+-----
+
+Autogenerated API docs, including basic installation directions and examples,
+can be found at https://pyee.readthedocs.io .
+
+Development:
+------------
+
+See ``DEVELOPMENT.rst``.
+
+Changelog:
+----------
+
+See ``CHANGELOG.rst``.
+
+Contributors:
+-------------
+
+See ``CONTRIBUTORS.rst``.
+
+License:
+--------
+
+MIT/X11, see ``LICENSE``.
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 0000000..011dc88
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,225 @@
+# Makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS =
+SPHINXBUILD = sphinx-build
+PAPER =
+BUILDDIR = _build
+
+# Internal variables.
+PAPEROPT_a4 = -D latex_paper_size=a4
+PAPEROPT_letter = -D latex_paper_size=letter
+ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
+# the i18n builder cannot share the environment and doctrees with the others
+I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
+
+.PHONY: help
+help:
+ @echo "Please use \`make <target>' where <target> is one of"
+ @echo " html to make standalone HTML files"
+ @echo " dirhtml to make HTML files named index.html in directories"
+ @echo " singlehtml to make a single large HTML file"
+ @echo " pickle to make pickle files"
+ @echo " json to make JSON files"
+ @echo " htmlhelp to make HTML files and a HTML help project"
+ @echo " qthelp to make HTML files and a qthelp project"
+ @echo " applehelp to make an Apple Help Book"
+ @echo " devhelp to make HTML files and a Devhelp project"
+ @echo " epub to make an epub"
+ @echo " epub3 to make an epub3"
+ @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
+ @echo " latexpdf to make LaTeX files and run them through pdflatex"
+ @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
+ @echo " text to make text files"
+ @echo " man to make manual pages"
+ @echo " texinfo to make Texinfo files"
+ @echo " info to make Texinfo files and run them through makeinfo"
+ @echo " gettext to make PO message catalogs"
+ @echo " changes to make an overview of all changed/added/deprecated items"
+ @echo " xml to make Docutils-native XML files"
+ @echo " pseudoxml to make pseudoxml-XML files for display purposes"
+ @echo " linkcheck to check all external links for integrity"
+ @echo " doctest to run all doctests embedded in the documentation (if enabled)"
+ @echo " coverage to run coverage check of the documentation (if enabled)"
+ @echo " dummy to check syntax errors of document sources"
+
+.PHONY: clean
+clean:
+ rm -rf $(BUILDDIR)/*
+
+.PHONY: html
+html:
+ $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
+ @echo
+ @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
+
+.PHONY: dirhtml
+dirhtml:
+ $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
+ @echo
+ @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
+
+.PHONY: singlehtml
+singlehtml:
+ $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
+ @echo
+ @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
+
+.PHONY: pickle
+pickle:
+ $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
+ @echo
+ @echo "Build finished; now you can process the pickle files."
+
+.PHONY: json
+json:
+ $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
+ @echo
+ @echo "Build finished; now you can process the JSON files."
+
+.PHONY: htmlhelp
+htmlhelp:
+ $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
+ @echo
+ @echo "Build finished; now you can run HTML Help Workshop with the" \
+ ".hhp project file in $(BUILDDIR)/htmlhelp."
+
+.PHONY: qthelp
+qthelp:
+ $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
+ @echo
+ @echo "Build finished; now you can run "qcollectiongenerator" with the" \
+ ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
+ @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/pyee.qhcp"
+ @echo "To view the help file:"
+ @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/pyee.qhc"
+
+.PHONY: applehelp
+applehelp:
+ $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp
+ @echo
+ @echo "Build finished. The help book is in $(BUILDDIR)/applehelp."
+ @echo "N.B. You won't be able to view it unless you put it in" \
+ "~/Library/Documentation/Help or install it in your application" \
+ "bundle."
+
+.PHONY: devhelp
+devhelp:
+ $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
+ @echo
+ @echo "Build finished."
+ @echo "To view the help file:"
+ @echo "# mkdir -p $$HOME/.local/share/devhelp/pyee"
+ @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/pyee"
+ @echo "# devhelp"
+
+.PHONY: epub
+epub:
+ $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
+ @echo
+ @echo "Build finished. The epub file is in $(BUILDDIR)/epub."
+
+.PHONY: epub3
+epub3:
+ $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3
+ @echo
+ @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3."
+
+.PHONY: latex
+latex:
+ $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+ @echo
+ @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
+ @echo "Run \`make' in that directory to run these through (pdf)latex" \
+ "(use \`make latexpdf' here to do that automatically)."
+
+.PHONY: latexpdf
+latexpdf:
+ $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+ @echo "Running LaTeX files through pdflatex..."
+ $(MAKE) -C $(BUILDDIR)/latex all-pdf
+ @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
+
+.PHONY: latexpdfja
+latexpdfja:
+ $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+ @echo "Running LaTeX files through platex and dvipdfmx..."
+ $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
+ @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
+
+.PHONY: text
+text:
+ $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
+ @echo
+ @echo "Build finished. The text files are in $(BUILDDIR)/text."
+
+.PHONY: man
+man:
+ $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
+ @echo
+ @echo "Build finished. The manual pages are in $(BUILDDIR)/man."
+
+.PHONY: texinfo
+texinfo:
+ $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
+ @echo
+ @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
+ @echo "Run \`make' in that directory to run these through makeinfo" \
+ "(use \`make info' here to do that automatically)."
+
+.PHONY: info
+info:
+ $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
+ @echo "Running Texinfo files through makeinfo..."
+ make -C $(BUILDDIR)/texinfo info
+ @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
+
+.PHONY: gettext
+gettext:
+ $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
+ @echo
+ @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
+
+.PHONY: changes
+changes:
+ $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
+ @echo
+ @echo "The overview file is in $(BUILDDIR)/changes."
+
+.PHONY: linkcheck
+linkcheck:
+ $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
+ @echo
+ @echo "Link check complete; look for any errors in the above output " \
+ "or in $(BUILDDIR)/linkcheck/output.txt."
+
+.PHONY: doctest
+doctest:
+ $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
+ @echo "Testing of doctests in the sources finished, look at the " \
+ "results in $(BUILDDIR)/doctest/output.txt."
+
+.PHONY: coverage
+coverage:
+ $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage
+ @echo "Testing of coverage in the sources finished, look at the " \
+ "results in $(BUILDDIR)/coverage/python.txt."
+
+.PHONY: xml
+xml:
+ $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
+ @echo
+ @echo "Build finished. The XML files are in $(BUILDDIR)/xml."
+
+.PHONY: pseudoxml
+pseudoxml:
+ $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
+ @echo
+ @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
+
+.PHONY: dummy
+dummy:
+ $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy
+ @echo
+ @echo "Build finished. Dummy builder generates no files."
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 0000000..546b2ca
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,343 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+#
+# pyee documentation build configuration file, created by
+# sphinx-quickstart on Sat Oct 1 15:15:23 2016.
+#
+# This file is execfile()d with the current directory set to its
+# containing dir.
+#
+# Note that not all possible configuration values are present in this
+# autogenerated file.
+#
+# All configuration values have a default; values that are commented out
+# serve to show the default.
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+#
+# import os
+# import sys
+# sys.path.insert(0, os.path.abspath('.'))
+
+# -- General configuration ------------------------------------------------
+
+# If your documentation needs a minimal Sphinx version, state it here.
+#
+# needs_sphinx = '1.0'
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
+# ones.
+
+extensions = [
+ "sphinx.ext.autodoc",
+ "sphinx.ext.viewcode",
+]
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ["_templates"]
+
+# The suffix(es) of source filenames.
+# You can specify multiple suffix as a list of string:
+#
+# source_suffix = ['.rst', '.md']
+source_suffix = ".rst"
+
+# The encoding of source files.
+#
+# source_encoding = 'utf-8-sig'
+
+# The master toctree document.
+master_doc = "index"
+
+# General information about the project.
+project = "pyee"
+copyright = "2021, Josh Holbrook"
+author = "Josh Holbrook"
+
+# The version info for the project you're documenting, acts as replacement for
+# |version| and |release|, also used in various other places throughout the
+# built documents.
+#
+# The short X.Y version.
+version = "9.0.4"
+
+# The full version, including alpha/beta/rc tags.
+release = version
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+# for a list of supported languages.
+#
+# This is also used if you do content translation via gettext catalogs.
+# Usually you set "language" from the command line for these cases.
+language = None
+
+# There are two options for replacing |today|: either, you set today to some
+# non-false value, then it is used:
+#
+# today = ''
+#
+# Else, today_fmt is used as the format for a strftime call.
+#
+# today_fmt = '%B %d, %Y'
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+# This patterns also effect to html_static_path and html_extra_path
+exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
+
+# The reST default role (used for this markup: `text`) to use for all
+# documents.
+#
+# default_role = None
+
+# If true, '()' will be appended to :func: etc. cross-reference text.
+#
+# add_function_parentheses = True
+
+# If true, the current module name will be prepended to all description
+# unit titles (such as .. function::).
+#
+# add_module_names = True
+
+# If true, sectionauthor and moduleauthor directives will be shown in the
+# output. They are ignored by default.
+#
+# show_authors = False
+
+# The name of the Pygments (syntax highlighting) style to use.
+pygments_style = "sphinx"
+
+# A list of ignored prefixes for module index sorting.
+# modindex_common_prefix = []
+
+# If true, keep warnings as "system message" paragraphs in the built documents.
+# keep_warnings = False
+
+# If true, `todo` and `todoList` produce output, else they produce nothing.
+todo_include_todos = False
+
+
+# -- Options for HTML output ----------------------------------------------
+
+# The theme to use for HTML and HTML Help pages. See the documentation for
+# a list of builtin themes.
+#
+html_theme = "bizstyle"
+
+# Theme options are theme-specific and customize the look and feel of a theme
+# further. For a list of options available for each theme, see the
+# documentation.
+#
+# html_theme_options = {}
+
+# Add any paths that contain custom themes here, relative to this directory.
+# html_theme_path = []
+
+# The name for this set of Sphinx documents.
+# "<project> v<release> documentation" by default.
+#
+# html_title = 'pyee v1.0.2'
+
+# A shorter title for the navigation bar. Default is the same as html_title.
+#
+# html_short_title = None
+
+# The name of an image file (relative to this directory) to place at the top
+# of the sidebar.
+#
+# html_logo = None
+
+# The name of an image file (relative to this directory) to use as a favicon of
+# the docs. This file should be a Windows icon file (.ico) being 16x16 or
+# 32x32 pixels large.
+#
+# html_favicon = None
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ["_static"]
+
+# Add any extra paths that contain custom files (such as robots.txt or
+# .htaccess) here, relative to this directory. These files are copied
+# directly to the root of the documentation.
+#
+# html_extra_path = []
+
+# If not None, a 'Last updated on:' timestamp is inserted at every page
+# bottom, using the given strftime format.
+# The empty string is equivalent to '%b %d, %Y'.
+#
+# html_last_updated_fmt = None
+
+# If true, SmartyPants will be used to convert quotes and dashes to
+# typographically correct entities.
+#
+# html_use_smartypants = True
+
+# Custom sidebar templates, maps document names to template names.
+#
+# html_sidebars = {}
+
+# Additional templates that should be rendered to pages, maps page names to
+# template names.
+#
+# html_additional_pages = {}
+
+# If false, no module index is generated.
+#
+# html_domain_indices = True
+
+# If false, no index is generated.
+#
+# html_use_index = True
+
+# If true, the index is split into individual pages for each letter.
+#
+# html_split_index = False
+
+# If true, links to the reST sources are added to the pages.
+#
+# html_show_sourcelink = True
+
+# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
+#
+# html_show_sphinx = True
+
+# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
+#
+# html_show_copyright = True
+
+# If true, an OpenSearch description file will be output, and all pages will
+# contain a <link> tag referring to it. The value of this option must be the
+# base URL from which the finished HTML is served.
+#
+# html_use_opensearch = ''
+
+# This is the file name suffix for HTML files (e.g. ".xhtml").
+# html_file_suffix = None
+
+# Language to be used for generating the HTML full-text search index.
+# Sphinx supports the following languages:
+# 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja'
+# 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr', 'zh'
+#
+# html_search_language = 'en'
+
+# A dictionary with options for the search language support, empty by default.
+# 'ja' uses this config value.
+# 'zh' user can custom change `jieba` dictionary path.
+#
+# html_search_options = {'type': 'default'}
+
+# The name of a javascript file (relative to the configuration directory) that
+# implements a search results scorer. If empty, the default will be used.
+#
+# html_search_scorer = 'scorer.js'
+
+# Output file base name for HTML help builder.
+htmlhelp_basename = "pyeedoc"
+
+# -- Options for LaTeX output ---------------------------------------------
+
+latex_elements = {
+ # The paper size ('letterpaper' or 'a4paper').
+ #
+ # 'papersize': 'letterpaper',
+ # The font size ('10pt', '11pt' or '12pt').
+ #
+ # 'pointsize': '10pt',
+ # Additional stuff for the LaTeX preamble.
+ #
+ # 'preamble': '',
+ # Latex figure (float) alignment
+ #
+ # 'figure_align': 'htbp',
+}
+
+# Grouping the document tree into LaTeX files. List of tuples
+# (source start file, target name, title,
+# author, documentclass [howto, manual, or own class]).
+latex_documents = [
+ (master_doc, "pyee.tex", "pyee Documentation", "Josh Holbrook", "manual"),
+]
+
+# The name of an image file (relative to this directory) to place at the top of
+# the title page.
+#
+# latex_logo = None
+
+# For "manual" documents, if this is true, then toplevel headings are parts,
+# not chapters.
+#
+# latex_use_parts = False
+
+# If true, show page references after internal links.
+#
+# latex_show_pagerefs = False
+
+# If true, show URL addresses after external links.
+#
+# latex_show_urls = False
+
+# Documents to append as an appendix to all manuals.
+#
+# latex_appendices = []
+
+# It false, will not define \strong, \code, itleref, \crossref ... but only
+# \sphinxstrong, ..., \sphinxtitleref, ... To help avoid clash with user added
+# packages.
+#
+# latex_keep_old_macro_names = True
+
+# If false, no module index is generated.
+#
+# latex_domain_indices = True
+
+
+# -- Options for manual page output ---------------------------------------
+
+# One entry per manual page. List of tuples
+# (source start file, name, description, authors, manual section).
+man_pages = [(master_doc, "pyee", "pyee Documentation", [author], 1)]
+
+# If true, show URL addresses after external links.
+#
+# man_show_urls = False
+
+
+# -- Options for Texinfo output -------------------------------------------
+
+# Grouping the document tree into Texinfo files. List of tuples
+# (source start file, target name, title, author,
+# dir menu entry, description, category)
+texinfo_documents = [
+ (
+ master_doc,
+ "pyee",
+ "pyee Documentation",
+ author,
+ "pyee",
+ "One line description of project.",
+ "Miscellaneous",
+ ),
+]
+
+# Documents to append as an appendix to all manuals.
+#
+# texinfo_appendices = []
+
+# If false, no module index is generated.
+#
+# texinfo_domain_indices = True
+
+# How to display URL addresses: 'footnote', 'no', or 'inline'.
+#
+# texinfo_show_urls = 'footnote'
+
+# If true, do not generate a @detailmenu in the "Top" node's menu.
+#
+# texinfo_no_detailmenu = False
diff --git a/docs/index.rst b/docs/index.rst
new file mode 100644
index 0000000..ccdfb81
--- /dev/null
+++ b/docs/index.rst
@@ -0,0 +1,63 @@
+pyee
+====
+
+pyee is a rough port of
+`node.js's EventEmitter <https://nodejs.org/api/events.html>`_. Unlike its
+namesake, it includes a number of subclasses useful for implementing async
+and threaded programming in python, such as async/await as seen in python 3.5+.
+
+Install:
+--------
+
+You can install this project into your environment of choice using ``pip``::
+
+ pip install pyee
+
+API Docs:
+---------
+
+.. toctree::
+ :maxdepth: 2
+
+.. automodule:: pyee
+
+.. autoclass:: pyee.EventEmitter
+ :members:
+
+.. autoclass:: pyee.asyncio.AsyncIOEventEmitter
+ :members:
+
+.. autoclass:: pyee.twisted.TwistedEventEmitter
+ :members:
+
+.. autoclass:: pyee.executor.ExecutorEventEmitter
+ :members:
+
+.. autoclass:: pyee.trio.TrioEventEmitter
+ :members:
+
+.. autoclass:: BaseEventEmitter
+ :members:
+
+.. autoexception:: pyee.PyeeException
+
+.. autofunction:: pyee.uplift.uplift
+
+.. autofunction:: pyee.cls.on
+
+.. autofunction:: pyee.cls.evented
+
+
+Some Links
+==========
+
+* `Fork Me On GitHub! <https://github.com/jfhbrook/pyee>`_
+* `These Very Docs on readthedocs.io <https://pyee.rtfd.io>`_
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
+
+Changelog
+=========
+
+.. include:: ../CHANGELOG.rst
diff --git a/environment.yml b/environment.yml
new file mode 100644
index 0000000..25a2c6c
--- /dev/null
+++ b/environment.yml
@@ -0,0 +1,14 @@
+name: pyee
+channels:
+ - conda-forge
+ - default
+dependencies:
+ - python=3.8.3
+ - pip=20.2.3
+ - trio=0.17.0
+ - twine=3.2.0
+ - twisted=20.3.0
+ - pip:
+ - -r requirements.txt
+ - -r requirements_dev.txt
+ - -e .
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..61df80a
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,37 @@
+{
+ "name": "pyee-devtools",
+ "version": "1.0.0",
+ "lockfileVersion": 2,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "pyee-devtools",
+ "version": "1.0.0",
+ "license": "MIT",
+ "devDependencies": {
+ "pyright": "^1.1.159"
+ }
+ },
+ "node_modules/pyright": {
+ "version": "1.1.203",
+ "resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.203.tgz",
+ "integrity": "sha512-BglTVxjj6iQBRvqxsQbm9pz8ZMQzBt1GJxxyW4QRJ3utbaXiPQJMpB4UGLIQI6c5S30lcObEdkLicHeWtQYvuQ==",
+ "dev": true,
+ "bin": {
+ "pyright": "index.js",
+ "pyright-langserver": "langserver.index.js"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ }
+ },
+ "dependencies": {
+ "pyright": {
+ "version": "1.1.203",
+ "resolved": "https://registry.npmjs.org/pyright/-/pyright-1.1.203.tgz",
+ "integrity": "sha512-BglTVxjj6iQBRvqxsQbm9pz8ZMQzBt1GJxxyW4QRJ3utbaXiPQJMpB4UGLIQI6c5S30lcObEdkLicHeWtQYvuQ==",
+ "dev": true
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..c16a298
--- /dev/null
+++ b/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "pyee-devtools",
+ "version": "1.0.0",
+ "description": "Node.js tools to support developing pyee",
+ "main": "index.js",
+ "scripts": {
+ "pyright": "pyright ./pyee ./tests"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+ssh://git@github.com/jfhbrook/pyee.git"
+ },
+ "author": "Josh Holbrook",
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/jfhbrook/pyee/issues"
+ },
+ "homepage": "https://github.com/jfhbrook/pyee#readme",
+ "devDependencies": {
+ "pyright": "^1.1.159"
+ }
+}
diff --git a/pyee/__init__.py b/pyee/__init__.py
new file mode 100644
index 0000000..9a4dafb
--- /dev/null
+++ b/pyee/__init__.py
@@ -0,0 +1,138 @@
+# -*- coding: utf-8 -*-
+
+"""
+pyee supplies a ``EventEmitter`` class that is similar to the
+``EventEmitter`` class from Node.js. In addition, it supplies the subclasses
+``AsyncIOEventEmitter``, ``TwistedEventEmitter`` and ``ExecutorEventEmitter``
+for supporting async and threaded execution with asyncio, twisted, and
+concurrent.futures Executors respectively, as supported by the environment.
+
+
+Example
+-------
+
+::
+
+ In [1]: from pyee.base import EventEmitter
+
+ In [2]: ee = EventEmitter()
+
+ In [3]: @ee.on('event')
+ ...: def event_handler():
+ ...: print('BANG BANG')
+ ...:
+
+ In [4]: ee.emit('event')
+ BANG BANG
+
+ In [5]:
+
+"""
+
+from warnings import warn
+
+from pyee.base import EventEmitter as EventEmitter
+from pyee.base import PyeeException
+
+
+class BaseEventEmitter(EventEmitter):
+ """
+ BaseEventEmitter is deprecated and an alias for EventEmitter.
+ """
+
+ def __init__(self):
+ warn(
+ DeprecationWarning(
+ "pyee.BaseEventEmitter is deprecated and will be removed in a "
+ "future major version; you should instead use pyee.EventEmitter."
+ )
+ )
+
+ super(BaseEventEmitter, self).__init__()
+
+
+__all__ = ["BaseEventEmitter", "EventEmitter", "PyeeException"]
+
+try:
+ from pyee.asyncio import AsyncIOEventEmitter as _AsyncIOEventEmitter # noqa
+
+ class AsyncIOEventEmitter(_AsyncIOEventEmitter):
+ """
+ AsyncIOEventEmitter has been moved to the pyee.asyncio module.
+ """
+
+ def __init__(self, loop=None):
+ warn(
+ DeprecationWarning(
+ "pyee.AsyncIOEventEmitter has been moved to the pyee.asyncio "
+ "module."
+ )
+ )
+ super(AsyncIOEventEmitter, self).__init__(loop=loop)
+
+ __all__.append("AsyncIOEventEmitter")
+except ImportError:
+ pass
+
+try:
+ from pyee.twisted import TwistedEventEmitter as _TwistedEventEmitter # noqa
+
+ class TwistedEventEmitter(_TwistedEventEmitter):
+ """
+ TwistedEventEmitter has been moved to the pyee.twisted module.
+ """
+
+ def __init__(self):
+ warn(
+ DeprecationWarning(
+ "pyee.TwistedEventEmitter has been moved to the pyee.twisted "
+ "module."
+ )
+ )
+ super(TwistedEventEmitter, self).__init__()
+
+ __all__.append("TwistedEventEmitter")
+except ImportError:
+ pass
+
+try:
+ from pyee.executor import ExecutorEventEmitter as _ExecutorEventEmitter # noqa
+
+ class ExecutorEventEmitter(_ExecutorEventEmitter):
+ """
+ ExecutorEventEmitter has been moved to the pyee.executor module.
+ """
+
+ def __init__(self, executor=None):
+ warn(
+ DeprecationWarning(
+ "pyee.ExecutorEventEmitter has been moved to the pyee.executor "
+ "module."
+ )
+ )
+ super(ExecutorEventEmitter, self).__init__(executor=executor)
+
+ __all__.append("ExecutorEventEmitter")
+except ImportError:
+ pass
+
+try:
+ from pyee.trio import TrioEventEmitter as _TrioEventEmitter # noqa
+
+ class TrioEventEmitter(_TrioEventEmitter):
+ """
+ TrioEventEmitter has been moved to the pyee.trio module.
+ """
+
+ def __init__(self, nursery=None, manager=None):
+ warn(
+ DeprecationWarning(
+ "pyee.TrioEventEmitter has been moved to the pyee.trio module."
+ )
+ )
+
+ super(TrioEventEmitter, self).__init__(nursery=nursery, manager=manager)
+
+ __all__.append("TrioEventEmitter")
+except (ImportError, SyntaxError):
+ pass
diff --git a/pyee/asyncio.py b/pyee/asyncio.py
new file mode 100644
index 0000000..433001f
--- /dev/null
+++ b/pyee/asyncio.py
@@ -0,0 +1,73 @@
+# -*- coding: utf-8 -*-
+
+from asyncio import AbstractEventLoop, ensure_future, Future, iscoroutine
+from typing import Any, Callable, cast, Dict, Optional, Tuple
+
+from pyee.base import EventEmitter
+
+__all__ = ["AsyncIOEventEmitter"]
+
+
+class AsyncIOEventEmitter(EventEmitter):
+ """An event emitter class which can run asyncio coroutines in addition to
+ synchronous blocking functions. For example::
+
+ @ee.on('event')
+ async def async_handler(*args, **kwargs):
+ await returns_a_future()
+
+ On emit, the event emitter will automatically schedule the coroutine using
+ ``asyncio.ensure_future`` and the configured event loop (defaults to
+ ``asyncio.get_event_loop()``).
+
+ Unlike the case with the EventEmitter, all exceptions raised by
+ event handlers are automatically emitted on the ``error`` event. This is
+ important for asyncio coroutines specifically but is also handled for
+ synchronous functions for consistency.
+
+ When ``loop`` is specified, the supplied event loop will be used when
+ scheduling work with ``ensure_future``. Otherwise, the default asyncio
+ event loop is used.
+
+ For asyncio coroutine event handlers, calling emit is non-blocking.
+ In other words, you do not have to await any results from emit, and the
+ coroutine is scheduled in a fire-and-forget fashion.
+ """
+
+ def __init__(self, loop: Optional[AbstractEventLoop] = None):
+ super(AsyncIOEventEmitter, self).__init__()
+ self._loop: Optional[AbstractEventLoop] = loop
+
+ def _emit_run(
+ self,
+ f: Callable,
+ args: Tuple[Any, ...],
+ kwargs: Dict[str, Any],
+ ):
+ try:
+ coro: Any = f(*args, **kwargs)
+ except Exception as exc:
+ self.emit("error", exc)
+ else:
+ if iscoroutine(coro):
+ if self._loop:
+ # ensure_future is *extremely* cranky about the types here,
+ # but this is relatively well-tested and I think the types
+ # are more strict than they should be
+ fut: Any = ensure_future(cast(Any, coro), loop=self._loop)
+ else:
+ fut = ensure_future(cast(Any, coro))
+ elif isinstance(coro, Future):
+ fut = cast(Any, coro)
+ else:
+ return
+
+ def callback(f):
+ if f.cancelled():
+ return
+
+ exc: Exception = f.exception()
+ if exc:
+ self.emit("error", exc)
+
+ fut.add_done_callback(callback)
diff --git a/pyee/base.py b/pyee/base.py
new file mode 100644
index 0000000..85a6cf9
--- /dev/null
+++ b/pyee/base.py
@@ -0,0 +1,239 @@
+# -*- coding: utf-8 -*-
+
+from collections import OrderedDict
+from threading import Lock
+from typing import Any, Callable, Dict, List, Optional, Set, Tuple, TypeVar, Union
+
+
+class PyeeException(Exception):
+ """An exception internal to pyee."""
+
+
+Handler = TypeVar(name="Handler", bound=Callable)
+
+
+class EventEmitter:
+ """The base event emitter class. All other event emitters inherit from
+ this class.
+
+ Most events are registered with an emitter via the ``on`` and ``once``
+ methods, and fired with the ``emit`` method. However, pyee event emitters
+ have two *special* events:
+
+ - ``new_listener``: Fires whenever a new listener is created. Listeners for
+ this event do not fire upon their own creation.
+
+ - ``error``: When emitted raises an Exception by default, behavior can be
+ overridden by attaching callback to the event.
+
+ For example::
+
+ @ee.on('error')
+ def on_error(message):
+ logging.err(message)
+
+ ee.emit('error', Exception('something blew up'))
+
+ All callbacks are handled in a synchronous, blocking manner. As in node.js,
+ raised exceptions are not automatically handled for you---you must catch
+ your own exceptions, and treat them accordingly.
+ """
+
+ def __init__(self) -> None:
+ self._events: Dict[
+ str,
+ "OrderedDict[Callable, Callable]",
+ ] = dict()
+ self._lock: Lock = Lock()
+
+ def on(
+ self, event: str, f: Optional[Handler] = None
+ ) -> Union[Handler, Callable[[Handler], Handler]]:
+ """Registers the function ``f`` to the event name ``event``, if provided.
+
+ If ``f`` isn't provided, this method calls ``EventEmitter#listens_to`, and
+ otherwise calls ``EventEmitter#add_listener``. In other words, you may either
+ use it as a decorator::
+
+ @ee.on('data')
+ def data_handler(data):
+ print(data)
+
+ Or directly::
+
+ ee.on('data', data_handler)
+
+ In both the decorated and undecorated forms, the event handler is
+ returned. The upshot of this is that you can call decorated handlers
+ directly, as well as use them in remove_listener calls.
+
+ Note that this method's return type is a union type. If you are using
+ mypy or pyright, you will probably want to use either
+ ``EventEmitter#listens_to`` or ``EventEmitter#add_listener``.
+ """
+ if f is None:
+ return self.listens_to(event)
+ else:
+ return self.add_listener(event, f)
+
+ def listens_to(self, event: str) -> Callable[[Handler], Handler]:
+ """Returns a decorator which will register the decorated function to
+ the event name ``event``::
+
+ @ee.listens_to("event")
+ def data_handler(data):
+ print(data)
+
+ By only supporting the decorator use case, this method has improved
+ type safety over ``EventEmitter#on``.
+ """
+
+ def on(f: Handler) -> Handler:
+ self._add_event_handler(event, f, f)
+ return f
+
+ return on
+
+ def add_listener(self, event: str, f: Handler) -> Handler:
+ """Register the function ``f`` to the event name ``event``::
+
+ def data_handler(data):
+ print(data)
+
+ h = ee.add_listener("event", data_handler)
+
+ By not supporting the decorator use case, this method has improved
+ type safety over ``EventEmitter#on``.
+ """
+ self._add_event_handler(event, f, f)
+ return f
+
+ def _add_event_handler(self, event: str, k: Callable, v: Callable):
+ # Fire 'new_listener' *before* adding the new listener!
+ self.emit("new_listener", event, k)
+
+ # Add the necessary function
+ # Note that k and v are the same for `on` handlers, but
+ # different for `once` handlers, where v is a wrapped version
+ # of k which removes itself before calling k
+ with self._lock:
+ if event not in self._events:
+ self._events[event] = OrderedDict()
+ self._events[event][k] = v
+
+ def _emit_run(
+ self,
+ f: Callable,
+ args: Tuple[Any, ...],
+ kwargs: Dict[str, Any],
+ ) -> None:
+ f(*args, **kwargs)
+
+ def event_names(self) -> Set[str]:
+ """Get a set of events that this emitter is listening to."""
+ return set(self._events.keys())
+
+ def _emit_handle_potential_error(self, event: str, error: Any) -> None:
+ if event == "error":
+ if isinstance(error, Exception):
+ raise error
+ else:
+ raise PyeeException(f"Uncaught, unspecified 'error' event: {error}")
+
+ def _call_handlers(
+ self,
+ event: str,
+ args: Tuple[Any, ...],
+ kwargs: Dict[str, Any],
+ ) -> bool:
+ handled = False
+
+ with self._lock:
+ funcs = list(self._events.get(event, OrderedDict()).values())
+ for f in funcs:
+ self._emit_run(f, args, kwargs)
+ handled = True
+
+ return handled
+
+ def emit(
+ self,
+ event: str,
+ *args: Any,
+ **kwargs: Any,
+ ) -> bool:
+ """Emit ``event``, passing ``*args`` and ``**kwargs`` to each attached
+ function. Returns ``True`` if any functions are attached to ``event``;
+ otherwise returns ``False``.
+
+ Example::
+
+ ee.emit('data', '00101001')
+
+ Assuming ``data`` is an attached function, this will call
+ ``data('00101001')'``.
+ """
+ handled = self._call_handlers(event, args, kwargs)
+
+ if not handled:
+ self._emit_handle_potential_error(event, args[0] if args else None)
+
+ return handled
+
+ def once(
+ self,
+ event: str,
+ f: Callable = None,
+ ) -> Callable:
+ """The same as ``ee.on``, except that the listener is automatically
+ removed after being called.
+ """
+
+ def _wrapper(f: Callable) -> Callable:
+ def g(
+ *args: Any,
+ **kwargs: Any,
+ ) -> Any:
+ with self._lock:
+ # Check that the event wasn't removed already right
+ # before the lock
+ if event in self._events and f in self._events[event]:
+ self._remove_listener(event, f)
+ else:
+ return None
+ # f may return a coroutine, so we need to return that
+ # result here so that emit can schedule it
+ return f(*args, **kwargs)
+
+ self._add_event_handler(event, f, g)
+ return f
+
+ if f is None:
+ return _wrapper
+ else:
+ return _wrapper(f)
+
+ def _remove_listener(self, event: str, f: Callable) -> None:
+ """Naked unprotected removal."""
+ self._events[event].pop(f)
+ if not len(self._events[event]):
+ del self._events[event]
+
+ def remove_listener(self, event: str, f: Callable) -> None:
+ """Removes the function ``f`` from ``event``."""
+ with self._lock:
+ self._remove_listener(event, f)
+
+ def remove_all_listeners(self, event: Optional[str] = None) -> None:
+ """Remove all listeners attached to ``event``.
+ If ``event`` is ``None``, remove all listeners on all events.
+ """
+ with self._lock:
+ if event is not None:
+ self._events[event] = OrderedDict()
+ else:
+ self._events = dict()
+
+ def listeners(self, event: str) -> List[Callable]:
+ """Returns a list of all listeners registered to the ``event``."""
+ return list(self._events.get(event, OrderedDict()).keys())
diff --git a/pyee/cls.py b/pyee/cls.py
new file mode 100644
index 0000000..21885b4
--- /dev/null
+++ b/pyee/cls.py
@@ -0,0 +1,112 @@
+from dataclasses import dataclass
+from functools import wraps
+from typing import Callable, List, Type, TypeVar
+
+from pyee import EventEmitter
+
+
+@dataclass
+class Handler:
+ event: str
+ method: Callable
+
+
+class Handlers:
+ def __init__(self):
+ self._handlers: List[Handler] = []
+
+ def append(self, handler):
+ self._handlers.append(handler)
+
+ def __iter__(self):
+ return iter(self._handlers)
+
+ def reset(self):
+ self._handlers = []
+
+
+_handlers = Handlers()
+
+
+def on(event: str) -> Callable[[Callable], Callable]:
+ """
+ Register an event handler on an evented class. See the ``evented`` class
+ decorator for a full example.
+ """
+
+ def decorator(method: Callable) -> Callable:
+ _handlers.append(Handler(event=event, method=method))
+ return method
+
+ return decorator
+
+
+def _bind(self, method):
+ @wraps(method)
+ def bound(*args, **kwargs):
+ return method(self, *args, **kwargs)
+
+ return bound
+
+
+Cls = TypeVar(name="Cls", bound=Type)
+
+
+def evented(cls: Cls) -> Cls:
+ """
+ Configure an evented class.
+
+ Evented classes are classes which use an EventEmitter to call instance
+ methods during runtime. To achieve this without this helper, you would
+ instantiate an ``EventEmitter`` in the ``__init__`` method and then call
+ ``event_emitter.on`` for every method on ``self``.
+
+ This decorator and the ``on`` function help make things look a little nicer
+ by defining the event handler on the method in the class and then adding
+ the ``__init__`` hook in a wrapper::
+
+ from pyee.cls import evented, on
+
+ @evented
+ class Evented:
+ @on("event")
+ def event_handler(self, *args, **kwargs):
+ print(self, args, kwargs)
+
+ evented_obj = Evented()
+
+ evented_obj.event_emitter.emit(
+ "event", "hello world", numbers=[1, 2, 3]
+ )
+
+ The ``__init__`` wrapper will create a ``self.event_emitter: EventEmitter``
+ automatically but you can also define your own event_emitter inside your
+ class's unwrapped ``__init__`` method. For example, to use this
+ decorator with a ``TwistedEventEmitter``::
+
+ @evented
+ class Evented:
+ def __init__(self):
+ self.event_emitter = TwistedEventEmitter()
+
+ @on("event")
+ async def event_handler(self, *args, **kwargs):
+ await self.some_async_action(*args, **kwargs)
+ """
+ handlers: List[Handler] = list(_handlers)
+ _handlers.reset()
+
+ og_init: Callable = cls.__init__
+
+ @wraps(cls.__init__)
+ def init(self, *args, **kwargs):
+ og_init(self, *args, **kwargs)
+ if not hasattr(self, "event_emitter"):
+ self.event_emitter = EventEmitter()
+
+ for h in handlers:
+ self.event_emitter.on(h.event, _bind(self, h.method))
+
+ cls.__init__ = init
+
+ return cls
diff --git a/pyee/executor.py b/pyee/executor.py
new file mode 100644
index 0000000..25df774
--- /dev/null
+++ b/pyee/executor.py
@@ -0,0 +1,79 @@
+# -*- coding: utf-8 -*-
+
+from concurrent.futures import Executor, Future, ThreadPoolExecutor
+from types import TracebackType
+from typing import Any, Callable, Dict, Optional, Tuple, Type
+
+from pyee.base import EventEmitter
+
+__all__ = ["ExecutorEventEmitter"]
+
+
+class ExecutorEventEmitter(EventEmitter):
+ """An event emitter class which runs handlers in a ``concurrent.futures``
+ executor.
+
+ By default, this class creates a default ``ThreadPoolExecutor``, but
+ a custom executor may also be passed in explicitly to, for instance,
+ use a ``ProcessPoolExecutor`` instead.
+
+ This class runs all emitted events on the configured executor. Errors
+ captured by the resulting Future are automatically emitted on the
+ ``error`` event. This is unlike the EventEmitter, which have no error
+ handling.
+
+ The underlying executor may be shut down by calling the ``shutdown``
+ method. Alternately you can treat the event emitter as a context manager::
+
+ with ExecutorEventEmitter() as ee:
+ # Underlying executor open
+
+ @ee.on('data')
+ def handler(data):
+ print(data)
+
+ ee.emit('event')
+
+ # Underlying executor closed
+
+ Since the function call is scheduled on an executor, emit is always
+ non-blocking.
+
+ No effort is made to ensure thread safety, beyond using an executor.
+ """
+
+ def __init__(self, executor: Executor = None):
+ super(ExecutorEventEmitter, self).__init__()
+ if executor:
+ self._executor: Executor = executor
+ else:
+ self._executor = ThreadPoolExecutor()
+
+ def _emit_run(
+ self,
+ f: Callable,
+ args: Tuple[Any, ...],
+ kwargs: Dict[str, Any],
+ ):
+ future: Future = self._executor.submit(f, *args, **kwargs)
+
+ @future.add_done_callback
+ def _callback(f: Future) -> None:
+ exc: Optional[BaseException] = f.exception()
+ if isinstance(exc, Exception):
+ self.emit("error", exc)
+ elif exc is not None:
+ raise exc
+
+ def shutdown(self, wait: bool = True) -> None:
+ """Call ``shutdown`` on the internal executor."""
+
+ self._executor.shutdown(wait=wait)
+
+ def __enter__(self) -> "ExecutorEventEmitter":
+ return self
+
+ def __exit__(
+ self, type: Type[Exception], value: Exception, traceback: TracebackType
+ ) -> Optional[bool]:
+ self.shutdown()
diff --git a/pyee/py.typed b/pyee/py.typed
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/pyee/py.typed
diff --git a/pyee/trio.py b/pyee/trio.py
new file mode 100644
index 0000000..e79d457
--- /dev/null
+++ b/pyee/trio.py
@@ -0,0 +1,129 @@
+# -*- coding: utf-8 -*-
+
+from contextlib import AbstractAsyncContextManager, asynccontextmanager
+from types import TracebackType
+from typing import Any, AsyncGenerator, Awaitable, Callable, Dict, Optional, Tuple, Type
+
+import trio
+
+from pyee.base import EventEmitter, PyeeException
+
+__all__ = ["TrioEventEmitter"]
+
+
+Nursery = trio.Nursery
+
+
+class TrioEventEmitter(EventEmitter):
+ """An event emitter class which can run trio tasks in a trio nursery.
+
+ By default, this class will lazily create both a nursery manager (the
+ object returned from ``trio.open_nursery()`` and a nursery (the object
+ yielded by using the nursery manager as an async context manager). It is
+ also possible to supply an existing nursery manager via the ``manager``
+ argument, or an existing nursery via the ``nursery`` argument.
+
+ Instances of TrioEventEmitter are themselves async context managers, so
+ that they may manage the lifecycle of the underlying trio nursery. For
+ example, typical usage of this library may look something like this::
+
+ async with TrioEventEmitter() as ee:
+ # Underlying nursery is instantiated and ready to go
+ @ee.on('data')
+ async def handler(data):
+ print(data)
+
+ ee.emit('event')
+
+ # Underlying nursery and manager have been cleaned up
+
+ Unlike the case with the EventEmitter, all exceptions raised by event
+ handlers are automatically emitted on the ``error`` event. This is
+ important for trio coroutines specifically but is also handled for
+ synchronous functions for consistency.
+
+ For trio coroutine event handlers, calling emit is non-blocking. In other
+ words, you should not attempt to await emit; the coroutine is scheduled
+ in a fire-and-forget fashion.
+ """
+
+ def __init__(
+ self,
+ nursery: Nursery = None,
+ manager: "AbstractAsyncContextManager[trio.Nursery]" = None,
+ ):
+ super(TrioEventEmitter, self).__init__()
+ self._nursery: Optional[Nursery] = None
+ self._manager: Optional["AbstractAsyncContextManager[trio.Nursery]"] = None
+ if nursery:
+ if manager:
+ raise PyeeException(
+ "You may either pass a nursery or a nursery manager " "but not both"
+ )
+ self._nursery = nursery
+ elif manager:
+ self._manager = manager
+ else:
+ self._manager = trio.open_nursery()
+
+ def _async_runner(
+ self,
+ f: Callable,
+ args: Tuple[Any, ...],
+ kwargs: Dict[str, Any],
+ ) -> Callable[[], Awaitable[None]]:
+ async def runner() -> None:
+ try:
+ await f(*args, **kwargs)
+ except Exception as exc:
+ self.emit("error", exc)
+
+ return runner
+
+ def _emit_run(
+ self,
+ f: Callable,
+ args: Tuple[Any, ...],
+ kwargs: Dict[str, Any],
+ ) -> None:
+ if not self._nursery:
+ raise PyeeException("Uninitialized trio nursery")
+ self._nursery.start_soon(self._async_runner(f, args, kwargs))
+
+ @asynccontextmanager
+ async def context(
+ self,
+ ) -> AsyncGenerator["TrioEventEmitter", None]:
+ """Returns an async contextmanager which manages the underlying
+ nursery to the EventEmitter. The ``TrioEventEmitter``'s
+ async context management methods are implemented using this
+ function, but it may also be used directly for clarity.
+ """
+ if self._nursery is not None:
+ yield self
+ elif self._manager is not None:
+ async with self._manager as nursery:
+ self._nursery = nursery
+ yield self
+ else:
+ raise PyeeException("Uninitialized nursery or nursery manager")
+
+ async def __aenter__(self) -> "TrioEventEmitter":
+ self._context: Optional[
+ AbstractAsyncContextManager["TrioEventEmitter"]
+ ] = self.context()
+ return await self._context.__aenter__()
+
+ async def __aexit__(
+ self,
+ type: Optional[Type[BaseException]],
+ value: Optional[BaseException],
+ traceback: Optional[TracebackType],
+ ) -> Optional[bool]:
+ if self._context is None:
+ raise PyeeException("Attempting to exit uninitialized context")
+ rv = await self._context.__aexit__(type, value, traceback)
+ self._context = None
+ self._nursery = None
+ self._manager = None
+ return rv
diff --git a/pyee/twisted.py b/pyee/twisted.py
new file mode 100644
index 0000000..2b9d20b
--- /dev/null
+++ b/pyee/twisted.py
@@ -0,0 +1,93 @@
+# -*- coding: utf-8 -*-
+
+from typing import Any, Callable, Dict, Tuple
+
+from twisted.internet.defer import Deferred, ensureDeferred
+from twisted.python.failure import Failure
+
+from pyee.base import EventEmitter, PyeeException
+
+try:
+ from asyncio import iscoroutine
+except ImportError:
+ iscoroutine = None
+
+
+__all__ = ["TwistedEventEmitter"]
+
+
+class TwistedEventEmitter(EventEmitter):
+ """An event emitter class which can run twisted coroutines and handle
+ returned Deferreds, in addition to synchronous blocking functions. For
+ example::
+
+ @ee.on('event')
+ @inlineCallbacks
+ def async_handler(*args, **kwargs):
+ yield returns_a_deferred()
+
+ or::
+
+ @ee.on('event')
+ async def async_handler(*args, **kwargs):
+ await returns_a_deferred()
+
+
+ When async handlers fail, Failures are first emitted on the ``failure``
+ event. If there are no ``failure`` handlers, the Failure's associated
+ exception is then emitted on the ``error`` event. If there are no ``error``
+ handlers, the exception is raised. For consistency, when handlers raise
+ errors synchronously, they're captured, wrapped in a Failure and treated
+ as an async failure. This is unlike the behavior of EventEmitter,
+ which have no special error handling.
+
+ For twisted coroutine event handlers, calling emit is non-blocking.
+ In other words, you do not have to await any results from emit, and the
+ coroutine is scheduled in a fire-and-forget fashion.
+
+ Similar behavior occurs for "sync" functions which return Deferreds.
+ """
+
+ def __init__(self):
+ super(TwistedEventEmitter, self).__init__()
+
+ def _emit_run(
+ self,
+ f: Callable,
+ args: Tuple[Any, ...],
+ kwargs: Dict[str, Any],
+ ) -> None:
+ d = None
+ try:
+ result = f(*args, **kwargs)
+ except Exception:
+ self.emit("failure", Failure())
+ else:
+ if iscoroutine and iscoroutine(result):
+ d: Deferred[Any] = ensureDeferred(result)
+ elif isinstance(result, Deferred):
+ d = result
+ else:
+ return
+
+ def errback(failure: Failure) -> None:
+ if failure:
+ self.emit("failure", failure)
+
+ d.addErrback(errback)
+
+ def _emit_handle_potential_error(self, event: str, error: Any) -> None:
+ if event == "failure":
+ if isinstance(error, Failure):
+ try:
+ error.raiseException()
+ except Exception as exc:
+ self.emit("error", exc)
+ elif isinstance(error, Exception):
+ self.emit("error", error)
+ else:
+ self.emit("error", PyeeException(f"Unexpected failure object: {error}"))
+ else:
+ (super(TwistedEventEmitter, self))._emit_handle_potential_error(
+ event, error
+ )
diff --git a/pyee/uplift.py b/pyee/uplift.py
new file mode 100644
index 0000000..aa5f55a
--- /dev/null
+++ b/pyee/uplift.py
@@ -0,0 +1,178 @@
+# -*- coding: utf-8 -*-
+
+from functools import wraps
+from typing import Any, Callable, Dict, Optional, Tuple, Type, TypeVar, Union
+import warnings
+
+from typing_extensions import Literal
+
+from pyee.base import EventEmitter
+
+UpliftingEventEmitter = TypeVar(name="UpliftingEventEmitter", bound=EventEmitter)
+
+
+EMIT_WRAPPERS: Dict[EventEmitter, Callable[[], None]] = dict()
+
+
+def unwrap(event_emitter: EventEmitter) -> None:
+ """Unwrap an uplifted EventEmitter, returning it to its prior state."""
+ if event_emitter in EMIT_WRAPPERS:
+ EMIT_WRAPPERS[event_emitter]()
+
+
+def _wrap(
+ left: EventEmitter,
+ right: EventEmitter,
+ error_handler: Any,
+ proxy_new_listener: bool,
+) -> None:
+ left_emit = left.emit
+ left_unwrap: Optional[Callable[[], None]] = EMIT_WRAPPERS.get(left)
+
+ @wraps(left_emit)
+ def wrapped_emit(event: str, *args: Any, **kwargs: Any) -> bool:
+ left_handled: bool = left._call_handlers(event, args, kwargs)
+
+ # Do it for the right side
+ if proxy_new_listener or event != "new_listener":
+ right_handled = right._call_handlers(event, args, kwargs)
+ else:
+ right_handled = False
+
+ handled = left_handled or right_handled
+
+ # Use the error handling on ``error_handler`` (should either be
+ # ``left`` or ``right``)
+ if not handled:
+ error_handler._emit_handle_potential_error(event, args[0] if args else None)
+
+ return handled
+
+ def _unwrap() -> None:
+ warnings.warn(
+ DeprecationWarning(
+ "Patched ee.unwrap() is deprecated and will be removed in a "
+ "future release. Use pyee.uplift.unwrap instead."
+ )
+ )
+ unwrap(left)
+
+ def unwrap_hook() -> None:
+ left.emit = left_emit
+ if left_unwrap:
+ EMIT_WRAPPERS[left] = left_unwrap
+ else:
+ del EMIT_WRAPPERS[left]
+ del left.unwrap # type: ignore
+ left.emit = left_emit
+
+ unwrap(right)
+
+ left.emit = wrapped_emit
+
+ EMIT_WRAPPERS[left] = unwrap_hook
+ left.unwrap = _unwrap # type: ignore
+
+
+_PROXY_NEW_LISTENER_SETTINGS: Dict[str, Tuple[bool, bool]] = dict(
+ forward=(False, True),
+ backward=(True, False),
+ both=(True, True),
+ neither=(False, False),
+)
+
+
+ErrorStrategy = Union[Literal["new"], Literal["underlying"], Literal["neither"]]
+ProxyStrategy = Union[
+ Literal["forward"], Literal["backward"], Literal["both"], Literal["neither"]
+]
+
+
+def uplift(
+ cls: Type[UpliftingEventEmitter],
+ underlying: EventEmitter,
+ error_handling: ErrorStrategy = "new",
+ proxy_new_listener: ProxyStrategy = "forward",
+ *args: Any,
+ **kwargs: Any
+) -> UpliftingEventEmitter:
+ """A helper to create instances of an event emitter ``cls`` that inherits
+ event behavior from an ``underlying`` event emitter instance.
+
+ This is mostly helpful if you have a simple underlying event emitter
+ that you don't have direct control over, but you want to use that
+ event emitter in a new context - for example, you may want to ``uplift`` a
+ ``EventEmitter`` supplied by a third party library into an
+ ``AsyncIOEventEmitter`` so that you may register async event handlers
+ in your ``asyncio`` app but still be able to receive events from the
+ underlying event emitter and call the underlying event emitter's existing
+ handlers.
+
+ When called, ``uplift`` instantiates a new instance of ``cls``, passing
+ along any unrecognized arguments, and overwrites the ``emit`` method on
+ the ``underlying`` event emitter to also emit events on the new event
+ emitter and vice versa. In both cases, they return whether the ``emit``
+ method was handled by either emitter. Execution order prefers the event
+ emitter on which ``emit`` was called.
+
+ The ``unwrap`` function may be called on either instance; this will
+ unwrap both ``emit`` methods.
+
+ The ``error_handling`` flag can be configured to control what happens to
+ unhandled errors:
+
+ - 'new': Error handling for the new event emitter is always used and the
+ underlying library's non-event-based error handling is inert.
+ - 'underlying': Error handling on the underlying event emitter is always
+ used and the new event emitter can not implement non-event-based error
+ handling.
+ - 'neither': Error handling for the new event emitter is used if the
+ handler was registered on the new event emitter, and vice versa.
+
+ Tuning this option can be useful depending on how the underlying event
+ emitter does error handling. The default is 'new'.
+
+ The ``proxy_new_listener`` option can be configured to control how
+ ``new_listener`` events are treated:
+
+ - 'forward': ``new_listener`` events are propagated from the underlying
+ - 'both': ``new_listener`` events are propagated as with other events.
+ - 'neither': ``new_listener`` events are only fired on their respective
+ event emitters.
+ event emitter to the new event emitter but not vice versa.
+ - 'backward': ``new_listener`` events are propagated from the new event
+ emitter to the underlying event emitter, but not vice versa.
+
+ Tuning this option can be useful depending on how the ``new_listener``
+ event is used by the underlying event emitter, if at all. The default is
+ 'forward', since ``underlying`` may not know how to handle certain
+ handlers, such as asyncio coroutines.
+
+ Each event emitter tracks its own internal table of handlers.
+ ``remove_listener``, ``remove_all_listeners`` and ``listeners`` all
+ work independently. This means you will have to remember which event
+ emitter an event handler was added to!
+
+ Note that both the new event emitter returned by ``cls`` and the
+ underlying event emitter should inherit from ``EventEmitter``, or at
+ least implement the interface for the undocumented ``_call_handlers`` and
+ ``_emit_handle_potential_error`` methods.
+ """
+
+ (
+ new_proxy_new_listener,
+ underlying_proxy_new_listener,
+ ) = _PROXY_NEW_LISTENER_SETTINGS[proxy_new_listener]
+
+ new: UpliftingEventEmitter = cls(*args, **kwargs)
+
+ uplift_error_handlers: Dict[str, Tuple[EventEmitter, EventEmitter]] = dict(
+ new=(new, new), underlying=(underlying, underlying), neither=(new, underlying)
+ )
+
+ new_error_handler, underlying_error_handler = uplift_error_handlers[error_handling]
+
+ _wrap(new, underlying, new_error_handler, new_proxy_new_listener)
+ _wrap(underlying, new, underlying_error_handler, underlying_proxy_new_listener)
+
+ return new
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..59293c3
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,6 @@
+[tool.isort]
+profile = "appnexus"
+known_application = "pyee"
+
+[tool.pyright]
+include = ["python"]
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..edb4554
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,3 @@
+[pytest]
+addopts = --verbose -s
+testpaths = tests
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..0e4bc00
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1 @@
+typing-extensions==4.0.1
diff --git a/requirements_dev.txt b/requirements_dev.txt
new file mode 100644
index 0000000..cabc838
--- /dev/null
+++ b/requirements_dev.txt
@@ -0,0 +1,14 @@
+mock==4.0.2
+flake8==3.8.3
+flake8-black==0.2.3
+pytest==6.2.5
+pytest-asyncio==0.12.0; python_version >= '3.4'
+pytest-trio==0.6.0; python_version >= '3.7'
+trio==0.17.0; python_version > '3.6'
+twisted==22.10.0
+Sphinx==3.2.1
+black==21.7b0
+isort==5.10.1
+trio-typing==0.7.0
+tox==3.20.0
+twine==3.2.0
diff --git a/requirements_docs.txt b/requirements_docs.txt
new file mode 100644
index 0000000..4c17431
--- /dev/null
+++ b/requirements_docs.txt
@@ -0,0 +1,3 @@
+-r requirements.txt
+-r requirements_dev.txt
+-e .
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 0000000..8dd399a
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,3 @@
+[flake8]
+max-line-length = 88
+extend-ignore = E203
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..bdbe45b
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+
+from os import path
+
+from setuptools import find_packages, setup
+
+README_rst = path.join(path.abspath(path.dirname(__file__)), "README.rst")
+
+with open(README_rst, "r") as f:
+ long_description = f.read()
+
+setup(
+ name="pyee",
+ version="9.0.4",
+ packages=find_packages(),
+ include_package_data=True,
+ description="A port of node.js's EventEmitter to python.",
+ long_description=long_description,
+ author="Josh Holbrook",
+ author_email="josh.holbrook@gmail.com",
+ url="https://github.com/jfhbrook/pyee",
+ license="MIT",
+ keywords=["events", "emitter", "node.js", "node", "eventemitter", "event_emitter"],
+ install_requires=["typing-extensions"],
+ tests_require=["twisted", "trio"],
+ classifiers=[
+ "Programming Language :: Python",
+ "Development Status :: 4 - Beta",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: MIT License",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.5",
+ "Programming Language :: Python :: 3.6",
+ "Programming Language :: Python :: 3.7",
+ "Programming Language :: Python :: 3.8",
+ "Topic :: Other/Nonlisted Topic",
+ ],
+)
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..18b0633
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+
+from sys import version_info as v
+
+collect_ignore = []
+
+if not (v[0] >= 3 and v[1] >= 5):
+ collect_ignore.append("test_async.py")
+
+if not (v[0] >= 3 and v[1] >= 7):
+ collect_ignore.append("test_trio.py")
diff --git a/tests/test_async.py b/tests/test_async.py
new file mode 100644
index 0000000..d503c51
--- /dev/null
+++ b/tests/test_async.py
@@ -0,0 +1,190 @@
+# -*- coding: utf-8 -*-
+
+from asyncio import Future, wait_for
+
+import pytest
+import pytest_asyncio.plugin # noqa
+
+try:
+ from asyncio.exceptions import TimeoutError # type: ignore
+except ImportError:
+ from concurrent.futures import TimeoutError # type: ignore
+
+from mock import Mock
+from twisted.internet.defer import succeed
+
+from pyee import AsyncIOEventEmitter, TwistedEventEmitter
+
+
+class PyeeTestError(Exception):
+ pass
+
+
+@pytest.mark.asyncio
+async def test_asyncio_emit(event_loop):
+ """Test that AsyncIOEventEmitter can handle wrapping
+ coroutines
+ """
+
+ ee = AsyncIOEventEmitter(loop=event_loop)
+
+ should_call = Future(loop=event_loop)
+
+ @ee.on("event")
+ async def event_handler():
+ should_call.set_result(True)
+
+ ee.emit("event")
+
+ result = await wait_for(should_call, 0.1)
+
+ assert result is True
+
+
+@pytest.mark.asyncio
+async def test_asyncio_once_emit(event_loop):
+ """Test that AsyncIOEventEmitter also wrap coroutines when
+ using once
+ """
+
+ ee = AsyncIOEventEmitter(loop=event_loop)
+
+ should_call = Future(loop=event_loop)
+
+ @ee.once("event")
+ async def event_handler():
+ should_call.set_result(True)
+
+ ee.emit("event")
+
+ result = await wait_for(should_call, 0.1)
+
+ assert result is True
+
+
+@pytest.mark.asyncio
+async def test_asyncio_error(event_loop):
+ """Test that AsyncIOEventEmitter can handle errors when
+ wrapping coroutines
+ """
+ ee = AsyncIOEventEmitter(loop=event_loop)
+
+ should_call = Future(loop=event_loop)
+
+ @ee.on("event")
+ async def event_handler():
+ raise PyeeTestError()
+
+ @ee.on("error")
+ def handle_error(exc):
+ should_call.set_result(exc)
+
+ ee.emit("event")
+
+ result = await wait_for(should_call, 0.1)
+
+ assert isinstance(result, PyeeTestError)
+
+
+@pytest.mark.asyncio
+async def test_asyncio_cancellation(event_loop):
+ """Test that AsyncIOEventEmitter can handle Future cancellations"""
+
+ cancel_me = Future(loop=event_loop)
+ should_not_call = Future(loop=event_loop)
+
+ ee = AsyncIOEventEmitter(loop=event_loop)
+
+ @ee.on("event")
+ async def event_handler():
+ cancel_me.cancel()
+
+ @ee.on("error")
+ def handle_error(exc):
+ should_not_call.set_result(None)
+
+ ee.emit("event")
+
+ try:
+ await wait_for(should_not_call, 0.1)
+ except TimeoutError:
+ pass
+ else:
+ raise PyeeTestError()
+
+
+@pytest.mark.asyncio
+async def test_sync_error(event_loop):
+ """Test that regular functions have the same error handling as coroutines"""
+ ee = AsyncIOEventEmitter(loop=event_loop)
+
+ should_call = Future(loop=event_loop)
+
+ @ee.on("event")
+ def sync_handler():
+ raise PyeeTestError()
+
+ @ee.on("error")
+ def handle_error(exc):
+ should_call.set_result(exc)
+
+ ee.emit("event")
+
+ result = await wait_for(should_call, 0.1)
+
+ assert isinstance(result, PyeeTestError)
+
+
+def test_twisted_emit():
+ """Test that TwistedEventEmitter can handle wrapping
+ coroutines
+ """
+ ee = TwistedEventEmitter()
+
+ should_call = Mock()
+
+ @ee.on("event")
+ async def event_handler():
+ _ = await succeed("yes!")
+ should_call(True)
+
+ ee.emit("event")
+
+ should_call.assert_called_once()
+
+
+def test_twisted_once():
+ """Test that TwistedEventEmitter also wraps coroutines for
+ once
+ """
+ ee = TwistedEventEmitter()
+
+ should_call = Mock()
+
+ @ee.once("event")
+ async def event_handler():
+ _ = await succeed("yes!")
+ should_call(True)
+
+ ee.emit("event")
+
+ should_call.assert_called_once()
+
+
+def test_twisted_error():
+ """Test that TwistedEventEmitters handle Failures when wrapping coroutines."""
+ ee = TwistedEventEmitter()
+
+ should_call = Mock()
+
+ @ee.on("event")
+ async def event_handler():
+ raise PyeeTestError()
+
+ @ee.on("failure")
+ def handle_error(e):
+ should_call(e)
+
+ ee.emit("event")
+
+ should_call.assert_called_once()
diff --git a/tests/test_cls.py b/tests/test_cls.py
new file mode 100644
index 0000000..d7ca3ec
--- /dev/null
+++ b/tests/test_cls.py
@@ -0,0 +1,47 @@
+# -*- coding: utf-8 -*-
+from mock import Mock
+import pytest
+
+from pyee import EventEmitter
+from pyee.cls import evented, on
+
+
+@evented
+class EventedFixture:
+ def __init__(self):
+ self.call_me = Mock()
+
+ @on("event")
+ def event_handler(self, *args, **kwargs):
+ self.call_me(self, *args, **kwargs)
+
+
+_custom_event_emitter = EventEmitter()
+
+
+@evented
+class CustomEmitterFixture:
+ def __init__(self):
+ self.call_me = Mock()
+ self.event_emitter = _custom_event_emitter
+
+ @on("event")
+ def event_handler(self, *args, **kwargs):
+ self.call_me(self, *args, **kwargs)
+
+
+class InheritedFixture(EventedFixture):
+ pass
+
+
+@pytest.mark.parametrize(
+ "cls", [EventedFixture, CustomEmitterFixture, InheritedFixture]
+)
+def test_evented_decorator(cls):
+ inst = cls()
+
+ inst.event_emitter.emit("event", "emitter is emitted!")
+
+ inst.call_me.assert_called_once_with(inst, "emitter is emitted!")
+
+ _custom_event_emitter.remove_all_listeners()
diff --git a/tests/test_executor.py b/tests/test_executor.py
new file mode 100644
index 0000000..a7fef48
--- /dev/null
+++ b/tests/test_executor.py
@@ -0,0 +1,61 @@
+# -*- coding: utf-8 -*-
+
+from time import sleep
+
+from mock import Mock
+
+from pyee import ExecutorEventEmitter
+
+
+class PyeeTestError(Exception):
+ pass
+
+
+def test_executor_emit():
+ """Test that ExecutorEventEmitters can emit events."""
+ with ExecutorEventEmitter() as ee:
+ should_call = Mock()
+
+ @ee.on("event")
+ def event_handler():
+ should_call(True)
+
+ ee.emit("event")
+ sleep(0.1)
+
+ should_call.assert_called_once()
+
+
+def test_executor_once():
+ """Test that ExecutorEventEmitters also emit events for once."""
+ with ExecutorEventEmitter() as ee:
+ should_call = Mock()
+
+ @ee.once("event")
+ def event_handler():
+ should_call(True)
+
+ ee.emit("event")
+ sleep(0.1)
+
+ should_call.assert_called_once()
+
+
+def test_executor_error():
+ """Test that ExecutorEventEmitters handle errors."""
+ with ExecutorEventEmitter() as ee:
+ should_call = Mock()
+
+ @ee.on("event")
+ def event_handler():
+ raise PyeeTestError()
+
+ @ee.on("error")
+ def handle_error(e):
+ should_call(e)
+
+ ee.emit("event")
+
+ sleep(0.1)
+
+ should_call.assert_called_once()
diff --git a/tests/test_sync.py b/tests/test_sync.py
new file mode 100644
index 0000000..a09bf00
--- /dev/null
+++ b/tests/test_sync.py
@@ -0,0 +1,280 @@
+# -*- coding: utf-8 -*-
+from collections import OrderedDict
+
+from mock import Mock
+from pytest import raises
+
+from pyee import EventEmitter
+
+
+class PyeeTestException(Exception):
+ pass
+
+
+def test_emit_sync():
+ """Basic synchronous emission works"""
+
+ call_me = Mock()
+ ee = EventEmitter()
+
+ @ee.on("event")
+ def event_handler(data, **kwargs):
+ call_me()
+ assert data == "emitter is emitted!"
+
+ assert ee.event_names() == {"event"}
+
+ # Making sure data is passed propers
+ ee.emit("event", "emitter is emitted!", error=False)
+
+ call_me.assert_called_once()
+
+
+def test_emit_error():
+ """Errors raise with no event handler, otherwise emit on handler"""
+
+ call_me = Mock()
+ ee = EventEmitter()
+
+ test_exception = PyeeTestException("lololol")
+
+ with raises(PyeeTestException):
+ ee.emit("error", test_exception)
+
+ @ee.on("error")
+ def on_error(exc):
+ call_me()
+
+ assert ee.event_names() == {"error"}
+
+ # No longer raises and error instead return True indicating handled
+ assert ee.emit("error", test_exception) is True
+ call_me.assert_called_once()
+
+
+def test_emit_return():
+ """Emit returns True when handlers are registered on an event, and false
+ otherwise.
+ """
+
+ call_me = Mock()
+ ee = EventEmitter()
+
+ assert ee.event_names() == set()
+
+ # make sure emitting without a callback returns False
+ assert not ee.emit("data")
+
+ # add a callback
+ ee.on("data")(call_me)
+
+ # should return True now
+ assert ee.emit("data")
+
+
+def test_new_listener_event():
+ """The 'new_listener' event fires whenever a new listener is added."""
+
+ call_me = Mock()
+ ee = EventEmitter()
+
+ ee.on("new_listener", call_me)
+
+ # Should fire new_listener event
+ @ee.on("event")
+ def event_handler(data):
+ pass
+
+ assert ee.event_names() == {"new_listener", "event"}
+
+ call_me.assert_called_once_with("event", event_handler)
+
+
+def test_listener_removal():
+ """Removing listeners removes the correct listener from an event."""
+
+ ee = EventEmitter()
+
+ # Some functions to pass to the EE
+ def first():
+ return 1
+
+ ee.on("event", first)
+
+ @ee.on("event")
+ def second():
+ return 2
+
+ @ee.on("event")
+ def third():
+ return 3
+
+ def fourth():
+ return 4
+
+ ee.on("event", fourth)
+
+ assert ee.event_names() == {"event"}
+
+ assert ee._events["event"] == OrderedDict(
+ [(first, first), (second, second), (third, third), (fourth, fourth)]
+ )
+
+ ee.remove_listener("event", second)
+
+ assert ee._events["event"] == OrderedDict(
+ [(first, first), (third, third), (fourth, fourth)]
+ )
+
+ ee.remove_listener("event", first)
+ assert ee._events["event"] == OrderedDict([(third, third), (fourth, fourth)])
+
+ ee.remove_all_listeners("event")
+ assert "event" not in ee._events["event"]
+
+
+def test_listener_removal_on_emit():
+ """Test that a listener removed during an emit is called inside the current
+ emit cycle.
+ """
+
+ call_me = Mock()
+ ee = EventEmitter()
+
+ def should_remove():
+ ee.remove_listener("remove", call_me)
+
+ ee.on("remove", should_remove)
+ ee.on("remove", call_me)
+
+ assert ee.event_names() == {"remove"}
+
+ ee.emit("remove")
+
+ call_me.assert_called_once()
+
+ call_me.reset_mock()
+
+ # Also test with the listeners added in the opposite order
+ ee = EventEmitter()
+ ee.on("remove", call_me)
+ ee.on("remove", should_remove)
+
+ assert ee.event_names() == {"remove"}
+
+ ee.emit("remove")
+
+ call_me.assert_called_once()
+
+
+def test_once():
+ """Test that `once()` method works propers."""
+
+ # very similar to "test_emit" but also makes sure that the event
+ # gets removed afterwards
+
+ call_me = Mock()
+ ee = EventEmitter()
+
+ def once_handler(data):
+ assert data == "emitter is emitted!"
+ call_me()
+
+ # Tests to make sure that after event is emitted that it's gone.
+ ee.once("event", once_handler)
+
+ assert ee.event_names() == {"event"}
+
+ ee.emit("event", "emitter is emitted!")
+
+ call_me.assert_called_once()
+
+ assert ee.event_names() == set()
+
+ assert "event" not in ee._events
+
+
+def test_once_removal():
+ """Removal of once functions works"""
+
+ ee = EventEmitter()
+
+ def once_handler(data):
+ pass
+
+ handle = ee.once("event", once_handler)
+
+ assert handle == once_handler
+ assert ee.event_names() == {"event"}
+
+ ee.remove_listener("event", handle)
+
+ assert "event" not in ee._events
+ assert ee.event_names() == set()
+
+
+def test_listeners():
+ """`listeners()` returns a copied list of listeners."""
+
+ call_me = Mock()
+ ee = EventEmitter()
+
+ @ee.on("event")
+ def event_handler():
+ pass
+
+ @ee.once("event")
+ def once_handler():
+ pass
+
+ listeners = ee.listeners("event")
+
+ assert listeners[0] == event_handler
+ assert listeners[1] == once_handler
+
+ # listeners is a copy, you can't mutate the innards this way
+ listeners[0] = call_me
+
+ ee.emit("event")
+
+ call_me.assert_not_called()
+
+
+def test_listeners_does_work_with_unknown_listeners():
+ """`listeners()` should not throw."""
+ ee = EventEmitter()
+ listeners = ee.listeners("event")
+ assert listeners == []
+
+
+def test_properties_preserved():
+ """Test that the properties of decorated functions are preserved."""
+
+ call_me = Mock()
+ call_me_also = Mock()
+ ee = EventEmitter()
+
+ @ee.on("always")
+ def always_event_handler():
+ """An event handler."""
+ call_me()
+
+ @ee.once("once")
+ def once_event_handler():
+ """Another event handler."""
+ call_me_also()
+
+ assert always_event_handler.__doc__ == "An event handler."
+ assert once_event_handler.__doc__ == "Another event handler."
+
+ always_event_handler()
+ call_me.assert_called_once()
+
+ once_event_handler()
+ call_me_also.assert_called_once()
+
+ call_me_also.reset_mock()
+
+ # Calling the event handler directly doesn't clear the handler
+ ee.emit("once")
+ call_me_also.assert_called_once()
diff --git a/tests/test_trio.py b/tests/test_trio.py
new file mode 100644
index 0000000..3877849
--- /dev/null
+++ b/tests/test_trio.py
@@ -0,0 +1,112 @@
+# -*- coding: utf-8 -*-
+
+import pytest
+import pytest_trio.plugin # noqa
+import trio
+
+from pyee import TrioEventEmitter
+
+
+class PyeeTestError(Exception):
+ pass
+
+
+@pytest.mark.trio
+async def test_trio_emit():
+ """Test that the trio event emitter can handle wrapping
+ coroutines
+ """
+
+ async with TrioEventEmitter() as ee:
+
+ should_call = trio.Event()
+
+ @ee.on("event")
+ async def event_handler():
+ should_call.set()
+
+ ee.emit("event")
+
+ result = False
+ with trio.move_on_after(0.1):
+ await should_call.wait()
+ result = True
+
+ assert result
+
+
+@pytest.mark.trio
+async def test_trio_once_emit():
+ """Test that trio event emitters also wrap coroutines when
+ using once
+ """
+
+ async with TrioEventEmitter() as ee:
+ should_call = trio.Event()
+
+ @ee.once("event")
+ async def event_handler():
+ should_call.set()
+
+ ee.emit("event")
+
+ result = False
+ with trio.move_on_after(0.1):
+ await should_call.wait()
+ result = True
+
+ assert result
+
+
+@pytest.mark.trio
+async def test_trio_error():
+ """Test that trio event emitters can handle errors when
+ wrapping coroutines
+ """
+
+ async with TrioEventEmitter() as ee:
+ send, rcv = trio.open_memory_channel(1)
+
+ @ee.on("event")
+ async def event_handler():
+ raise PyeeTestError()
+
+ @ee.on("error")
+ async def handle_error(exc):
+ async with send:
+ await send.send(exc)
+
+ ee.emit("event")
+
+ result = None
+ with trio.move_on_after(0.1):
+ async with rcv:
+ result = await rcv.__anext__()
+
+ assert isinstance(result, PyeeTestError)
+
+
+@pytest.mark.trio
+async def test_sync_error(event_loop):
+ """Test that regular functions have the same error handling as coroutines"""
+
+ async with TrioEventEmitter() as ee:
+ send, rcv = trio.open_memory_channel(1)
+
+ @ee.on("event")
+ def sync_handler():
+ raise PyeeTestError()
+
+ @ee.on("error")
+ async def handle_error(exc):
+ async with send:
+ await send.send(exc)
+
+ ee.emit("event")
+
+ result = None
+ with trio.move_on_after(0.1):
+ async with rcv:
+ result = await rcv.__anext__()
+
+ assert isinstance(result, PyeeTestError)
diff --git a/tests/test_twisted.py b/tests/test_twisted.py
new file mode 100644
index 0000000..6a667ed
--- /dev/null
+++ b/tests/test_twisted.py
@@ -0,0 +1,80 @@
+# -*- coding: utf-8 -*-
+
+from mock import Mock
+from twisted.internet.defer import inlineCallbacks
+from twisted.python.failure import Failure
+
+from pyee import TwistedEventEmitter
+
+
+class PyeeTestError(Exception):
+ pass
+
+
+def test_propagates_failure():
+ """Test that TwistedEventEmitters can propagate failures
+ from twisted Deferreds
+ """
+ ee = TwistedEventEmitter()
+
+ should_call = Mock()
+
+ @ee.on("event")
+ @inlineCallbacks
+ def event_handler():
+ yield Failure(PyeeTestError())
+
+ @ee.on("failure")
+ def handle_failure(f):
+ assert isinstance(f, Failure)
+ should_call(f)
+
+ ee.emit("event")
+
+ should_call.assert_called_once()
+
+
+def test_propagates_sync_failure():
+ """Test that TwistedEventEmitters can propagate failures
+ from twisted Deferreds
+ """
+ ee = TwistedEventEmitter()
+
+ should_call = Mock()
+
+ @ee.on("event")
+ def event_handler():
+ raise PyeeTestError()
+
+ @ee.on("failure")
+ def handle_failure(f):
+ assert isinstance(f, Failure)
+ should_call(f)
+
+ ee.emit("event")
+
+ should_call.assert_called_once()
+
+
+def test_propagates_exception():
+ """Test that TwistedEventEmitters propagate failures as exceptions to
+ the error event when no failure handler
+ """
+
+ ee = TwistedEventEmitter()
+
+ should_call = Mock()
+
+ @ee.on("event")
+ @inlineCallbacks
+ def event_handler():
+ yield Failure(PyeeTestError())
+
+ @ee.on("error")
+ def handle_error(exc):
+ assert isinstance(exc, Exception)
+ should_call(exc)
+
+ ee.emit("event")
+
+ should_call.assert_called_once()
diff --git a/tests/test_uplift.py b/tests/test_uplift.py
new file mode 100644
index 0000000..69350e0
--- /dev/null
+++ b/tests/test_uplift.py
@@ -0,0 +1,201 @@
+# -*- coding: utf-8 -*-
+
+from mock import call, Mock
+import pytest
+
+from pyee import EventEmitter
+from pyee.uplift import unwrap, uplift
+
+
+class UpliftedEventEmitter(EventEmitter):
+ pass
+
+
+def test_uplift_emit():
+ call_me = Mock()
+
+ base_ee = EventEmitter()
+
+ @base_ee.on("base_event")
+ def base_handler():
+ call_me("base event on base emitter")
+
+ @base_ee.on("shared_event")
+ def shared_base_handler():
+ call_me("shared event on base emitter")
+
+ uplifted_ee = uplift(UpliftedEventEmitter, base_ee)
+
+ assert isinstance(uplifted_ee, UpliftedEventEmitter), "Returns an uplifted emitter"
+
+ @uplifted_ee.on("uplifted_event")
+ def uplifted_handler():
+ call_me("uplifted event on uplifted emitter")
+
+ @uplifted_ee.on("shared_event")
+ def shared_uplifted_handler():
+ call_me("shared event on uplifted emitter")
+
+ # Events on uplifted proxy correctly
+ assert uplifted_ee.emit("base_event")
+ assert uplifted_ee.emit("shared_event")
+ assert uplifted_ee.emit("uplifted_event")
+
+ call_me.assert_has_calls(
+ [
+ call("base event on base emitter"),
+ call("shared event on uplifted emitter"),
+ call("shared event on base emitter"),
+ call("uplifted event on uplifted emitter"),
+ ]
+ )
+
+ call_me.reset_mock()
+
+ # Events on underlying proxy correctly
+ assert base_ee.emit("base_event")
+ assert base_ee.emit("shared_event")
+ assert base_ee.emit("uplifted_event")
+
+ call_me.assert_has_calls(
+ [
+ call("base event on base emitter"),
+ call("shared event on base emitter"),
+ call("shared event on uplifted emitter"),
+ call("uplifted event on uplifted emitter"),
+ ]
+ )
+
+ call_me.reset_mock()
+
+ # Quick check for unwrap
+ unwrap(uplifted_ee)
+
+ with pytest.raises(AttributeError):
+ getattr(uplifted_ee, "unwrap")
+
+ with pytest.raises(AttributeError):
+ getattr(base_ee, "unwrap")
+
+ assert not uplifted_ee.emit("base_event")
+ assert uplifted_ee.emit("shared_event")
+ assert uplifted_ee.emit("uplifted_event")
+
+ assert base_ee.emit("base_event")
+ assert base_ee.emit("shared_event")
+ assert not base_ee.emit("uplifted_event")
+
+ call_me.assert_has_calls(
+ [
+ # No listener for base event on uplifted
+ call("shared event on uplifted emitter"),
+ call("uplifted event on uplifted emitter"),
+ call("base event on base emitter"),
+ call("shared event on base emitter")
+ # No listener for uplifted event on uplifted
+ ]
+ )
+
+
+@pytest.mark.parametrize("error_handling", ["new", "underlying", "neither"])
+def test_exception_handling(error_handling):
+ base_ee = EventEmitter()
+ uplifted_ee = uplift(UpliftedEventEmitter, base_ee, error_handling=error_handling)
+
+ # Exception handling always prefers uplifted
+ base_error = Exception("base error")
+ uplifted_error = Exception("uplifted error")
+
+ # Hold my beer
+ base_error_handler = Mock()
+ base_ee._emit_handle_potential_error = base_error_handler
+
+ # Hold my other beer
+ uplifted_error_handler = Mock()
+ uplifted_ee._emit_handle_potential_error = uplifted_error_handler
+
+ base_ee.emit("error", base_error)
+ uplifted_ee.emit("error", uplifted_error)
+
+ if error_handling == "new":
+ base_error_handler.assert_not_called()
+ uplifted_error_handler.assert_has_calls(
+ [call("error", base_error), call("error", uplifted_error)]
+ )
+ elif error_handling == "underlying":
+ base_error_handler.assert_has_calls(
+ [call("error", base_error), call("error", uplifted_error)]
+ )
+ uplifted_error_handler.assert_not_called()
+ elif error_handling == "neither":
+ base_error_handler.assert_called_once_with("error", base_error)
+ uplifted_error_handler.assert_called_once_with("error", uplifted_error)
+ else:
+ raise Exception("unrecognized setting")
+
+
+@pytest.mark.parametrize(
+ "proxy_new_listener", ["both", "neither", "forward", "backward"]
+)
+def test_proxy_new_listener(proxy_new_listener):
+ call_me = Mock()
+
+ base_ee = EventEmitter()
+
+ uplifted_ee = uplift(
+ UpliftedEventEmitter, base_ee, proxy_new_listener=proxy_new_listener
+ )
+
+ @base_ee.on("new_listener")
+ def base_new_listener_handler(event, f):
+ assert event in ("event", "new_listener")
+ call_me("base new listener handler", f)
+
+ @uplifted_ee.on("new_listener")
+ def uplifted_new_listener_handler(event, f):
+ assert event in ("event", "new_listener")
+ call_me("uplifted new listener handler", f)
+
+ def fresh_base_handler():
+ pass
+
+ def fresh_uplifted_handler():
+ pass
+
+ base_ee.on("event", fresh_base_handler)
+ uplifted_ee.on("event", fresh_uplifted_handler)
+
+ if proxy_new_listener == "both":
+ call_me.assert_has_calls(
+ [
+ call("base new listener handler", fresh_base_handler),
+ call("uplifted new listener handler", fresh_base_handler),
+ call("uplifted new listener handler", fresh_uplifted_handler),
+ call("base new listener handler", fresh_uplifted_handler),
+ ]
+ )
+ elif proxy_new_listener == "neither":
+ call_me.assert_has_calls(
+ [
+ call("base new listener handler", fresh_base_handler),
+ call("uplifted new listener handler", fresh_uplifted_handler),
+ ]
+ )
+ elif proxy_new_listener == "forward":
+ call_me.assert_has_calls(
+ [
+ call("base new listener handler", fresh_base_handler),
+ call("uplifted new listener handler", fresh_base_handler),
+ call("uplifted new listener handler", fresh_uplifted_handler),
+ ]
+ )
+ elif proxy_new_listener == "backward":
+ call_me.assert_has_calls(
+ [
+ call("base new listener handler", fresh_base_handler),
+ call("uplifted new listener handler", fresh_uplifted_handler),
+ call("base new listener handler", fresh_uplifted_handler),
+ ]
+ )
+ else:
+ raise Exception("unrecognized proxy_new_listener")
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..86f7fef
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,9 @@
+[tox]
+envlist = py38,py39,py310
+
+[testenv]
+deps =
+ -rrequirements_test.txt
+commands =
+ flake8
+ pytest ./tests
diff --git a/typings/twisted/python/failure.pyi b/typings/twisted/python/failure.pyi
new file mode 100644
index 0000000..dabec96
--- /dev/null
+++ b/typings/twisted/python/failure.pyi
@@ -0,0 +1,5 @@
+class Failure(BaseException):
+ value: Exception
+
+ def raiseException() -> None:
+ ...