From cb4c0971bc09a78990b01d9e5721807f286fe1e0 Mon Sep 17 00:00:00 2001 From: uael Date: Fri, 17 Feb 2023 15:46:01 +0000 Subject: Merge remote-tracking branch 'aosp/upstream-main' into master Test: None Change-Id: I6e0570d75fe1ab0743f6630ac3e6a5a73cd1d5b3 --- .github/workflows/qa.yaml | 34 ++++ .gitignore | 15 ++ .readthedocs.yaml | 12 ++ CHANGELOG.rst | 240 ++++++++++++++++++++++++++ CONTRIBUTORS.rst | 16 ++ DEVELOPMENT.rst | 123 +++++++++++++ LICENSE | 21 +++ MANIFEST.in | 8 + Makefile | 48 ++++++ README.rst | 38 ++++ docs/Makefile | 225 ++++++++++++++++++++++++ docs/conf.py | 343 +++++++++++++++++++++++++++++++++++++ docs/index.rst | 63 +++++++ environment.yml | 14 ++ package-lock.json | 37 ++++ package.json | 22 +++ pyee/__init__.py | 138 +++++++++++++++ pyee/asyncio.py | 73 ++++++++ pyee/base.py | 239 ++++++++++++++++++++++++++ pyee/cls.py | 112 ++++++++++++ pyee/executor.py | 79 +++++++++ pyee/py.typed | 0 pyee/trio.py | 129 ++++++++++++++ pyee/twisted.py | 93 ++++++++++ pyee/uplift.py | 178 +++++++++++++++++++ pyproject.toml | 6 + pytest.ini | 3 + requirements.txt | 1 + requirements_dev.txt | 14 ++ requirements_docs.txt | 3 + setup.cfg | 3 + setup.py | 40 +++++ tests/conftest.py | 11 ++ tests/test_async.py | 190 ++++++++++++++++++++ tests/test_cls.py | 47 +++++ tests/test_executor.py | 61 +++++++ tests/test_sync.py | 280 ++++++++++++++++++++++++++++++ tests/test_trio.py | 112 ++++++++++++ tests/test_twisted.py | 80 +++++++++ tests/test_uplift.py | 201 ++++++++++++++++++++++ tox.ini | 9 + typings/twisted/python/failure.pyi | 5 + 42 files changed, 3366 insertions(+) create mode 100644 .github/workflows/qa.yaml create mode 100644 .gitignore create mode 100644 .readthedocs.yaml create mode 100644 CHANGELOG.rst create mode 100644 CONTRIBUTORS.rst create mode 100644 DEVELOPMENT.rst create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 Makefile create mode 100644 README.rst create mode 100644 docs/Makefile create mode 100644 docs/conf.py create mode 100644 docs/index.rst create mode 100644 environment.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 pyee/__init__.py create mode 100644 pyee/asyncio.py create mode 100644 pyee/base.py create mode 100644 pyee/cls.py create mode 100644 pyee/executor.py create mode 100644 pyee/py.typed create mode 100644 pyee/trio.py create mode 100644 pyee/twisted.py create mode 100644 pyee/uplift.py create mode 100644 pyproject.toml create mode 100644 pytest.ini create mode 100644 requirements.txt create mode 100644 requirements_dev.txt create mode 100644 requirements_docs.txt create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 tests/conftest.py create mode 100644 tests/test_async.py create mode 100644 tests/test_cls.py create mode 100644 tests/test_executor.py create mode 100644 tests/test_sync.py create mode 100644 tests/test_trio.py create mode 100644 tests/test_twisted.py create mode 100644 tests/test_uplift.py create mode 100644 tox.ini create mode 100644 typings/twisted/python/failure.pyi 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 +- Gleicon Moraes @gleicon +- Zack Do @doboy +- @Zearin +- René Kijewski @Kijewski +- Gabe Appleton @gappleto97 +- Daniel M. Capella @polyzen +- Fabian Affolter @fabaff +- Anton Bolshakov @blshkv +- Åke Forslund @forslund +- 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 ` 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 `, log in, then go +to `the pyee project page ` and build +latest and stable. + +Announce on Twitter +~~~~~~~~~~~~~~~~~~~ + +It's not official, but I like to announce the release on Twitter. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..67dd129 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2021 Josh Holbrook + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..91b0e6b --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,8 @@ +include LICENSE +include README.rst +include CHANGELOG.rst +include CONTRIBUTORS.rst +include DEVELOPMENT.rst +include version.txt +include pyee/py.typed +recursive-include tests *.py diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9eba7d8 --- /dev/null +++ b/Makefile @@ -0,0 +1,48 @@ +.PHONY: setup setup-conda package upload check test tox lint format build_docs serve_docs clean + +setup: + python3 -m venv venv + if [ -d venv ]; then . ./venv/bin/activate; fi; pip install pip wheel --upgrade + if [ -d venv ]; then . ./venv/bin/activate; fi; pip install -r requirements.txt + if [ -d venv ]; then . ./venv/bin/activate; fi; pip install -r requirements_dev.txt + if [ -d venv ]; then . ./venv/bin/activate; fi; pip install -e . + npm i + +package: test lint + if [ -d venv ]; then . ./venv/bin/activate; fi; python setup.py check + if [ -d venv ]; then . ./venv/bin/activate; fi; python setup.py sdist + if [ -d venv ]; then . ./venv/bin/activate; fi; python setup.py bdist_wheel --universal + +upload: + if [ -d venv ]; then . ./venv/bin/activate; fi; twine upload dist/* + +check: + if [ -d venv ]; then . ./venv/bin/activate; fi; npm run pyright + +test: + if [ -d venv ]; then . ./venv/bin/activate; fi; pytest ./tests + +tox: + if [ -d venv ]; then . ./venv/bin/activate; fi; tox + +lint: + if [ -d venv ]; then . ./venv/bin/activate; fi; flake8 ./pyee setup.py ./tests ./docs + +format: + if [ -d venv ]; then . ./venv/bin/activate; fi; black ./pyee setup.py ./tests ./docs + if [ -d venv ]; then . ./venv/bin/activate; fi; isort ./pyee setup.py ./tests ./docs + +build_docs: + if [ -d venv ]; then . ./venv/bin/activate; fi; cd docs && make html + +serve_docs: build_docs + if [ -d venv ]; then . ./venv/bin/activate; fi; cd docs/_build/html && python -m http.server + +clean: + rm -rf .tox + rm -rf dist + rm -rf pyee.egg-info + rm -rf pyee/*.pyc + rm -rf pyee/__pycache__ + rm -rf pytest_runner-*.egg + rm -rf tests/__pycache__ diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..a31a220 --- /dev/null +++ b/README.rst @@ -0,0 +1,38 @@ +pyee +==== + +.. image:: https://travis-ci.org/jfhbrook/pyee.png + :target: https://travis-ci.org/jfhbrook/pyee +.. image:: https://readthedocs.org/projects/pyee/badge/?version=latest + :target: https://pyee.readthedocs.io + +pyee supplies a ``EventEmitter`` object that is similar to the +``EventEmitter`` class from Node.js. It also supplies a number of subclasses +with added support for async and threaded programming in python, such as +async/await as seen in python 3.5+. + +Docs: +----- + +Autogenerated API docs, including basic installation directions and examples, +can be found at https://pyee.readthedocs.io . + +Development: +------------ + +See ``DEVELOPMENT.rst``. + +Changelog: +---------- + +See ``CHANGELOG.rst``. + +Contributors: +------------- + +See ``CONTRIBUTORS.rst``. + +License: +-------- + +MIT/X11, see ``LICENSE``. diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..011dc88 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,225 @@ +# Makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +PAPER = +BUILDDIR = _build + +# Internal variables. +PAPEROPT_a4 = -D latex_paper_size=a4 +PAPEROPT_letter = -D latex_paper_size=letter +ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# the i18n builder cannot share the environment and doctrees with the others +I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . + +.PHONY: help +help: + @echo "Please use \`make ' where 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. +# " v 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 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 `_. 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! `_ +* `These Very Docs on readthedocs.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 diff --git a/pyee/trio.py b/pyee/trio.py new file mode 100644 index 0000000..e79d457 --- /dev/null +++ b/pyee/trio.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- + +from contextlib import AbstractAsyncContextManager, asynccontextmanager +from types import TracebackType +from typing import Any, AsyncGenerator, Awaitable, Callable, Dict, Optional, Tuple, Type + +import trio + +from pyee.base import EventEmitter, PyeeException + +__all__ = ["TrioEventEmitter"] + + +Nursery = trio.Nursery + + +class TrioEventEmitter(EventEmitter): + """An event emitter class which can run trio tasks in a trio nursery. + + By default, this class will lazily create both a nursery manager (the + object returned from ``trio.open_nursery()`` and a nursery (the object + yielded by using the nursery manager as an async context manager). It is + also possible to supply an existing nursery manager via the ``manager`` + argument, or an existing nursery via the ``nursery`` argument. + + Instances of TrioEventEmitter are themselves async context managers, so + that they may manage the lifecycle of the underlying trio nursery. For + example, typical usage of this library may look something like this:: + + async with TrioEventEmitter() as ee: + # Underlying nursery is instantiated and ready to go + @ee.on('data') + async def handler(data): + print(data) + + ee.emit('event') + + # Underlying nursery and manager have been cleaned up + + Unlike the case with the EventEmitter, all exceptions raised by event + handlers are automatically emitted on the ``error`` event. This is + important for trio coroutines specifically but is also handled for + synchronous functions for consistency. + + For trio coroutine event handlers, calling emit is non-blocking. In other + words, you should not attempt to await emit; the coroutine is scheduled + in a fire-and-forget fashion. + """ + + def __init__( + self, + nursery: Nursery = None, + manager: "AbstractAsyncContextManager[trio.Nursery]" = None, + ): + super(TrioEventEmitter, self).__init__() + self._nursery: Optional[Nursery] = None + self._manager: Optional["AbstractAsyncContextManager[trio.Nursery]"] = None + if nursery: + if manager: + raise PyeeException( + "You may either pass a nursery or a nursery manager " "but not both" + ) + self._nursery = nursery + elif manager: + self._manager = manager + else: + self._manager = trio.open_nursery() + + def _async_runner( + self, + f: Callable, + args: Tuple[Any, ...], + kwargs: Dict[str, Any], + ) -> Callable[[], Awaitable[None]]: + async def runner() -> None: + try: + await f(*args, **kwargs) + except Exception as exc: + self.emit("error", exc) + + return runner + + def _emit_run( + self, + f: Callable, + args: Tuple[Any, ...], + kwargs: Dict[str, Any], + ) -> None: + if not self._nursery: + raise PyeeException("Uninitialized trio nursery") + self._nursery.start_soon(self._async_runner(f, args, kwargs)) + + @asynccontextmanager + async def context( + self, + ) -> AsyncGenerator["TrioEventEmitter", None]: + """Returns an async contextmanager which manages the underlying + nursery to the EventEmitter. The ``TrioEventEmitter``'s + async context management methods are implemented using this + function, but it may also be used directly for clarity. + """ + if self._nursery is not None: + yield self + elif self._manager is not None: + async with self._manager as nursery: + self._nursery = nursery + yield self + else: + raise PyeeException("Uninitialized nursery or nursery manager") + + async def __aenter__(self) -> "TrioEventEmitter": + self._context: Optional[ + AbstractAsyncContextManager["TrioEventEmitter"] + ] = self.context() + return await self._context.__aenter__() + + async def __aexit__( + self, + type: Optional[Type[BaseException]], + value: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> Optional[bool]: + if self._context is None: + raise PyeeException("Attempting to exit uninitialized context") + rv = await self._context.__aexit__(type, value, traceback) + self._context = None + self._nursery = None + self._manager = None + return rv diff --git a/pyee/twisted.py b/pyee/twisted.py new file mode 100644 index 0000000..2b9d20b --- /dev/null +++ b/pyee/twisted.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- + +from typing import Any, Callable, Dict, Tuple + +from twisted.internet.defer import Deferred, ensureDeferred +from twisted.python.failure import Failure + +from pyee.base import EventEmitter, PyeeException + +try: + from asyncio import iscoroutine +except ImportError: + iscoroutine = None + + +__all__ = ["TwistedEventEmitter"] + + +class TwistedEventEmitter(EventEmitter): + """An event emitter class which can run twisted coroutines and handle + returned Deferreds, in addition to synchronous blocking functions. For + example:: + + @ee.on('event') + @inlineCallbacks + def async_handler(*args, **kwargs): + yield returns_a_deferred() + + or:: + + @ee.on('event') + async def async_handler(*args, **kwargs): + await returns_a_deferred() + + + When async handlers fail, Failures are first emitted on the ``failure`` + event. If there are no ``failure`` handlers, the Failure's associated + exception is then emitted on the ``error`` event. If there are no ``error`` + handlers, the exception is raised. For consistency, when handlers raise + errors synchronously, they're captured, wrapped in a Failure and treated + as an async failure. This is unlike the behavior of EventEmitter, + which have no special error handling. + + For twisted coroutine event handlers, calling emit is non-blocking. + In other words, you do not have to await any results from emit, and the + coroutine is scheduled in a fire-and-forget fashion. + + Similar behavior occurs for "sync" functions which return Deferreds. + """ + + def __init__(self): + super(TwistedEventEmitter, self).__init__() + + def _emit_run( + self, + f: Callable, + args: Tuple[Any, ...], + kwargs: Dict[str, Any], + ) -> None: + d = None + try: + result = f(*args, **kwargs) + except Exception: + self.emit("failure", Failure()) + else: + if iscoroutine and iscoroutine(result): + d: Deferred[Any] = ensureDeferred(result) + elif isinstance(result, Deferred): + d = result + else: + return + + def errback(failure: Failure) -> None: + if failure: + self.emit("failure", failure) + + d.addErrback(errback) + + def _emit_handle_potential_error(self, event: str, error: Any) -> None: + if event == "failure": + if isinstance(error, Failure): + try: + error.raiseException() + except Exception as exc: + self.emit("error", exc) + elif isinstance(error, Exception): + self.emit("error", error) + else: + self.emit("error", PyeeException(f"Unexpected failure object: {error}")) + else: + (super(TwistedEventEmitter, self))._emit_handle_potential_error( + event, error + ) diff --git a/pyee/uplift.py b/pyee/uplift.py new file mode 100644 index 0000000..aa5f55a --- /dev/null +++ b/pyee/uplift.py @@ -0,0 +1,178 @@ +# -*- coding: utf-8 -*- + +from functools import wraps +from typing import Any, Callable, Dict, Optional, Tuple, Type, TypeVar, Union +import warnings + +from typing_extensions import Literal + +from pyee.base import EventEmitter + +UpliftingEventEmitter = TypeVar(name="UpliftingEventEmitter", bound=EventEmitter) + + +EMIT_WRAPPERS: Dict[EventEmitter, Callable[[], None]] = dict() + + +def unwrap(event_emitter: EventEmitter) -> None: + """Unwrap an uplifted EventEmitter, returning it to its prior state.""" + if event_emitter in EMIT_WRAPPERS: + EMIT_WRAPPERS[event_emitter]() + + +def _wrap( + left: EventEmitter, + right: EventEmitter, + error_handler: Any, + proxy_new_listener: bool, +) -> None: + left_emit = left.emit + left_unwrap: Optional[Callable[[], None]] = EMIT_WRAPPERS.get(left) + + @wraps(left_emit) + def wrapped_emit(event: str, *args: Any, **kwargs: Any) -> bool: + left_handled: bool = left._call_handlers(event, args, kwargs) + + # Do it for the right side + if proxy_new_listener or event != "new_listener": + right_handled = right._call_handlers(event, args, kwargs) + else: + right_handled = False + + handled = left_handled or right_handled + + # Use the error handling on ``error_handler`` (should either be + # ``left`` or ``right``) + if not handled: + error_handler._emit_handle_potential_error(event, args[0] if args else None) + + return handled + + def _unwrap() -> None: + warnings.warn( + DeprecationWarning( + "Patched ee.unwrap() is deprecated and will be removed in a " + "future release. Use pyee.uplift.unwrap instead." + ) + ) + unwrap(left) + + def unwrap_hook() -> None: + left.emit = left_emit + if left_unwrap: + EMIT_WRAPPERS[left] = left_unwrap + else: + del EMIT_WRAPPERS[left] + del left.unwrap # type: ignore + left.emit = left_emit + + unwrap(right) + + left.emit = wrapped_emit + + EMIT_WRAPPERS[left] = unwrap_hook + left.unwrap = _unwrap # type: ignore + + +_PROXY_NEW_LISTENER_SETTINGS: Dict[str, Tuple[bool, bool]] = dict( + forward=(False, True), + backward=(True, False), + both=(True, True), + neither=(False, False), +) + + +ErrorStrategy = Union[Literal["new"], Literal["underlying"], Literal["neither"]] +ProxyStrategy = Union[ + Literal["forward"], Literal["backward"], Literal["both"], Literal["neither"] +] + + +def uplift( + cls: Type[UpliftingEventEmitter], + underlying: EventEmitter, + error_handling: ErrorStrategy = "new", + proxy_new_listener: ProxyStrategy = "forward", + *args: Any, + **kwargs: Any +) -> UpliftingEventEmitter: + """A helper to create instances of an event emitter ``cls`` that inherits + event behavior from an ``underlying`` event emitter instance. + + This is mostly helpful if you have a simple underlying event emitter + that you don't have direct control over, but you want to use that + event emitter in a new context - for example, you may want to ``uplift`` a + ``EventEmitter`` supplied by a third party library into an + ``AsyncIOEventEmitter`` so that you may register async event handlers + in your ``asyncio`` app but still be able to receive events from the + underlying event emitter and call the underlying event emitter's existing + handlers. + + When called, ``uplift`` instantiates a new instance of ``cls``, passing + along any unrecognized arguments, and overwrites the ``emit`` method on + the ``underlying`` event emitter to also emit events on the new event + emitter and vice versa. In both cases, they return whether the ``emit`` + method was handled by either emitter. Execution order prefers the event + emitter on which ``emit`` was called. + + The ``unwrap`` function may be called on either instance; this will + unwrap both ``emit`` methods. + + The ``error_handling`` flag can be configured to control what happens to + unhandled errors: + + - 'new': Error handling for the new event emitter is always used and the + underlying library's non-event-based error handling is inert. + - 'underlying': Error handling on the underlying event emitter is always + used and the new event emitter can not implement non-event-based error + handling. + - 'neither': Error handling for the new event emitter is used if the + handler was registered on the new event emitter, and vice versa. + + Tuning this option can be useful depending on how the underlying event + emitter does error handling. The default is 'new'. + + The ``proxy_new_listener`` option can be configured to control how + ``new_listener`` events are treated: + + - 'forward': ``new_listener`` events are propagated from the underlying + - 'both': ``new_listener`` events are propagated as with other events. + - 'neither': ``new_listener`` events are only fired on their respective + event emitters. + event emitter to the new event emitter but not vice versa. + - 'backward': ``new_listener`` events are propagated from the new event + emitter to the underlying event emitter, but not vice versa. + + Tuning this option can be useful depending on how the ``new_listener`` + event is used by the underlying event emitter, if at all. The default is + 'forward', since ``underlying`` may not know how to handle certain + handlers, such as asyncio coroutines. + + Each event emitter tracks its own internal table of handlers. + ``remove_listener``, ``remove_all_listeners`` and ``listeners`` all + work independently. This means you will have to remember which event + emitter an event handler was added to! + + Note that both the new event emitter returned by ``cls`` and the + underlying event emitter should inherit from ``EventEmitter``, or at + least implement the interface for the undocumented ``_call_handlers`` and + ``_emit_handle_potential_error`` methods. + """ + + ( + new_proxy_new_listener, + underlying_proxy_new_listener, + ) = _PROXY_NEW_LISTENER_SETTINGS[proxy_new_listener] + + new: UpliftingEventEmitter = cls(*args, **kwargs) + + uplift_error_handlers: Dict[str, Tuple[EventEmitter, EventEmitter]] = dict( + new=(new, new), underlying=(underlying, underlying), neither=(new, underlying) + ) + + new_error_handler, underlying_error_handler = uplift_error_handlers[error_handling] + + _wrap(new, underlying, new_error_handler, new_proxy_new_listener) + _wrap(underlying, new, underlying_error_handler, underlying_proxy_new_listener) + + return new diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..59293c3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,6 @@ +[tool.isort] +profile = "appnexus" +known_application = "pyee" + +[tool.pyright] +include = ["python"] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..edb4554 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +addopts = --verbose -s +testpaths = tests diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0e4bc00 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +typing-extensions==4.0.1 diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..cabc838 --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1,14 @@ +mock==4.0.2 +flake8==3.8.3 +flake8-black==0.2.3 +pytest==6.2.5 +pytest-asyncio==0.12.0; python_version >= '3.4' +pytest-trio==0.6.0; python_version >= '3.7' +trio==0.17.0; python_version > '3.6' +twisted==22.10.0 +Sphinx==3.2.1 +black==21.7b0 +isort==5.10.1 +trio-typing==0.7.0 +tox==3.20.0 +twine==3.2.0 diff --git a/requirements_docs.txt b/requirements_docs.txt new file mode 100644 index 0000000..4c17431 --- /dev/null +++ b/requirements_docs.txt @@ -0,0 +1,3 @@ +-r requirements.txt +-r requirements_dev.txt +-e . diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..8dd399a --- /dev/null +++ b/setup.cfg @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 88 +extend-ignore = E203 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..bdbe45b --- /dev/null +++ b/setup.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- + +from os import path + +from setuptools import find_packages, setup + +README_rst = path.join(path.abspath(path.dirname(__file__)), "README.rst") + +with open(README_rst, "r") as f: + long_description = f.read() + +setup( + name="pyee", + version="9.0.4", + packages=find_packages(), + include_package_data=True, + description="A port of node.js's EventEmitter to python.", + long_description=long_description, + author="Josh Holbrook", + author_email="josh.holbrook@gmail.com", + url="https://github.com/jfhbrook/pyee", + license="MIT", + keywords=["events", "emitter", "node.js", "node", "eventemitter", "event_emitter"], + install_requires=["typing-extensions"], + tests_require=["twisted", "trio"], + classifiers=[ + "Programming Language :: Python", + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Topic :: Other/Nonlisted Topic", + ], +) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..18b0633 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- + +from sys import version_info as v + +collect_ignore = [] + +if not (v[0] >= 3 and v[1] >= 5): + collect_ignore.append("test_async.py") + +if not (v[0] >= 3 and v[1] >= 7): + collect_ignore.append("test_trio.py") diff --git a/tests/test_async.py b/tests/test_async.py new file mode 100644 index 0000000..d503c51 --- /dev/null +++ b/tests/test_async.py @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- + +from asyncio import Future, wait_for + +import pytest +import pytest_asyncio.plugin # noqa + +try: + from asyncio.exceptions import TimeoutError # type: ignore +except ImportError: + from concurrent.futures import TimeoutError # type: ignore + +from mock import Mock +from twisted.internet.defer import succeed + +from pyee import AsyncIOEventEmitter, TwistedEventEmitter + + +class PyeeTestError(Exception): + pass + + +@pytest.mark.asyncio +async def test_asyncio_emit(event_loop): + """Test that AsyncIOEventEmitter can handle wrapping + coroutines + """ + + ee = AsyncIOEventEmitter(loop=event_loop) + + should_call = Future(loop=event_loop) + + @ee.on("event") + async def event_handler(): + should_call.set_result(True) + + ee.emit("event") + + result = await wait_for(should_call, 0.1) + + assert result is True + + +@pytest.mark.asyncio +async def test_asyncio_once_emit(event_loop): + """Test that AsyncIOEventEmitter also wrap coroutines when + using once + """ + + ee = AsyncIOEventEmitter(loop=event_loop) + + should_call = Future(loop=event_loop) + + @ee.once("event") + async def event_handler(): + should_call.set_result(True) + + ee.emit("event") + + result = await wait_for(should_call, 0.1) + + assert result is True + + +@pytest.mark.asyncio +async def test_asyncio_error(event_loop): + """Test that AsyncIOEventEmitter can handle errors when + wrapping coroutines + """ + ee = AsyncIOEventEmitter(loop=event_loop) + + should_call = Future(loop=event_loop) + + @ee.on("event") + async def event_handler(): + raise PyeeTestError() + + @ee.on("error") + def handle_error(exc): + should_call.set_result(exc) + + ee.emit("event") + + result = await wait_for(should_call, 0.1) + + assert isinstance(result, PyeeTestError) + + +@pytest.mark.asyncio +async def test_asyncio_cancellation(event_loop): + """Test that AsyncIOEventEmitter can handle Future cancellations""" + + cancel_me = Future(loop=event_loop) + should_not_call = Future(loop=event_loop) + + ee = AsyncIOEventEmitter(loop=event_loop) + + @ee.on("event") + async def event_handler(): + cancel_me.cancel() + + @ee.on("error") + def handle_error(exc): + should_not_call.set_result(None) + + ee.emit("event") + + try: + await wait_for(should_not_call, 0.1) + except TimeoutError: + pass + else: + raise PyeeTestError() + + +@pytest.mark.asyncio +async def test_sync_error(event_loop): + """Test that regular functions have the same error handling as coroutines""" + ee = AsyncIOEventEmitter(loop=event_loop) + + should_call = Future(loop=event_loop) + + @ee.on("event") + def sync_handler(): + raise PyeeTestError() + + @ee.on("error") + def handle_error(exc): + should_call.set_result(exc) + + ee.emit("event") + + result = await wait_for(should_call, 0.1) + + assert isinstance(result, PyeeTestError) + + +def test_twisted_emit(): + """Test that TwistedEventEmitter can handle wrapping + coroutines + """ + ee = TwistedEventEmitter() + + should_call = Mock() + + @ee.on("event") + async def event_handler(): + _ = await succeed("yes!") + should_call(True) + + ee.emit("event") + + should_call.assert_called_once() + + +def test_twisted_once(): + """Test that TwistedEventEmitter also wraps coroutines for + once + """ + ee = TwistedEventEmitter() + + should_call = Mock() + + @ee.once("event") + async def event_handler(): + _ = await succeed("yes!") + should_call(True) + + ee.emit("event") + + should_call.assert_called_once() + + +def test_twisted_error(): + """Test that TwistedEventEmitters handle Failures when wrapping coroutines.""" + ee = TwistedEventEmitter() + + should_call = Mock() + + @ee.on("event") + async def event_handler(): + raise PyeeTestError() + + @ee.on("failure") + def handle_error(e): + should_call(e) + + ee.emit("event") + + should_call.assert_called_once() diff --git a/tests/test_cls.py b/tests/test_cls.py new file mode 100644 index 0000000..d7ca3ec --- /dev/null +++ b/tests/test_cls.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +from mock import Mock +import pytest + +from pyee import EventEmitter +from pyee.cls import evented, on + + +@evented +class EventedFixture: + def __init__(self): + self.call_me = Mock() + + @on("event") + def event_handler(self, *args, **kwargs): + self.call_me(self, *args, **kwargs) + + +_custom_event_emitter = EventEmitter() + + +@evented +class CustomEmitterFixture: + def __init__(self): + self.call_me = Mock() + self.event_emitter = _custom_event_emitter + + @on("event") + def event_handler(self, *args, **kwargs): + self.call_me(self, *args, **kwargs) + + +class InheritedFixture(EventedFixture): + pass + + +@pytest.mark.parametrize( + "cls", [EventedFixture, CustomEmitterFixture, InheritedFixture] +) +def test_evented_decorator(cls): + inst = cls() + + inst.event_emitter.emit("event", "emitter is emitted!") + + inst.call_me.assert_called_once_with(inst, "emitter is emitted!") + + _custom_event_emitter.remove_all_listeners() diff --git a/tests/test_executor.py b/tests/test_executor.py new file mode 100644 index 0000000..a7fef48 --- /dev/null +++ b/tests/test_executor.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- + +from time import sleep + +from mock import Mock + +from pyee import ExecutorEventEmitter + + +class PyeeTestError(Exception): + pass + + +def test_executor_emit(): + """Test that ExecutorEventEmitters can emit events.""" + with ExecutorEventEmitter() as ee: + should_call = Mock() + + @ee.on("event") + def event_handler(): + should_call(True) + + ee.emit("event") + sleep(0.1) + + should_call.assert_called_once() + + +def test_executor_once(): + """Test that ExecutorEventEmitters also emit events for once.""" + with ExecutorEventEmitter() as ee: + should_call = Mock() + + @ee.once("event") + def event_handler(): + should_call(True) + + ee.emit("event") + sleep(0.1) + + should_call.assert_called_once() + + +def test_executor_error(): + """Test that ExecutorEventEmitters handle errors.""" + with ExecutorEventEmitter() as ee: + should_call = Mock() + + @ee.on("event") + def event_handler(): + raise PyeeTestError() + + @ee.on("error") + def handle_error(e): + should_call(e) + + ee.emit("event") + + sleep(0.1) + + should_call.assert_called_once() diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 0000000..a09bf00 --- /dev/null +++ b/tests/test_sync.py @@ -0,0 +1,280 @@ +# -*- coding: utf-8 -*- +from collections import OrderedDict + +from mock import Mock +from pytest import raises + +from pyee import EventEmitter + + +class PyeeTestException(Exception): + pass + + +def test_emit_sync(): + """Basic synchronous emission works""" + + call_me = Mock() + ee = EventEmitter() + + @ee.on("event") + def event_handler(data, **kwargs): + call_me() + assert data == "emitter is emitted!" + + assert ee.event_names() == {"event"} + + # Making sure data is passed propers + ee.emit("event", "emitter is emitted!", error=False) + + call_me.assert_called_once() + + +def test_emit_error(): + """Errors raise with no event handler, otherwise emit on handler""" + + call_me = Mock() + ee = EventEmitter() + + test_exception = PyeeTestException("lololol") + + with raises(PyeeTestException): + ee.emit("error", test_exception) + + @ee.on("error") + def on_error(exc): + call_me() + + assert ee.event_names() == {"error"} + + # No longer raises and error instead return True indicating handled + assert ee.emit("error", test_exception) is True + call_me.assert_called_once() + + +def test_emit_return(): + """Emit returns True when handlers are registered on an event, and false + otherwise. + """ + + call_me = Mock() + ee = EventEmitter() + + assert ee.event_names() == set() + + # make sure emitting without a callback returns False + assert not ee.emit("data") + + # add a callback + ee.on("data")(call_me) + + # should return True now + assert ee.emit("data") + + +def test_new_listener_event(): + """The 'new_listener' event fires whenever a new listener is added.""" + + call_me = Mock() + ee = EventEmitter() + + ee.on("new_listener", call_me) + + # Should fire new_listener event + @ee.on("event") + def event_handler(data): + pass + + assert ee.event_names() == {"new_listener", "event"} + + call_me.assert_called_once_with("event", event_handler) + + +def test_listener_removal(): + """Removing listeners removes the correct listener from an event.""" + + ee = EventEmitter() + + # Some functions to pass to the EE + def first(): + return 1 + + ee.on("event", first) + + @ee.on("event") + def second(): + return 2 + + @ee.on("event") + def third(): + return 3 + + def fourth(): + return 4 + + ee.on("event", fourth) + + assert ee.event_names() == {"event"} + + assert ee._events["event"] == OrderedDict( + [(first, first), (second, second), (third, third), (fourth, fourth)] + ) + + ee.remove_listener("event", second) + + assert ee._events["event"] == OrderedDict( + [(first, first), (third, third), (fourth, fourth)] + ) + + ee.remove_listener("event", first) + assert ee._events["event"] == OrderedDict([(third, third), (fourth, fourth)]) + + ee.remove_all_listeners("event") + assert "event" not in ee._events["event"] + + +def test_listener_removal_on_emit(): + """Test that a listener removed during an emit is called inside the current + emit cycle. + """ + + call_me = Mock() + ee = EventEmitter() + + def should_remove(): + ee.remove_listener("remove", call_me) + + ee.on("remove", should_remove) + ee.on("remove", call_me) + + assert ee.event_names() == {"remove"} + + ee.emit("remove") + + call_me.assert_called_once() + + call_me.reset_mock() + + # Also test with the listeners added in the opposite order + ee = EventEmitter() + ee.on("remove", call_me) + ee.on("remove", should_remove) + + assert ee.event_names() == {"remove"} + + ee.emit("remove") + + call_me.assert_called_once() + + +def test_once(): + """Test that `once()` method works propers.""" + + # very similar to "test_emit" but also makes sure that the event + # gets removed afterwards + + call_me = Mock() + ee = EventEmitter() + + def once_handler(data): + assert data == "emitter is emitted!" + call_me() + + # Tests to make sure that after event is emitted that it's gone. + ee.once("event", once_handler) + + assert ee.event_names() == {"event"} + + ee.emit("event", "emitter is emitted!") + + call_me.assert_called_once() + + assert ee.event_names() == set() + + assert "event" not in ee._events + + +def test_once_removal(): + """Removal of once functions works""" + + ee = EventEmitter() + + def once_handler(data): + pass + + handle = ee.once("event", once_handler) + + assert handle == once_handler + assert ee.event_names() == {"event"} + + ee.remove_listener("event", handle) + + assert "event" not in ee._events + assert ee.event_names() == set() + + +def test_listeners(): + """`listeners()` returns a copied list of listeners.""" + + call_me = Mock() + ee = EventEmitter() + + @ee.on("event") + def event_handler(): + pass + + @ee.once("event") + def once_handler(): + pass + + listeners = ee.listeners("event") + + assert listeners[0] == event_handler + assert listeners[1] == once_handler + + # listeners is a copy, you can't mutate the innards this way + listeners[0] = call_me + + ee.emit("event") + + call_me.assert_not_called() + + +def test_listeners_does_work_with_unknown_listeners(): + """`listeners()` should not throw.""" + ee = EventEmitter() + listeners = ee.listeners("event") + assert listeners == [] + + +def test_properties_preserved(): + """Test that the properties of decorated functions are preserved.""" + + call_me = Mock() + call_me_also = Mock() + ee = EventEmitter() + + @ee.on("always") + def always_event_handler(): + """An event handler.""" + call_me() + + @ee.once("once") + def once_event_handler(): + """Another event handler.""" + call_me_also() + + assert always_event_handler.__doc__ == "An event handler." + assert once_event_handler.__doc__ == "Another event handler." + + always_event_handler() + call_me.assert_called_once() + + once_event_handler() + call_me_also.assert_called_once() + + call_me_also.reset_mock() + + # Calling the event handler directly doesn't clear the handler + ee.emit("once") + call_me_also.assert_called_once() diff --git a/tests/test_trio.py b/tests/test_trio.py new file mode 100644 index 0000000..3877849 --- /dev/null +++ b/tests/test_trio.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- + +import pytest +import pytest_trio.plugin # noqa +import trio + +from pyee import TrioEventEmitter + + +class PyeeTestError(Exception): + pass + + +@pytest.mark.trio +async def test_trio_emit(): + """Test that the trio event emitter can handle wrapping + coroutines + """ + + async with TrioEventEmitter() as ee: + + should_call = trio.Event() + + @ee.on("event") + async def event_handler(): + should_call.set() + + ee.emit("event") + + result = False + with trio.move_on_after(0.1): + await should_call.wait() + result = True + + assert result + + +@pytest.mark.trio +async def test_trio_once_emit(): + """Test that trio event emitters also wrap coroutines when + using once + """ + + async with TrioEventEmitter() as ee: + should_call = trio.Event() + + @ee.once("event") + async def event_handler(): + should_call.set() + + ee.emit("event") + + result = False + with trio.move_on_after(0.1): + await should_call.wait() + result = True + + assert result + + +@pytest.mark.trio +async def test_trio_error(): + """Test that trio event emitters can handle errors when + wrapping coroutines + """ + + async with TrioEventEmitter() as ee: + send, rcv = trio.open_memory_channel(1) + + @ee.on("event") + async def event_handler(): + raise PyeeTestError() + + @ee.on("error") + async def handle_error(exc): + async with send: + await send.send(exc) + + ee.emit("event") + + result = None + with trio.move_on_after(0.1): + async with rcv: + result = await rcv.__anext__() + + assert isinstance(result, PyeeTestError) + + +@pytest.mark.trio +async def test_sync_error(event_loop): + """Test that regular functions have the same error handling as coroutines""" + + async with TrioEventEmitter() as ee: + send, rcv = trio.open_memory_channel(1) + + @ee.on("event") + def sync_handler(): + raise PyeeTestError() + + @ee.on("error") + async def handle_error(exc): + async with send: + await send.send(exc) + + ee.emit("event") + + result = None + with trio.move_on_after(0.1): + async with rcv: + result = await rcv.__anext__() + + assert isinstance(result, PyeeTestError) diff --git a/tests/test_twisted.py b/tests/test_twisted.py new file mode 100644 index 0000000..6a667ed --- /dev/null +++ b/tests/test_twisted.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- + +from mock import Mock +from twisted.internet.defer import inlineCallbacks +from twisted.python.failure import Failure + +from pyee import TwistedEventEmitter + + +class PyeeTestError(Exception): + pass + + +def test_propagates_failure(): + """Test that TwistedEventEmitters can propagate failures + from twisted Deferreds + """ + ee = TwistedEventEmitter() + + should_call = Mock() + + @ee.on("event") + @inlineCallbacks + def event_handler(): + yield Failure(PyeeTestError()) + + @ee.on("failure") + def handle_failure(f): + assert isinstance(f, Failure) + should_call(f) + + ee.emit("event") + + should_call.assert_called_once() + + +def test_propagates_sync_failure(): + """Test that TwistedEventEmitters can propagate failures + from twisted Deferreds + """ + ee = TwistedEventEmitter() + + should_call = Mock() + + @ee.on("event") + def event_handler(): + raise PyeeTestError() + + @ee.on("failure") + def handle_failure(f): + assert isinstance(f, Failure) + should_call(f) + + ee.emit("event") + + should_call.assert_called_once() + + +def test_propagates_exception(): + """Test that TwistedEventEmitters propagate failures as exceptions to + the error event when no failure handler + """ + + ee = TwistedEventEmitter() + + should_call = Mock() + + @ee.on("event") + @inlineCallbacks + def event_handler(): + yield Failure(PyeeTestError()) + + @ee.on("error") + def handle_error(exc): + assert isinstance(exc, Exception) + should_call(exc) + + ee.emit("event") + + should_call.assert_called_once() diff --git a/tests/test_uplift.py b/tests/test_uplift.py new file mode 100644 index 0000000..69350e0 --- /dev/null +++ b/tests/test_uplift.py @@ -0,0 +1,201 @@ +# -*- coding: utf-8 -*- + +from mock import call, Mock +import pytest + +from pyee import EventEmitter +from pyee.uplift import unwrap, uplift + + +class UpliftedEventEmitter(EventEmitter): + pass + + +def test_uplift_emit(): + call_me = Mock() + + base_ee = EventEmitter() + + @base_ee.on("base_event") + def base_handler(): + call_me("base event on base emitter") + + @base_ee.on("shared_event") + def shared_base_handler(): + call_me("shared event on base emitter") + + uplifted_ee = uplift(UpliftedEventEmitter, base_ee) + + assert isinstance(uplifted_ee, UpliftedEventEmitter), "Returns an uplifted emitter" + + @uplifted_ee.on("uplifted_event") + def uplifted_handler(): + call_me("uplifted event on uplifted emitter") + + @uplifted_ee.on("shared_event") + def shared_uplifted_handler(): + call_me("shared event on uplifted emitter") + + # Events on uplifted proxy correctly + assert uplifted_ee.emit("base_event") + assert uplifted_ee.emit("shared_event") + assert uplifted_ee.emit("uplifted_event") + + call_me.assert_has_calls( + [ + call("base event on base emitter"), + call("shared event on uplifted emitter"), + call("shared event on base emitter"), + call("uplifted event on uplifted emitter"), + ] + ) + + call_me.reset_mock() + + # Events on underlying proxy correctly + assert base_ee.emit("base_event") + assert base_ee.emit("shared_event") + assert base_ee.emit("uplifted_event") + + call_me.assert_has_calls( + [ + call("base event on base emitter"), + call("shared event on base emitter"), + call("shared event on uplifted emitter"), + call("uplifted event on uplifted emitter"), + ] + ) + + call_me.reset_mock() + + # Quick check for unwrap + unwrap(uplifted_ee) + + with pytest.raises(AttributeError): + getattr(uplifted_ee, "unwrap") + + with pytest.raises(AttributeError): + getattr(base_ee, "unwrap") + + assert not uplifted_ee.emit("base_event") + assert uplifted_ee.emit("shared_event") + assert uplifted_ee.emit("uplifted_event") + + assert base_ee.emit("base_event") + assert base_ee.emit("shared_event") + assert not base_ee.emit("uplifted_event") + + call_me.assert_has_calls( + [ + # No listener for base event on uplifted + call("shared event on uplifted emitter"), + call("uplifted event on uplifted emitter"), + call("base event on base emitter"), + call("shared event on base emitter") + # No listener for uplifted event on uplifted + ] + ) + + +@pytest.mark.parametrize("error_handling", ["new", "underlying", "neither"]) +def test_exception_handling(error_handling): + base_ee = EventEmitter() + uplifted_ee = uplift(UpliftedEventEmitter, base_ee, error_handling=error_handling) + + # Exception handling always prefers uplifted + base_error = Exception("base error") + uplifted_error = Exception("uplifted error") + + # Hold my beer + base_error_handler = Mock() + base_ee._emit_handle_potential_error = base_error_handler + + # Hold my other beer + uplifted_error_handler = Mock() + uplifted_ee._emit_handle_potential_error = uplifted_error_handler + + base_ee.emit("error", base_error) + uplifted_ee.emit("error", uplifted_error) + + if error_handling == "new": + base_error_handler.assert_not_called() + uplifted_error_handler.assert_has_calls( + [call("error", base_error), call("error", uplifted_error)] + ) + elif error_handling == "underlying": + base_error_handler.assert_has_calls( + [call("error", base_error), call("error", uplifted_error)] + ) + uplifted_error_handler.assert_not_called() + elif error_handling == "neither": + base_error_handler.assert_called_once_with("error", base_error) + uplifted_error_handler.assert_called_once_with("error", uplifted_error) + else: + raise Exception("unrecognized setting") + + +@pytest.mark.parametrize( + "proxy_new_listener", ["both", "neither", "forward", "backward"] +) +def test_proxy_new_listener(proxy_new_listener): + call_me = Mock() + + base_ee = EventEmitter() + + uplifted_ee = uplift( + UpliftedEventEmitter, base_ee, proxy_new_listener=proxy_new_listener + ) + + @base_ee.on("new_listener") + def base_new_listener_handler(event, f): + assert event in ("event", "new_listener") + call_me("base new listener handler", f) + + @uplifted_ee.on("new_listener") + def uplifted_new_listener_handler(event, f): + assert event in ("event", "new_listener") + call_me("uplifted new listener handler", f) + + def fresh_base_handler(): + pass + + def fresh_uplifted_handler(): + pass + + base_ee.on("event", fresh_base_handler) + uplifted_ee.on("event", fresh_uplifted_handler) + + if proxy_new_listener == "both": + call_me.assert_has_calls( + [ + call("base new listener handler", fresh_base_handler), + call("uplifted new listener handler", fresh_base_handler), + call("uplifted new listener handler", fresh_uplifted_handler), + call("base new listener handler", fresh_uplifted_handler), + ] + ) + elif proxy_new_listener == "neither": + call_me.assert_has_calls( + [ + call("base new listener handler", fresh_base_handler), + call("uplifted new listener handler", fresh_uplifted_handler), + ] + ) + elif proxy_new_listener == "forward": + call_me.assert_has_calls( + [ + call("base new listener handler", fresh_base_handler), + call("uplifted new listener handler", fresh_base_handler), + call("uplifted new listener handler", fresh_uplifted_handler), + ] + ) + elif proxy_new_listener == "backward": + call_me.assert_has_calls( + [ + call("base new listener handler", fresh_base_handler), + call("uplifted new listener handler", fresh_uplifted_handler), + call("base new listener handler", fresh_uplifted_handler), + ] + ) + else: + raise Exception("unrecognized proxy_new_listener") diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..86f7fef --- /dev/null +++ b/tox.ini @@ -0,0 +1,9 @@ +[tox] +envlist = py38,py39,py310 + +[testenv] +deps = + -rrequirements_test.txt +commands = + flake8 + pytest ./tests diff --git a/typings/twisted/python/failure.pyi b/typings/twisted/python/failure.pyi new file mode 100644 index 0000000..dabec96 --- /dev/null +++ b/typings/twisted/python/failure.pyi @@ -0,0 +1,5 @@ +class Failure(BaseException): + value: Exception + + def raiseException() -> None: + ... -- cgit v1.2.3