diff options
author | uael <uael@google.com> | 2023-02-28 20:06:58 +0000 |
---|---|---|
committer | Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com> | 2023-02-28 20:06:58 +0000 |
commit | 94bb11b0296ef32cbb5894032e48be3104f44a5e (patch) | |
tree | f8212a46b7996e853daab468a74a7619c457f38f | |
parent | 6e442d50a511673fe4d4407145f8f5dff9dc3878 (diff) | |
parent | d92a09e29802087aa99e68907f01b8e22ebf64b5 (diff) | |
download | pyee-94bb11b0296ef32cbb5894032e48be3104f44a5e.tar.gz |
Merge remote-tracking branch 'aosp/upstream-main' into master am: cb4c0971bc am: f125ae6e90 am: 5a4b11e827 am: 52e70c9fc3 am: d92a09e298
Original change: https://android-review.googlesource.com/c/platform/external/python/pyee/+/2441564
Change-Id: Ieebfe6131509e6c3e4fa4fb6f0a8f342e1d0cc7b
Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
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. @@ -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") @@ -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: + ... |