pax_global_header00006660000000000000000000000064147660674300014527gustar00rootroot0000000000000052 comment=d0aa115d726bbd0a6d689ce828a2f0884183d43d pyee-13.0.0/000077500000000000000000000000001476606743000125525ustar00rootroot00000000000000pyee-13.0.0/.github/000077500000000000000000000000001476606743000141125ustar00rootroot00000000000000pyee-13.0.0/.github/workflows/000077500000000000000000000000001476606743000161475ustar00rootroot00000000000000pyee-13.0.0/.github/workflows/qa.yaml000066400000000000000000000034051476606743000174360ustar00rootroot00000000000000name: QA on: push: branches: [ "main" ] pull_request: branches: [ "main" ] jobs: python-versions: runs-on: ubuntu-latest outputs: versions: ${{ steps.python-versions.outputs.versions }} steps: - uses: actions/checkout@v4 - name: Get Python versions id: python-versions run: | CLASSIFIERS="$(yq -p toml -o toml '.project.classifiers[]' pyproject.toml | grep "Programming Language :: Python :: " | grep "\.")" VERSIONS="$(echo "${CLASSIFIERS}" | sed -e 's/Programming Language :: Python :: \([0-9.]*\)$/"\1"/g' | tr '\n' ',' | sed -e 's/,$//g')" echo "versions=[${VERSIONS}]" > "${GITHUB_OUTPUT}" qa: name: Run QA checks runs-on: ubuntu-latest needs: python-versions strategy: matrix: python-version: ${{fromJson(needs.python-versions.outputs.versions)}} steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Set up Node.js LTS uses: actions/setup-node@v4 with: node-version: "lts/*" - name: Install the world run: | python -m pip install --upgrade pip setuptools wheel pip install -e .[dev] - name: Run linting run: | flake8 ./pyee ./tests validate-pyproject ./pyproject.toml - name: Run pyright run: npx pyright@latest - name: Run mypy run: mypy . - name: Run tests run: pytest ./tests actionlint: name: Run actionlint runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Run actionlint uses: raven-actions/actionlint@v2 pyee-13.0.0/.github/workflows/release.yaml000066400000000000000000000071001476606743000204510ustar00rootroot00000000000000name: Release on: push: tags: - 'v*' jobs: versions: runs-on: ubuntu-latest outputs: python-version: ${{ steps.python-version.outputs.python_version }} release-version: ${{ steps.release-version.outputs.release_version }} steps: - uses: actions/checkout@v4 - name: Get latest supported Python version id: python-version run: | CLASSIFIERS="$(yq -p toml -o toml '.project.classifiers[]' pyproject.toml | grep "Programming Language :: Python :: " | grep "\.")" VERSION="$(echo "${CLASSIFIERS}" | sed -e 's/Programming Language :: Python :: \([0-9.]*\)$/\1/g' | tail -n 1)" echo "python_version=${VERSION}" > "${GITHUB_OUTPUT}" # See: https://stackoverflow.com/questions/58177786/get-the-current-pushed-tag-in-github-actions - name: Get release version id: release-version run: | VERSION="$(yq -p toml -o toml -r '.project.version' pyproject.toml)" echo "release_version=${VERSION}" >> "${GITHUB_OUTPUT}" build: runs-on: ubuntu-latest needs: - versions steps: - uses: actions/checkout@v4 - name: Set up Python ${{ needs.versions.outputs.python-version }} uses: actions/setup-python@v5 with: python-version: "${{ needs.versions.outputs.python-version }}" - name: Install the world run: | python -m pip install --upgrade pip setuptools wheel pip install -e .[dev] - name: Build Package Distributions run: python -m build - name: Store Package Distributions uses: actions/upload-artifact@v4 with: name: package-distributions path: dist/ - name: Build Man Page run: sphinx-build -M man docs _build - name: Store Man Page uses: actions/upload-artifact@v4 with: name: man-page path: _build/man - name: Build Release Notes # thanks to https://gist.github.com/Integralist/57accaf446cf3e7974cd01d57158532c run: mkdir notes && awk '/^##/ {block++} {if (block == 1) { print }}' CHANGELOG.md > notes/RELEASE.md - name: Store Release Notes uses: actions/upload-artifact@v4 with: name: release-notes path: notes pypi-release: runs-on: ubuntu-latest needs: - build environment: name: pypi url: https://pypi.org/p/pypi permissions: id-token: write steps: - name: Download distributions uses: actions/download-artifact@v4.1.7 with: name: package-distributions path: dist/ - name: Publish release to PyPI uses: pypa/gh-action-pypi-publish@release/v1 github-release: runs-on: ubuntu-latest needs: - versions - build steps: - uses: actions/checkout@v4 - name: Download distributions uses: actions/download-artifact@v4.1.7 with: name: package-distributions path: dist/ - name: Download man page uses: actions/download-artifact@v4.1.7 with: name: man-page path: man - name: Download release notes uses: actions/download-artifact@v4.1.7 with: name: release-notes path: notes - name: Create a GitHub release env: GITHUB_TOKEN: ${{ github.TOKEN }} shell: bash run: | gh release create 'v${{ needs.versions.outputs.release-version }}' --title 'Release v${{ needs.versions.outputs.release-version }}' --notes "$(cat notes/RELEASE.md)" dist/* man/pyee.1 pyee-13.0.0/.gitignore000066400000000000000000000002361476606743000145430ustar00rootroot00000000000000*.pyc docs/_build dist/* build/* MANIFEST README .cache .eggs .python-version pyee.egg-info/ version.txt scratchpad.ipynb .tox/ node_modules venv site _build pyee-13.0.0/.readthedocs.yaml000066400000000000000000000003501476606743000157770ustar00rootroot00000000000000version: 2 build: os: ubuntu-lts-latest tools: python: "3.12" nodejs: "20" mkdocs: configuration: mkdocs.yml fail_on_warning: false python: install: - requirements: requirements_dev.txt - path: ".[dev]" pyee-13.0.0/CHANGELOG.md000066400000000000000000000240271476606743000143700ustar00rootroot00000000000000# Changelog ## 2025/03/17 Version 13.0.0 - Type checking improvements - Introduce overloads for `ee.on` - Add `None` return type for functions as appropriate - Type `self` as `Any` in all methods - Local and CI tasks for type checking with `mypy` - `mypy` type checking passes - `pyright` type checking passes - Addition of `mypy` to development dependencies - Removed conditional import of `iscoroutine` - This was implemented to support Python 3.3, which was dropped long ago - Removed type stub for `twisted.python.Failure` - This was to address a typing issue in unsupported versions of Twisted - Export `Handler` type in `pyee/__init__.py` ## 2024/11/16 Version 12.1.1 - Fixed ReadTheDocs build - `build.os` is [now a required parameter](https://blog.readthedocs.com/use-build-os-config/) - `python.version` is replaced by `build.tools` ## 2024/11/16 Version 12.1.0 - New features in `pyee.asyncio.AsyncIOEventEmitter`: - `wait_for_complete` method to wait for all running handlers to complete execution - `cancel` method to cancel execution of all running handlers - `complete` property that's `True` when no handlers are currently running - Updated changelog for v12 release to describe where to find alternatives to deprecated and removed imports - Add support for Python 3.13 - Upgrade GitHub Actions - Upgrade `actions/setup-python` to v5 - Upgrade `actions/setup-node` to v4 - Upgrade `actions/upload-artifact` to v4 - Updated `CONTRIBUTORS.md` to include missing contributors ## 2024/08/30 Version 12.0.0 - Remove deprecated imports: - `pyee.BaseEventEmitter` - Use `pyee.base.EventEmitter` or `pyee.EventEmitter` instead - `pyee.AsyncIOEventEmitter` - Use `pyee.asyncio.AsyncIOEventEmitter` instead - `pyee.TwistedEventEmitter` - Use `pyee.twisted.TwistedEventEmitter` instead - `pyee.ExecutorEventEmitter` - Use `pyee.executor.ExecutorEventEmitter` instead - `pyee.TrioEventEmitter` - Use `pyee.trio.TrioEventEmitter` instead - Add `PyeeError` which inherits from `PyeeException`, and use throughout - Deprecate direct use of `PyeeException` - Use `PyeeError` instead ## 2024/08/30 Version 11.1.1 - Add project URLs to pyproject.toml and PyPI - Use ActionLint v2 - Fix GitHub release action ## 2023/11/23 Version 11.1.0 - Generate a man page with Sphinx (in addition to mkdocs HTML) - Use GitHub Actions to cut releases ## 2023/10/14 Version 11.0.1 - Bump development dependencies, thanks to dependabot - Support Python 3.12 - Use node.js LTS in GitHub Actions - Read support Python versions from `pyproject.toml` in GitHub Actions ## 2023/06/19 Version 11.0.0 - Require Python >= 3.8 ## 2023/06/19 Version 10.0.2 - Remove mention of Python 3.5 from documentation - Update classifiers in `pyproject.toml` - Add just task to tag releases in git - Update RTD badge in README.md - Copy fixes to changelog ## 2023/06/08 Version 10.0.1 - Remove package.json/package-lock.json - Use Python 3.8 on Read the Docs - Various bugfixes for `.readthedocs.yml` ## 2023/06/08 Version 10.0.0 Development Environment Updates: - Switch from `make` to `just` - Switch from vanilla `pip` to `pip-tools` - `environment.yml` updated - `environment.yml` not currently supported Packaging Updates: - Switch from `setup.py` to `pyproject.toml` (still using setuptools) Documentation updates: - Switch documentation generator from Sphinx to mkdocs (including on ReadTheDocs) - Use Python 3.10 on ReadTheDocs - Change documentation them to `readthedocs` (old theme not available in mkdocs) - Switch documentation format from RST to markdown (Including in docstrings) Testing and Linting Updates: - use `npx` to run pyright - `pyproject.toml` linting with `validate-pyproject` API Updates: - Minor type annotation bugfixes ## 2023/06/08 Version 9.1.1 - Store AsyncIO Futures in a set ## 2023/04/30 Version 9.1.0 - `EventEmitter` supports pickling - Development dependencies updated to latest - Dependency on mock removed in favor of unittest.mock - Additional type hints so pyright check passes on latest - Drop 3.7 support ## 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 pyee-13.0.0/CONTRIBUTORS.md000066400000000000000000000015161476606743000150340ustar00rootroot00000000000000General 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 - Masaya Suzuki @massongit - Xiao Shuai @xiaoshuai - Emil Loer - Huan Do - Niklas Fiekas - Olivia Appleton - Ryan Matthews - Tim Gates - @asellappen - @ddelange - Yuichiro Tachibana @whitphx pyee-13.0.0/DEVELOPMENT.md000066400000000000000000000145621476606743000146660ustar00rootroot00000000000000# Development And Publishing ## Prerequisites - Python 3.10+ - either system python3 or conda - npm - namely `npx` - [just](https://github.com/casey/just) ## Environment Setup To set everything up, run: ```bash just install ``` This will create a virtualenv at `./venv` and install dependencies with pip and pip-tools. ## Just Tasks To list all Just tasks, run `just --list`: ``` Available recipes: build # Build the package build-docs # Build the documentation check # Check type annotations with pyright clean # Clean up loose files compile # Generate locked requirements files based on dependencies in pyproject.toml console # Open a Jupyter console default # By default, run checks and tests, then format and lint docs # Live generate docs and host on a development webserver format # Format with black and isort install # Install all dependencies lint # Lint with flake8 man # Generate man page and open for preview mkdocs # Run mkdocs publish # Build the package and publish it to PyPI shell # Open a bash shell with the venv activated sphinx TARGET # Run sphinx tag # Tag the release in git test # Run tests with pytest tox # Run tests using tox update # Update all dependencies upgrade # Update all dependencies and rebuild the environment upload # Upload built packages ``` ### Updating Modules To update the modules being used, run: ```bash just update ``` Alternately, run everything with the just tasks, which source the activate script before running commands. You can get a full list with `just --list`. ### conda The author doesn't currently use Conda on any machines, and is unsupported. Howeer, an `environment.yml` *is* provided, and is intended to manage a Conda environment named `pyee`. These notes are a guide to how Conda *could* be used, and for anybody who wants to add Conda support to the `justfile`. In general, `conda env create`, `conda activate pyee` and `conda env update` should work as far as Conda is concerned. However, you'll need to use lower level commands to update the requirements files with pip-tools. Instead of running `just install`, run: ```bash just compile # generate requirements files conda env install # or conda env update ``` Instead of running `just update`, run: ```bash just clean # removes existing requirements files just compile conda env update ``` Note that the other just tasks assume an activate script at `./venv/bin/activate`. For now, you could probably stub it so that it calls `conda activate pyee`. Supporting it would probably look like generating that script in the venv setup task. Finally, be cautioned that, in the past, dependencies with compiled components were installed with Conda, such that pip left them alone after finding they were the same version. The reason for this is that source builds will have issues linking to non-Conda libraries. Now that it's a few years later, however, those modules may install binary wheels and be just fine. Either way, there's a decent shot a module with compiled components will have issues and need to be added to `environment.yml`. In the past, I manually kept the version matching `requirements_dev.txt`, but now that this is being generated by pip-tools - with locks for transient dependencies - this sort of bookkeeping is a lot harder. One option is to write a script that will naively grep versions from the requirements file, though keep in mind that pip versions aren't fully compatible with Conda versions. Another is to find the conda package for the binary dependency, and install that so the linter works. A final option is to punch Conda's linker - I've done this in the past, but it's pretty fragile and hopefully unnecessary. ## Interactive Environments To activate the venv in a subshell: ```bash just shell ``` To fire up an interactive console: ```bash just console ``` Note that I haven't figured out how to auto-exec code on load - you'll need to import everything. The easiest thing may be to use full fat Jupyter, potentially with [jpterm](https://github.com/davidbrochart/jpterm). ## Development Loop There are four main just tasks: - `just format` - format with black and isort - `just check` - check type annotations with pyright - `just test` - run tests with pytest - `just lint` - lint with flake8 and validate-pyproject To run all four in a row, simply run `just`. Then, run individual checks until you're happy with it, and run `just` again to make sure all is well. ### Cross-Version Tests with tox I usually lean on github actions for checking the test matrix, but `just tox` should run them with tox. An avenue to explore may be acting smarter about installing versions of python listed in the test matrix. ## Generating Docs Docs for published projects are automatically generated by readthedocs, but you can also preview them locally by running: ```bash just docs ``` This will use `mkdoc`'s dev server - click the link, good to go. It even supports hot reload! ## Publishing ### Do a Final Check Run `just` to do final checks. ### Update the Changelog Update the CHANGELOG.md file to detail the changes being rolled into the new version. ### Update the Version in pyproject.toml I do my best to follow [semver](https://semver.org) when updating versions. ### Add a Git Tag I try to use git tags to tag versions - there's a just task: ```bash just tag ``` ### Push the Tag to GitHub ```bash git push origin main --tags ``` ### Check on GitHub Actions This should trigger a GitHub Action which publishes to PyPI and creates a GitHub Release. Go to GitHub and make sure it worked. ### Check on RTD RTD should build automatically but I find there's a delay so I like to kick it off manually. Log into [RTD](https://readthedocs.org), log in, then go to [the pyee project page](https://readthedocs.org/projects/pyee/) and build latest and stable. ### (Optional) Announce on Twitter It's not official, but I like to announce the release on Twitter. ### (Optional) Build and Publish Manually If you want to publish the package manually, run: ```bash just publish ``` This should automatically build the package and upload with twine. However, you can also build the package manually with `just build`, or upload the existing build with `just upload`. pyee-13.0.0/LICENSE000066400000000000000000000020701476606743000135560ustar00rootroot00000000000000The 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. pyee-13.0.0/MANIFEST.in000066400000000000000000000002611476606743000143070ustar00rootroot00000000000000include LICENSE include README.rst include CHANGELOG.rst include CONTRIBUTORS.rst include DEVELOPMENT.rst include version.txt include pyee/py.typed recursive-include tests *.py pyee-13.0.0/README.md000066400000000000000000000013221476606743000140270ustar00rootroot00000000000000# pyee [![Documentation Status](https://readthedocs.org/projects/pyee/badge/?version=latest)](https://pyee.readthedocs.io/en/latest/?badge=latest) 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. ## Docs Autogenerated API docs, including basic installation directions and examples, can be found at . ## Development See [DEVELOPMENT.md](./DEVELOPMENT.md). ## Changelog See [CHANGELOG.md](./CHANGELOG.md). ## Contributors See [CONTRIBUTORS.md](./CONTRIBUTORS.md). ## License MIT/X11, see [LICENSE](./LICENSE). pyee-13.0.0/docs/000077500000000000000000000000001476606743000135025ustar00rootroot00000000000000pyee-13.0.0/docs/api.md000066400000000000000000000006021476606743000145730ustar00rootroot00000000000000# API Docs ## pyee ### ::: pyee handler: python options: members: - EventEmitter - PyeeException show_root_heading: false ## pyee.asyncio ### ::: pyee.asyncio ## pyee.twisted ### ::: pyee.twisted ## pyee.executor ### ::: pyee.executor ## pyee.trio ### ::: pyee.trio ## pyee.uplift ### ::: pyee.uplift ## pyee.cls ### ::: pyee.cls pyee-13.0.0/docs/changelog.md000066400000000000000000000000511476606743000157470ustar00rootroot00000000000000{% include-markdown '../CHANGELOG.md' %} pyee-13.0.0/docs/conf.py000066400000000000000000000012011476606743000147730ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # pyee uses mkdocs for its primary documentation. However, it uses sphinx to # generate a man page. # For the full list of built-in configuration values, see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html import toml with open("../pyproject.toml", "r") as f: pyproject_toml = toml.load(f) project = "pyee" copyright = "2023, Josh Holbrook" author = "Josh Holbrook" release = f'v{pyproject_toml["project"]["version"]}' templates_path = ["_templates"] exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "index.md"] root_doc = "man" pyee-13.0.0/docs/index.md000066400000000000000000000010361476606743000151330ustar00rootroot00000000000000# Getting Started pyee is a rough port of [node.js's EventEmitter](https://nodejs.org/api/events.html). Unlike its namesake, it includes a number of subclasses useful for implementing async and threaded programming in python, such as async/await. ## Install You can install this project into your environment of choice using `pip`: pip install pyee ## Some Links * [API Docs](./api.md) * [Changelog](./changelog.md) * [Fork Me On GitHub](https://github.com/jfhbrook/pyee) * [These Very Docs on readthedocs.io](https://pyee.rtfd.io) pyee-13.0.0/docs/man.rst000066400000000000000000000033071476606743000150120ustar00rootroot00000000000000pyee ==== 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 Usage ----- pyee supplies a ``EventEmitter`` class that is similar to the ``EventEmitter`` class from Node.js. In addition, it supplies subclasses for ``asyncio``, ``twisted``, ``concurrent.futures`` and ``trio``, 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]: API --- pyee contains a number of modules, each intended for a different concurrency paradigm or framework: - ``pyee`` - synchronous ``EventEmitter``, like Node.js - ``pyee.asyncio`` - asyncio support - ``pyee.twisted`` - twisted support - ``pyee.executor`` - concurrent.futures support - ``pyee.trio`` - trio support In addition, it contains two experimental modules: - ``pyee.uplift`` - support for "uplifting" event emitters from one paradigm to another - ie., adopting synchronous event emitters for use with asyncio - ``pyee.cls`` - support for "evented classes", which call class methods on events For in-depth API documentation, visit `the docs on readthedocs.io `_. Links ----- * `Fork Me On GitHub! `_ * `The Docs on readthedocs.io `_ pyee-13.0.0/environment.yml000066400000000000000000000002441476606743000156410ustar00rootroot00000000000000name: pyee channels: - conda-forge - default dependencies: - python=3.10.11 - pip - pip-tools - wheel - pip: - -r requirements_dev.txt - -e . pyee-13.0.0/justfile000066400000000000000000000073341476606743000143310ustar00rootroot00000000000000set dotenv-load := true sphinx-sphinxbuild := "sphinx-build" sphinx-sphinxopts := "" sphinx-sourcedir := "docs" sphinx-builddir := "_build" # By default, run checks and tests, then format and lint default: if [ ! -d venv ]; then just install; fi @just format @just check @just test @just lint # # Installing, updating and upgrading dependencies # _venv: if [ ! -d venv ]; then python3 -m venv venv; . ./venv/bin/activate && pip install pip pip-tools wheel; fi _clean-venv: rm -rf venv # Install all dependencies install: @just _venv @just compile . ./venv/bin/activate && pip install -r requirements_dev.txt . ./venv/bin/activate && pip install -e . # Update all dependencies update: @just _venv . ./venv/bin/activate && pip install pip pip-tools wheel --upgrade @just _clean-compile @just install # Update all dependencies and rebuild the environment upgrade: if [ -d venv ]; then just update && just check && just _upgrade; else just update; fi _upgrade: @just _clean-venv @just _venv @just _clean-compile @just compile @just install # Generate locked requirements files based on dependencies in pyproject.toml compile: . ./venv/bin/activate && python -m piptools compile --resolver=backtracking -o requirements.txt pyproject.toml --verbose . ./venv/bin/activate && python -m piptools compile --resolver=backtracking --extra=dev -o requirements_dev.txt pyproject.toml --verbose _clean-compile: rm -f requirements.txt rm -f requirements_dev.txt # # Development tooling - linting, formatting, etc # # Format with black and isort format: . ./venv/bin/activate && black ./docs './pyee' ./tests . ./venv/bin/activate && isort --settings-file . ./docs './pyee' ./tests # Lint with flake8 lint: . ./venv/bin/activate && flake8 ./docs './pyee' ./tests . ./venv/bin/activate && validate-pyproject ./pyproject.toml # Check type annotations with pyright check: . ./venv/bin/activate && npx pyright@latest # Check type annotations with mypy mypy: . ./venv/bin/activate && mypy . # Run tests with pytest test: . ./venv/bin/activate && pytest ./tests @just _clean-test _clean-test: rm -f pytest_runner-*.egg rm -rf tests/__pycache__ # Run tests using tox tox: . ./venv/bin/activate && tox @just _clean-tox _clean-tox: rm -rf .tox # # Shell and console # # Open a bash shell with the venv activated shell: . ./venv/bin/activate && bash # Open a Jupyter console console: . ./venv/bin/activate && jupyter console # # Documentation # # Live generate docs and host on a development webserver docs: . ./venv/bin/activate && mkdocs serve # Generate man page and open for preview man: (sphinx 'man') . ./venv/bin/activate && man -l _build/man/pyee.1 # Build the documentation build-docs: @just mkdocs @just sphinx man # Run mkdocs mkdocs: . ./venv/bin/activate && mkdocs build # Run sphinx sphinx TARGET: . ./venv/bin/activate && {{ sphinx-sphinxbuild }} -M "{{ TARGET }}" "{{ sphinx-sourcedir }}" "{{ sphinx-builddir }}" {{ sphinx-sphinxopts }} _clean-docs: rm -rf site rm -rf _build # # Package publishing # # Build the package build: . ./venv/bin/activate && python -m build _clean-build: rm -rf dist # Tag the release in git tag: . ./venv/bin/activate && git tag -a "v$(python3 -c 'import toml; print(toml.load(open("pyproject.toml", "r"))["project"]["version"])')" -m "Release $(python3 -c 'import toml; print(toml.load(open("pyproject.toml", "r"))["project"]["version"])')" # Upload built packages upload: . ./venv/bin/activate && twine upload dist/* # Build the package and publish it to PyPI publish: build upload # Clean up loose files clean: _clean-venv _clean-compile _clean-test _clean-tox _clean-docs rm -rf pyee.egg-info rm -f pyee/*.pyc rm -rf pyee/__pycache__ pyee-13.0.0/mkdocs.yml000066400000000000000000000001521476606743000145530ustar00rootroot00000000000000site_name: pyee Documentation theme: name: readthedocs plugins: - include-markdown - mkdocstrings pyee-13.0.0/mypy.ini000066400000000000000000000000371476606743000142510ustar00rootroot00000000000000[mypy] exclude = ^venv/|^docs/ pyee-13.0.0/pyee/000077500000000000000000000000001476606743000135145ustar00rootroot00000000000000pyee-13.0.0/pyee/__init__.py000066400000000000000000000013721476606743000156300ustar00rootroot00000000000000# -*- 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 ```text 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 pyee.base import EventEmitter, Handler, PyeeError, PyeeException __all__ = ["EventEmitter", "Handler", "PyeeError", "PyeeException"] pyee-13.0.0/pyee/asyncio.py000066400000000000000000000130271476606743000155360ustar00rootroot00000000000000# -*- coding: utf-8 -*- from asyncio import AbstractEventLoop, ensure_future, Future, iscoroutine, wait from typing import Any, Callable, cast, Dict, Optional, Set, Tuple from pyee.base import EventEmitter Self = Any __all__ = ["AsyncIOEventEmitter"] class AsyncIOEventEmitter(EventEmitter): """An event emitter class which can run asyncio coroutines in addition to synchronous blocking functions. For example: ```py @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: Self, loop: Optional[AbstractEventLoop] = None) -> None: super(AsyncIOEventEmitter, self).__init__() self._loop: Optional[AbstractEventLoop] = loop self._waiting: Set[Future] = set() def emit( self: Self, event: str, *args: Any, **kwargs: Any, ) -> bool: """Emit `event`, passing `*args` and `**kwargs` to each attached function or coroutine. Returns `True` if any functions are attached to `event`; otherwise returns `False`. Example: ```py ee.emit('data', '00101001') ``` Assuming `data` is an attached function, this will call `data('00101001')'`. When executing coroutine handlers, their respective futures will be stored in a "waiting" state. These futures may be waited on or canceled with `wait_for_complete` and `cancel`, respectively; and their status may be checked via the `complete` property. """ return super().emit(event, *args, **kwargs) def _emit_run( self: Self, f: Callable, args: Tuple[Any, ...], kwargs: Dict[str, Any], ) -> None: 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: Future) -> None: self._waiting.discard(f) if f.cancelled(): return exc: Optional[BaseException] = f.exception() if exc: self.emit("error", exc) fut.add_done_callback(callback) self._waiting.add(fut) async def wait_for_complete(self: Self) -> None: """Waits for all pending tasks to complete. For example: ```py @ee.on('event') async def async_handler(*args, **kwargs): await returns_a_future() # Triggers execution of async_handler ee.emit('data', '00101001') await ee.wait_for_complete() # async_handler has completed execution ``` This is useful if you're attempting a graceful shutdown of your application and want to ensure all coroutines have completed execution beforehand. """ if self._waiting: await wait(self._waiting) def cancel(self: Self) -> None: """Cancel all pending tasks. For example: ```py @ee.on('event') async def async_handler(*args, **kwargs): await returns_a_future() # Triggers execution of async_handler ee.emit('data', '00101001') ee.cancel() # async_handler execution has been canceled ``` This is useful if you're attempting to shut down your application and attempts at a graceful shutdown via `wait_for_complete` have failed. """ for fut in self._waiting: if not fut.done() and not fut.cancelled(): fut.cancel() self._waiting.clear() @property def complete(self: Self) -> bool: """When true, there are no pending tasks, and execution is complete. For example: ```py @ee.on('event') async def async_handler(*args, **kwargs): await returns_a_future() # Triggers execution of async_handler ee.emit('data', '00101001') # async_handler is still running, so this prints False print(ee.complete) await ee.wait_for_complete() # async_handler has completed execution, so this prints True print(ee.complete) ``` """ return not self._waiting pyee-13.0.0/pyee/base.py000066400000000000000000000204721476606743000150050ustar00rootroot00000000000000# -*- coding: utf-8 -*- from collections import OrderedDict from threading import Lock from typing import ( Any, Callable, Dict, List, Mapping, Optional, overload, Set, Tuple, TypeVar, Union, ) Self = Any class PyeeException(Exception): """An exception internal to pyee. Deprecated in favor of PyeeError.""" class PyeeError(PyeeException): """An error internal to pyee.""" Handler = TypeVar("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: ```py @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: Self) -> None: self._events: Dict[ str, "OrderedDict[Callable, Callable]", ] = dict() self._lock: Lock = Lock() def __getstate__(self: Self) -> Mapping[str, Any]: state = self.__dict__.copy() del state["_lock"] return state def __setstate__(self: Self, state: Mapping[str, Any]) -> None: self.__dict__.update(state) self._lock = Lock() @overload def on(self: Self, event: str) -> Callable[[Handler], Handler]: ... @overload def on(self: Self, event: str, f: Handler) -> Handler: ... def on( self: 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: ```py @ee.on('data') def data_handler(data): print(data) ``` Or directly: ```py 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: Self, event: str) -> Callable[[Handler], Handler]: """Returns a decorator which will register the decorated function to the event name `event`: ```py @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: 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: 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: Self, f: Callable, args: Tuple[Any, ...], kwargs: Dict[str, Any], ) -> None: f(*args, **kwargs) def event_names(self: 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: Self, event: str, error: Any) -> None: if event == "error": if isinstance(error, Exception): raise error else: raise PyeeError(f"Uncaught, unspecified 'error' event: {error}") def _call_handlers( self: 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: 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: ```py 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: Self, event: str, f: Optional[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: 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: 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: 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: Self, event: str) -> List[Callable]: """Returns a list of all listeners registered to the `event`.""" return list(self._events.get(event, OrderedDict()).keys()) pyee-13.0.0/pyee/cls.py000066400000000000000000000056101476606743000146510ustar00rootroot00000000000000from dataclasses import dataclass from functools import wraps from typing import Any, Callable, Iterator, List, Type, TypeVar from pyee import EventEmitter @dataclass class Handler: event: str method: Callable class Handlers: def __init__(self) -> None: self._handlers: List[Handler] = [] def append(self, handler) -> None: self._handlers.append(handler) def __iter__(self) -> Iterator[Handler]: 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: Any, method: Any) -> Any: @wraps(method) def bound(*args, **kwargs) -> Any: return method(self, *args, **kwargs) return bound Cls = TypeVar("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: ```py 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`:: ```py @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: Any, *args: Any, **kwargs: Any) -> None: 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 pyee-13.0.0/pyee/executor.py000066400000000000000000000047401476606743000157310ustar00rootroot00000000000000# -*- 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 Self = Any __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: ```py 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: Self, executor: Optional[Executor] = None) -> None: super(ExecutorEventEmitter, self).__init__() if executor: self._executor: Executor = executor else: self._executor = ThreadPoolExecutor() def _emit_run( self: Self, f: Callable, args: Tuple[Any, ...], kwargs: Dict[str, Any], ) -> None: 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: Self, wait: bool = True) -> None: """Call `shutdown` on the internal executor.""" self._executor.shutdown(wait=wait) def __enter__(self: Self) -> "ExecutorEventEmitter": return self def __exit__( self: Self, type: Type[Exception], value: Exception, traceback: TracebackType ) -> Optional[bool]: self.shutdown() return None pyee-13.0.0/pyee/py.typed000066400000000000000000000000001476606743000152010ustar00rootroot00000000000000pyee-13.0.0/pyee/trio.py000066400000000000000000000110001476606743000150330ustar00rootroot00000000000000# -*- coding: utf-8 -*- from contextlib import AbstractAsyncContextManager, asynccontextmanager from types import TracebackType from typing import ( Any, AsyncGenerator, Awaitable, Callable, cast, Dict, Optional, Tuple, Type, ) import trio from pyee.base import EventEmitter, PyeeError Self = Any __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:: ```py 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: Self, nursery: Optional[Nursery] = None, manager: Optional["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 PyeeError( "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: 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: Self, f: Callable, args: Tuple[Any, ...], kwargs: Dict[str, Any], ) -> None: if not self._nursery: raise PyeeError("Uninitialized trio nursery") self._nursery.start_soon(self._async_runner(f, args, kwargs)) @asynccontextmanager async def context( self: 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 PyeeError("Uninitialized nursery or nursery manager") async def __aenter__(self: Self) -> "TrioEventEmitter": self._context: Optional[AbstractAsyncContextManager["TrioEventEmitter"]] = ( self.context() ) return await cast(Any, self._context).__aenter__() async def __aexit__( self: Self, type: Optional[Type[BaseException]], value: Optional[BaseException], traceback: Optional[TracebackType], ) -> Optional[bool]: if self._context is None: raise PyeeError("Attempting to exit uninitialized context") rv = await self._context.__aexit__(type, value, traceback) self._context = None self._nursery = None self._manager = None return rv pyee-13.0.0/pyee/twisted.py000066400000000000000000000056431476606743000155610ustar00rootroot00000000000000# -*- coding: utf-8 -*- from asyncio import iscoroutine from typing import Any, Callable, cast, Dict, Optional, Tuple from twisted.internet.defer import Deferred, ensureDeferred from twisted.python.failure import Failure from pyee.base import EventEmitter, PyeeError Self = Any __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: ```py @ee.on('event') @inlineCallbacks def async_handler(*args, **kwargs): yield returns_a_deferred() ``` or: ```py @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: Self) -> None: super(TwistedEventEmitter, self).__init__() def _emit_run( self: Self, f: Callable, args: Tuple[Any, ...], kwargs: Dict[str, Any], ) -> None: d: Optional[Deferred[Any]] = None try: result = f(*args, **kwargs) except Exception: self.emit("failure", Failure()) else: if iscoroutine(result): d = ensureDeferred(result) elif isinstance(result, Deferred): d = result elif not d: return def errback(failure: Failure) -> None: if failure: self.emit("failure", failure) d.addErrback(errback) def _emit_handle_potential_error(self: 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", PyeeError(f"Unexpected failure object: {error}")) else: cast(Any, super(TwistedEventEmitter, self))._emit_handle_potential_error( event, error ) pyee-13.0.0/pyee/uplift.py000066400000000000000000000146641476606743000154040ustar00rootroot00000000000000# -*- coding: utf-8 -*- from functools import wraps from typing import Any, Callable, cast, Dict, Optional, Tuple, Type, TypeVar, Union import warnings from typing_extensions import Literal from pyee.base import EventEmitter UpliftingEventEmitter = TypeVar("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: cast(Any, left).emit = left_emit if left_unwrap: EMIT_WRAPPERS[left] = left_unwrap else: del EMIT_WRAPPERS[left] del left.unwrap # type: ignore cast(Any, left).emit = left_emit unwrap(right) cast(Any, 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 event emitter to the new event emitter but not vice versa. - 'both': `new_listener` events are propagated as with other events. - 'neither': `new_listener` events are only fired on their respective event emitters. - '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 pyee-13.0.0/pyproject.toml000066400000000000000000000040251476606743000154670ustar00rootroot00000000000000[build-system] requires = ["setuptools", "setuptools-scm", "wheel"] build-backend = "setuptools.build_meta" [project] name = "pyee" version = "13.0.0" authors = [ {name = "Josh Holbrook", email = "josh.holbrook@gmail.com"} ] urls = {Repository = "https://github.com/jfhbrook/pyee", Documentation = "https://pyee.readthedocs.io"} description = "A rough port of Node.js's EventEmitter to Python with a few tricks of its own" readme = "README.md" keywords = ["events", "emitter", "node.js", "node", "eventemitter", "event_emitter"] license = { text = "MIT" } 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.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Other/Nonlisted Topic", ] requires-python = ">=3.8" dependencies = [ "typing-extensions" ] [project.optional-dependencies] dev = [ "build", "flake8", "flake8-black", "pytest", "pytest-asyncio; python_version >= '3.4'", "pytest-trio; python_version >= '3.7'", "black", "isort", "jupyter-console", "mkdocs", "mkdocs-include-markdown-plugin", "mkdocstrings[python]", "mypy", "sphinx", "toml", "tox", "trio", "trio; python_version > '3.6'", "trio-typing; python_version > '3.6'", "twine", "twisted", "validate-pyproject[all]", ] [tool.isort] profile = "appnexus" known_application = "pyee" [tool.pyright] include = ["pyee", "tests"] [tool.pytest] addopts = "--verbose -s" testpaths = [ "tests" ] [tool.setuptools] packages = ["pyee"] [tool.setuptools.dynamic] dependencies = { file = ["requirements.txt"] } optional-dependencies.dev = { file = ["requirements_dev.txt"] } pyee-13.0.0/requirements.txt000066400000000000000000000003271476606743000160400ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with Python 3.13 # by the following command: # # pip-compile --output-file=requirements.txt pyproject.toml # typing-extensions==4.9.0 # via pyee (pyproject.toml) pyee-13.0.0/requirements_dev.txt000066400000000000000000000155671476606743000167120ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with Python 3.13 # by the following command: # # pip-compile --extra=dev --output-file=requirements_dev.txt pyproject.toml # alabaster==0.7.16 # via sphinx appnope==0.1.4 # via ipykernel asttokens==2.4.1 # via stack-data async-generator==1.10 # via trio-typing attrs==23.2.0 # via # automat # outcome # trio # twisted automat==22.10.0 # via twisted babel==2.14.0 # via sphinx black==24.3.0 # via # flake8-black # pyee (pyproject.toml) bracex==2.4 # via wcmatch build==1.0.3 # via pyee (pyproject.toml) cachetools==5.3.2 # via tox certifi==2024.7.4 # via requests chardet==5.2.0 # via tox charset-normalizer==3.3.2 # via requests click==8.1.7 # via # black # mkdocs # mkdocstrings colorama==0.4.6 # via # griffe # tox comm==0.2.1 # via ipykernel constantly==23.10.4 # via twisted debugpy==1.8.0 # via ipykernel decorator==5.1.1 # via ipython distlib==0.3.8 # via virtualenv docutils==0.20.1 # via # readme-renderer # sphinx executing==2.0.1 # via stack-data fastjsonschema==2.19.1 # via validate-pyproject filelock==3.13.1 # via # tox # virtualenv flake8==7.0.0 # via # flake8-black # pyee (pyproject.toml) flake8-black==0.3.6 # via pyee (pyproject.toml) ghp-import==2.1.0 # via mkdocs griffe==0.40.1 # via mkdocstrings-python hyperlink==21.0.0 # via twisted idna==3.7 # via # hyperlink # requests # trio imagesize==1.4.1 # via sphinx importlib-metadata==7.0.1 # via # trio-typing # twine incremental==24.7.2 # via twisted iniconfig==2.0.0 # via pytest ipykernel==6.29.2 # via jupyter-console ipython==8.22.1 # via # ipykernel # jupyter-console isort==5.13.2 # via pyee (pyproject.toml) jaraco-classes==3.3.1 # via keyring jedi==0.19.1 # via ipython jinja2==3.1.6 # via # mkdocs # mkdocstrings # sphinx jupyter-client==8.6.0 # via # ipykernel # jupyter-console jupyter-console==6.6.3 # via pyee (pyproject.toml) jupyter-core==5.7.1 # via # ipykernel # jupyter-client # jupyter-console keyring==24.3.0 # via twine markdown==3.5.2 # via # mkdocs # mkdocs-autorefs # mkdocstrings # pymdown-extensions markdown-it-py==3.0.0 # via rich markupsafe==2.1.5 # via # jinja2 # mkdocs # mkdocstrings matplotlib-inline==0.1.6 # via # ipykernel # ipython mccabe==0.7.0 # via flake8 mdurl==0.1.2 # via markdown-it-py mergedeep==1.3.4 # via mkdocs mkdocs==1.5.3 # via # mkdocs-autorefs # mkdocs-include-markdown-plugin # mkdocstrings # pyee (pyproject.toml) mkdocs-autorefs==0.5.0 # via mkdocstrings mkdocs-include-markdown-plugin==6.0.4 # via pyee (pyproject.toml) mkdocstrings[python]==0.24.0 # via # mkdocstrings-python # pyee (pyproject.toml) mkdocstrings-python==1.8.0 # via mkdocstrings more-itertools==10.2.0 # via jaraco-classes mypy==1.15.0 # via pyee (pyproject.toml) mypy-extensions==1.0.0 # via # black # mypy # trio-typing nest-asyncio==1.6.0 # via ipykernel nh3==0.2.15 # via readme-renderer outcome==1.3.0.post0 # via # pytest-trio # trio packaging==23.2 # via # black # build # ipykernel # mkdocs # pyproject-api # pytest # sphinx # tox # trio-typing # validate-pyproject parso==0.8.3 # via jedi pathspec==0.12.1 # via # black # mkdocs pexpect==4.9.0 # via ipython pkginfo==1.9.6 # via twine platformdirs==4.2.0 # via # black # jupyter-core # mkdocs # mkdocstrings # tox # virtualenv pluggy==1.4.0 # via # pytest # tox prompt-toolkit==3.0.43 # via # ipython # jupyter-console psutil==5.9.8 # via ipykernel ptyprocess==0.7.0 # via pexpect pure-eval==0.2.2 # via stack-data pycodestyle==2.11.1 # via flake8 pyflakes==3.2.0 # via flake8 pygments==2.17.2 # via # ipython # jupyter-console # readme-renderer # rich # sphinx pymdown-extensions==10.7 # via mkdocstrings pyproject-api==1.6.1 # via tox pyproject-hooks==1.0.0 # via build pytest==8.0.1 # via # pyee (pyproject.toml) # pytest-asyncio # pytest-trio pytest-asyncio==0.23.5 ; python_version >= "3.4" # via pyee (pyproject.toml) pytest-trio==0.8.0 ; python_version >= "3.7" # via pyee (pyproject.toml) python-dateutil==2.8.2 # via # ghp-import # jupyter-client pyyaml==6.0.1 # via # mkdocs # pymdown-extensions # pyyaml-env-tag pyyaml-env-tag==0.1 # via mkdocs pyzmq==25.1.2 # via # ipykernel # jupyter-client # jupyter-console readme-renderer==42.0 # via twine requests==2.32.2 # via # requests-toolbelt # sphinx # twine requests-toolbelt==1.0.0 # via twine rfc3986==2.0.0 # via twine rich==13.7.0 # via twine six==1.16.0 # via # asttokens # automat # python-dateutil sniffio==1.3.0 # via trio snowballstemmer==2.2.0 # via sphinx sortedcontainers==2.4.0 # via trio sphinx==7.2.6 # via pyee (pyproject.toml) sphinxcontrib-applehelp==1.0.8 # via sphinx sphinxcontrib-devhelp==1.0.6 # via sphinx sphinxcontrib-htmlhelp==2.0.5 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==1.0.7 # via sphinx sphinxcontrib-serializinghtml==1.1.10 # via sphinx stack-data==0.6.3 # via ipython toml==0.10.2 # via pyee (pyproject.toml) tornado==6.4.1 # via # ipykernel # jupyter-client tox==4.13.0 # via pyee (pyproject.toml) traitlets==5.14.1 # via # comm # ipykernel # ipython # jupyter-client # jupyter-console # jupyter-core # matplotlib-inline trio==0.24.0 ; python_version > "3.6" # via # pyee (pyproject.toml) # pytest-trio # trio-typing trio-typing==0.10.0 ; python_version > "3.6" # via pyee (pyproject.toml) trove-classifiers==2024.2.22 # via validate-pyproject twine==5.0.0 # via pyee (pyproject.toml) twisted==24.7.0 # via pyee (pyproject.toml) typing-extensions==4.9.0 # via # mypy # pyee (pyproject.toml) # trio-typing # twisted urllib3==2.2.2 # via # requests # twine validate-pyproject[all]==0.16 # via pyee (pyproject.toml) virtualenv==20.26.6 # via tox watchdog==4.0.0 # via mkdocs wcmatch==8.5.1 # via mkdocs-include-markdown-plugin wcwidth==0.2.13 # via prompt-toolkit zipp==3.19.1 # via importlib-metadata zope-interface==6.2 # via twisted # The following packages are considered to be unsafe in a requirements file: # setuptools pyee-13.0.0/setup.cfg000066400000000000000000000000631476606743000143720ustar00rootroot00000000000000[flake8] max-line-length = 88 extend-ignore = E203 pyee-13.0.0/tests/000077500000000000000000000000001476606743000137145ustar00rootroot00000000000000pyee-13.0.0/tests/conftest.py000066400000000000000000000003531476606743000161140ustar00rootroot00000000000000# -*- 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") pyee-13.0.0/tests/test_asyncio.py000066400000000000000000000104061476606743000167730ustar00rootroot00000000000000# -*- coding: utf-8 -*- import asyncio from asyncio import Future, get_running_loop, sleep, wait_for from typing import NoReturn 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 pyee.asyncio import AsyncIOEventEmitter class PyeeTestError(Exception): pass @pytest.mark.asyncio async def test_emit() -> None: """Test that AsyncIOEventEmitter can handle wrapping coroutines """ ee = AsyncIOEventEmitter(loop=get_running_loop()) should_call: Future[bool] = Future(loop=get_running_loop()) @ee.on("event") async def event_handler() -> None: should_call.set_result(True) ee.emit("event") result = await wait_for(should_call, 0.1) assert result is True await asyncio.sleep(0) assert ee.complete @pytest.mark.asyncio async def test_once_emit() -> None: """Test that AsyncIOEventEmitter also wrap coroutines when using once """ ee = AsyncIOEventEmitter(loop=get_running_loop()) should_call: Future[bool] = Future(loop=get_running_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_error() -> None: """Test that AsyncIOEventEmitter can handle errors when wrapping coroutines """ ee = AsyncIOEventEmitter(loop=get_running_loop()) should_call: Future[bool] = Future(loop=get_running_loop()) @ee.on("event") async def event_handler() -> NoReturn: 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_future_canceled() -> None: """Test that AsyncIOEventEmitter can handle canceled Futures""" cancel_me: Future[bool] = Future(loop=get_running_loop()) should_not_call: Future[None] = Future(loop=get_running_loop()) ee = AsyncIOEventEmitter(loop=get_running_loop()) @ee.on("event") async def event_handler() -> None: cancel_me.cancel() @ee.on("error") def handle_error(exc) -> None: 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_event_emitter_canceled() -> None: """Test that all running handlers in AsyncIOEventEmitter can be canceled""" ee = AsyncIOEventEmitter(loop=get_running_loop()) should_not_call: Future[bool] = Future(loop=get_running_loop()) @ee.on("event") async def event_handler(): await sleep(1) should_not_call.set_result(True) ee.emit("event") await sleep(0) # event_handler should still be running assert not ee.complete # cancel all pending tasks, including event_handler ee.cancel() await sleep(0) # event_handler should be canceled assert ee.complete @pytest.mark.asyncio async def test_wait_for_complete() -> None: """Test waiting for all pending tasks in an AsyncIOEventEmitter to complete """ ee = AsyncIOEventEmitter(loop=get_running_loop()) @ee.on("event") async def event_handler(): await sleep(0.1) ee.emit("event") await sleep(0) # event_handler should still be running assert not ee.complete # wait for event_handler to complete execution await ee.wait_for_complete() # event_handler should have finished execution assert ee.complete @pytest.mark.asyncio async def test_sync_error() -> None: """Test that regular functions have the same error handling as coroutines""" ee = AsyncIOEventEmitter(loop=get_running_loop()) should_call: Future[Exception] = Future(loop=get_running_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) pyee-13.0.0/tests/test_cls.py000066400000000000000000000017531476606743000161140ustar00rootroot00000000000000# -*- coding: utf-8 -*- from unittest.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() pyee-13.0.0/tests/test_executor.py000066400000000000000000000023351476606743000171660ustar00rootroot00000000000000# -*- coding: utf-8 -*- from time import sleep from unittest.mock import Mock from pyee.executor 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() pyee-13.0.0/tests/test_sync.py000066400000000000000000000142311476606743000163020ustar00rootroot00000000000000# -*- coding: utf-8 -*- from collections import OrderedDict from pickle import dumps, loads from unittest.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() def test_if_serializable(): """`pickle`ing should not throw.""" ee = EventEmitter() ee_copy = loads(dumps(ee)) assert ee._lock assert ee_copy._lock pyee-13.0.0/tests/test_trio.py000066400000000000000000000046331476606743000163100ustar00rootroot00000000000000# -*- coding: utf-8 -*- import pytest import pytest_trio.plugin # type: ignore # noqa import trio from pyee.trio 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) pyee-13.0.0/tests/test_twisted.py000066400000000000000000000056441476606743000170210ustar00rootroot00000000000000# -*- coding: utf-8 -*- from typing import Any, Generator from unittest.mock import Mock from twisted.internet.defer import Deferred, inlineCallbacks, succeed from twisted.python.failure import Failure from pyee.twisted import TwistedEventEmitter class PyeeTestError(Exception): pass def test_emit() -> None: """Test that TwistedEventEmitter can handle wrapping coroutines """ ee = TwistedEventEmitter() should_call = Mock() @ee.on("event") async def event_handler() -> None: _ = await succeed("yes!") should_call(True) ee.emit("event") should_call.assert_called_once() def test_once() -> None: """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_error() -> None: """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() 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() -> Generator[Deferred[object], object, None]: d: Deferred[Any] = Deferred() d.callback(Failure(PyeeTestError())) yield d @ee.on("failure") def handle_failure(f: Any) -> None: 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() -> Generator[Deferred[object], object, None]: d: Deferred[Any] = Deferred() d.callback(Failure(PyeeTestError())) yield d @ee.on("error") def handle_error(exc): assert isinstance(exc, Exception) should_call(exc) ee.emit("event") should_call.assert_called_once() pyee-13.0.0/tests/test_uplift.py000066400000000000000000000142571476606743000166410ustar00rootroot00000000000000# -*- coding: utf-8 -*- from unittest.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") pyee-13.0.0/tox.ini000066400000000000000000000002301476606743000140600ustar00rootroot00000000000000[tox] envlist = py38,py39,py310 [testenv] deps = -r requirements_dev.txt -e . commands = flake8 './pyee' ./tests ./docs pytest ./tests