├── .github └── workflows │ ├── build.yml │ └── deploy.yml ├── .gitignore ├── .vscode └── launch.json ├── CHANGELOG.md ├── CONTRIBUTING.rst ├── LICENSE ├── README.rst ├── broqer ├── __init__.py ├── coro_queue.py ├── disposable.py ├── error_handler.py ├── op │ ├── __init__.py │ ├── bitwise.py │ ├── cache.py │ ├── combine_latest.py │ ├── filter_.py │ ├── map_.py │ ├── map_async.py │ ├── py_operators.py │ └── throttle.py ├── operator.py ├── operator_overloading.py ├── publisher.py ├── publishers │ ├── __init__.py │ └── poll.py ├── subscriber.py ├── subscribers │ ├── __init__.py │ ├── on_emit_future.py │ ├── sink.py │ ├── sink_async.py │ └── trace.py ├── timer.py ├── types.py └── value.py ├── docs ├── Makefile ├── conf.py ├── example1.svg ├── hub.rst ├── index.rst ├── introduction.rst ├── logo.svg ├── operators.rst ├── operators │ ├── accumulate.rst │ ├── cache.rst │ ├── catch_exception.rst │ ├── combine_latest.rst │ ├── debounce.rst │ ├── delay.rst │ ├── filter.rst │ ├── map.rst │ ├── map_async.rst │ ├── map_threaded.rst │ ├── merge.rst │ ├── partition.rst │ ├── reduce.rst │ ├── replace.rst │ ├── sample.rst │ ├── sliding_window.rst │ ├── switch.rst │ └── throttle.rst ├── overview.gnumeric ├── publishers.rst ├── requirements.txt ├── subjects.rst └── subscribers.rst ├── examples ├── await.py ├── from_polling.py └── pipeline.py ├── pyproject.toml ├── requirements_dev.txt ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── eventloop.py ├── helper_multi.py ├── helper_single.py ├── test_core_disposable.py ├── test_core_publisher.py ├── test_core_publisher_operators.py ├── test_coro_queue.py ├── test_error_handler.py ├── test_op_bitwise.py ├── test_op_combine_latest.py ├── test_op_filter.py ├── test_op_map.py ├── test_op_on_emit_future.py ├── test_op_sink.py ├── test_op_throttle.py └── test_publishers_poll.py /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build Python Package 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: ["3.8", "3.9", "3.10", "3.11"] 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install -r requirements_dev.txt -e . 23 | - name: Lint with flake8 24 | run: flake8 broqer 25 | - name: Static type checking 26 | run: mypy broqer --no-strict-optional --disable-error-code type-var --disable-error-code call-arg 27 | - name: Check coding style 28 | run: pylama 29 | - name: Check Readme style 30 | run: rstcheck README.rst 31 | - name: Test with pytest 32 | run: pytest 33 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - name: Set up Python 13 | uses: actions/setup-python@v4 14 | with: 15 | python-version: '3.10' 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install -r requirements_dev.txt build 20 | pip install -e . 21 | - name: Build package 22 | run: python -m build 23 | - name: Publish package 24 | uses: pypa/gh-action-pypi-publish@v1.5.1 25 | with: 26 | user: __token__ 27 | password: ${{ secrets.PYPI_API_TOKEN }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | _version.py 3 | *.egg-info 4 | *.eggs 5 | .*_cache 6 | .venv 7 | htmlcov 8 | .coverage 9 | dist 10 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | 8 | { 9 | "name": "Pytest (all tests)", 10 | "type": "python", 11 | "request": "launch", 12 | "debugOptions": ["DebugStdLib"], 13 | "module": "pytest", 14 | "args": [ 15 | "-xsv", 16 | "--no-cov" 17 | ], 18 | }, 19 | { 20 | "name": "Pytest (current file)", 21 | "type": "python", 22 | "request": "launch", 23 | "debugOptions": ["DebugStdLib"], 24 | "module": "pytest", 25 | "args": [ 26 | "${file}", 27 | "-xsv", 28 | "--no-cov" 29 | ], 30 | } 31 | ] 32 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 3.1.1 2 | 3 | * bugfix for emit_partial 4 | 5 | ## 3.1.0 6 | 7 | * added emit_partial argument in CombineLatest (thanks to Florian Feurstein) 8 | 9 | ## 3.0.3 10 | 11 | * Fix missing error_callback call in SinkAsync (thanks to Patrick Kaulfus) 12 | 13 | ## 3.0.2 14 | 15 | * fixed liniting errors 16 | 17 | ## 3.0.1 18 | 19 | * merge #43: wheel dependency updated 20 | * fixing #43: fixed wrong reference of internal module 21 | 22 | ## 3.0.0 23 | 24 | * fixed typo ("orginator" fixed to "originator") 25 | * add log output for default error_handler 26 | * add `dependent_subscribe` function for `Values` 27 | * remove `OperatorFactory` logic from operators 28 | * remove `Concat` operator 29 | * add `CoroQueue` to be used in `MapAsync` and `SinkAsync` 30 | 31 | ## 2.4.0 32 | 33 | * changed default behavior of error_handler: now it raises an exception 34 | 35 | ## 2.3.3 36 | 37 | * added `broqer.Timer` class and rewrite `op.Throttle` to use that class 38 | 39 | ## 2.3.2 40 | 41 | * added `PollPublisher` 42 | 43 | ## 2.3.1 44 | 45 | * fix OnEmitFuture behavior when using `omit_subscription` argument 46 | 47 | ## 2.3.0 48 | 49 | * added `Cache` operator 50 | 51 | ## 2.2.0 52 | 53 | * added `Throttle` operator (thanks to [@flofeurstein](https://github.com/flofeurstein>) ) 54 | 55 | ## 2.1.0 56 | 57 | * .reset_state is now calling .reset_state for all subscribers 58 | 59 | ## 2.0.3 60 | 61 | * prevent iteration over a publisher 62 | * fix another bug in BitwiseCombineLatest (emitted NONE when no publisher had state) 63 | 64 | ## 2.0.2 65 | 66 | * fixed behaviour for BitwiseCombineLatest when a Publisher has state NONE 67 | 68 | ## 2.0.1 69 | 70 | * fixed problem in `Publisher.register_on_subscription_callback()` when subscriptions already are available 71 | 72 | ## 2.0.0 73 | 74 | * replace bumpversion by use_scm_version 75 | * replace pylint by pylama 76 | * fixed `Publisher.notify` bug (39d17642610ff86c9264986788e929419f007803) 77 | * added `BitwiseCombineLatest` and `map_bit` operator 78 | * added `Not` operator 79 | * remove Pipfile functionality 80 | 81 | ## 2.0.0rc1 82 | 83 | * rename `default_error_handler.py` to `error_handler.py` 84 | * added `BitwiseCombineLatest` and `map_bit` 85 | * fixing typing warnings 86 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every little bit 8 | helps, and credit will always be given. 9 | 10 | You can contribute in many ways: 11 | 12 | Types of Contributions 13 | ---------------------- 14 | 15 | Report Bugs 16 | ~~~~~~~~~~~ 17 | 18 | Report bugs at https://github.com/semiversus/python-broqer/issues. 19 | 20 | If you are reporting a bug, please include: 21 | 22 | * Your operating system name and version. 23 | * Any details about your local setup that might be helpful in troubleshooting. 24 | * Detailed steps to reproduce the bug. 25 | 26 | Fix Bugs 27 | ~~~~~~~~ 28 | 29 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help 30 | wanted" is open to whoever wants to implement it. 31 | 32 | Implement Features 33 | ~~~~~~~~~~~~~~~~~~ 34 | 35 | Look through the GitHub issues for features. Anything tagged with "enhancement" 36 | and "help wanted" is open to whoever wants to implement it. 37 | 38 | Write Documentation 39 | ~~~~~~~~~~~~~~~~~~~ 40 | 41 | Python Broqer could always use more documentation, whether as part of the 42 | official Python Broqer docs, in docstrings, or even on the web in blog posts, 43 | articles, and such. 44 | 45 | Submit Feedback 46 | ~~~~~~~~~~~~~~~ 47 | 48 | The best way to send feedback is to file an issue at https://github.com/semiversus/python-broqer/issues. 49 | 50 | If you are proposing a feature: 51 | 52 | * Explain in detail how it would work. 53 | * Keep the scope as narrow as possible, to make it easier to implement. 54 | * Remember that this is a volunteer-driven project, and that contributions 55 | are welcome :) 56 | 57 | Get Started! 58 | ------------ 59 | 60 | Ready to contribute? Here's how to set up `broqer` for local development. 61 | 62 | 1. Fork the `broqer` repo on GitHub. 63 | 2. Clone your fork locally:: 64 | 65 | $ git clone git@github.com:your_name_here/broqer.git 66 | 67 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 68 | 69 | $ mkvirtualenv broqer 70 | $ cd broqer/ 71 | $ python setup.py develop 72 | 73 | 4. Create a branch for local development:: 74 | 75 | $ git checkout -b name-of-your-bugfix-or-feature 76 | 77 | Now you can make your changes locally. 78 | 79 | 5. When you're done making changes, check that your changes pass flake8 and the 80 | tests, including testing other Python versions with tox:: 81 | 82 | $ flake8 broqer tests 83 | $ python setup.py test or py.test 84 | $ tox 85 | 86 | To get flake8 and tox, just pip install them into your virtualenv. 87 | 88 | 6. Commit your changes and push your branch to GitHub:: 89 | 90 | $ git add . 91 | $ git commit -m "Your detailed description of your changes." 92 | $ git push origin name-of-your-bugfix-or-feature 93 | 94 | 7. Submit a pull request through the GitHub website. 95 | 96 | Pull Request Guidelines 97 | ----------------------- 98 | 99 | Before you submit a pull request, check that it meets these guidelines: 100 | 101 | 1. The pull request should include tests. 102 | 2. If the pull request adds functionality, the docs should be updated. Put 103 | your new functionality into a function with a docstring, and add the 104 | feature to the list in README.rst. 105 | 3. The pull request should work for Python 3.6, 3.7, 3.8 and 3.9. Check 106 | https://travis-ci.org/semiversus/python-broqer/pull_requests 107 | and make sure that the tests pass for all supported Python versions. 108 | 109 | Tips 110 | ---- 111 | 112 | To run a subset of tests:: 113 | 114 | $ py.test tests.test_broqer 115 | 116 | 117 | Deploying 118 | --------- 119 | 120 | A reminder for the maintainers on how to deploy. 121 | Make sure all your changes are committed (including an entry in HISTORY.rst). 122 | Then run:: 123 | 124 | $ bumpversion patch # possible: major / minor / patch 125 | $ git push 126 | $ git push --tags 127 | 128 | Travis will then deploy to PyPI if tests pass. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Günther Jena 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======================================= 2 | Python Broqer 3 | ======================================= 4 | 5 | .. image:: https://img.shields.io/pypi/v/broqer.svg 6 | :target: https://pypi.python.org/pypi/broqer 7 | 8 | .. image:: https://readthedocs.org/projects/python-broqer/badge/?version=latest 9 | :target: https://python-broqer.readthedocs.io/en/latest 10 | 11 | .. image:: https://img.shields.io/github/license/semiversus/python-broqer.svg 12 | :target: https://en.wikipedia.org/wiki/MIT_License 13 | 14 | Initial focus on embedded systems *Broqer* can be used wherever continuous streams of data have to be processed - and they are everywhere. Watch out! 15 | 16 | .. image:: https://cdn.rawgit.com/semiversus/python-broqer/7beb7379/docs/logo.svg 17 | 18 | .. header 19 | 20 | Synopsis 21 | ======== 22 | 23 | - Pure python implementation without dependencies 24 | - Under MIT license (2018 Günther Jena) 25 | - Source is hosted on GitHub.com_ 26 | - Documentation is hosted on ReadTheDocs.com_ 27 | - Tested on Python 3.7. 3.8, 3.9, 3.10 and 3.11 28 | - Unit tested with pytest_, coding style checked with Flake8_, static type checked with mypy_, static code checked with Pylint_, documented with Sphinx_ 29 | - Operators known from ReactiveX_ and other streaming frameworks (like Map_, CombineLatest_, ...) 30 | 31 | + Centralised object to keep track of publishers and subscribers 32 | + Starting point to build applications with a microservice architecture 33 | 34 | .. _pytest: https://docs.pytest.org/en/latest 35 | .. _Flake8: http://flake8.pycqa.org/en/latest/ 36 | .. _mypy: http://mypy-lang.org/ 37 | .. _Pylint: https://www.pylint.org/ 38 | .. _Sphinx: http://www.sphinx-doc.org 39 | .. _GitHub.com: https://github.com/semiversus/python-broqer 40 | .. _ReadTheDocs.com: http://python-broqer.readthedocs.io 41 | .. _ReactiveX: http://reactivex.io/ 42 | 43 | Showcase 44 | ======== 45 | 46 | In other frameworks a *Publisher* is sometimes called *Oberservable*. A *Subscriber* 47 | is able to observe changes the publisher is emitting. With these basics you're 48 | able to use the observer pattern - let's see! 49 | 50 | Observer pattern 51 | ---------------- 52 | 53 | Subscribing to a publisher is done via the .subscribe() method. 54 | A simple subscriber is ``Sink`` which is calling a function with optional positional 55 | and keyword arguments. 56 | 57 | .. code-block:: python3 58 | 59 | >>> from broqer import Publisher, Sink 60 | >>> a = Publisher(5) # create a publisher with state `5` 61 | >>> s = Sink(print, 'Change:') # create a subscriber 62 | >>> disposable = a.subscribe(s) # subscribe subscriber to publisher 63 | Change: 5 64 | 65 | >>> a.notify(3) # change the state 66 | Change: 3 67 | 68 | >>> disposable.dispose() # unsubscribe 69 | 70 | Combine publishers with arithmetic operators 71 | -------------------------------------------- 72 | 73 | You're able to create publishers on the fly by combining two publishers with 74 | the common operators (like ``+``, ``>``, ``<<``, ...). 75 | 76 | .. code-block:: python3 77 | 78 | >>> a = Publisher(1) 79 | >>> b = Publisher(3) 80 | 81 | >>> c = a * 3 > b # create a new publisher via operator overloading 82 | >>> disposable = c.subscribe(Sink(print, 'c:')) 83 | c: False 84 | 85 | >>> a.notify(2) 86 | c: True 87 | 88 | >>> b.notify(10) 89 | c: False 90 | 91 | Also fancy stuff like getting item by index or key is possible: 92 | 93 | .. code-block:: python3 94 | 95 | >>> i = Publisher('a') 96 | >>> d = Publisher({'a':100, 'b':200, 'c':300}) 97 | 98 | >>> disposable = d[i].subscribe(Sink(print, 'r:')) 99 | r: 100 100 | 101 | >>> i.notify('c') 102 | r: 300 103 | >>> d.notify({'c':123}) 104 | r: 123 105 | 106 | Some python built in functions can't return Publishers (e.g. ``len()`` needs to 107 | return an integer). For these cases special functions are defined in broqer: ``Str``, 108 | ``Int``, ``Float``, ``Len`` and ``In`` (for ``x in y``). Also other functions 109 | for convenience are available: ``All``, ``Any``, ``BitwiseAnd`` and ``BitwiseOr``. 110 | 111 | Attribute access on a publisher is building a publisher where the actual attribute 112 | access is done on emitting values. A publisher has to know, which type it should 113 | mimic - this is done via ``.inherit_type(type)``. 114 | 115 | .. code-block:: python3 116 | 117 | >>> i = Publisher('Attribute access made REACTIVE') 118 | >>> i.inherit_type(str) 119 | >>> disposable = i.lower().split(sep=' ').subscribe(Sink(print)) 120 | ['attribute', 'access', 'made', 'reactive'] 121 | 122 | >>> i.notify('Reactive and pythonic') 123 | ['reactive', 'and', 'pythonic'] 124 | 125 | Function decorators 126 | ------------------- 127 | 128 | Make your own operators on the fly with function decorators. Decorators are 129 | available for ``Accumulate``, ``CombineLatest``, ``Filter``, ``Map``, ``MapAsync``, 130 | ``MapThreaded``, ``Reduce`` and ``Sink``. 131 | 132 | .. code-block:: python3 133 | 134 | >>> from broqer import op 135 | >>> @op.build_map 136 | ... def count_vowels(s): 137 | ... return sum([s.count(v) for v in 'aeiou']) 138 | 139 | >>> msg = Publisher('Hello World!') 140 | >>> disposable = (msg | count_vowels).subscribe(Sink(print, 'Number of vowels:')) 141 | Number of vowels: 3 142 | >>> msg.notify('Wahuuu') 143 | Number of vowels: 4 144 | 145 | You can even make configurable ``Map`` s and ``Filter`` s: 146 | 147 | .. code-block:: python3 148 | 149 | >>> import re 150 | 151 | >>> @op.build_filter_factory 152 | ... def filter_pattern(pattern, s): 153 | ... return re.search(pattern, s) is not None 154 | 155 | >>> msg = Publisher('Cars passed: 135!') 156 | >>> disposable = (msg | filter_pattern('[0-9]+')).subscribe(Sink(print)) 157 | Cars passed: 135! 158 | >>> msg.notify('No cars have passed') 159 | >>> msg.notify('Only 1 car has passed') 160 | Only 1 car has passed 161 | 162 | 163 | Install 164 | ======= 165 | 166 | .. code-block:: bash 167 | 168 | pip install broqer 169 | 170 | Credits 171 | ======= 172 | 173 | Broqer was inspired by: 174 | 175 | * RxPY_: Reactive Extension for Python (by Børge Lanes and Dag Brattli) 176 | * aioreactive_: Async/Await reactive tools for Python (by Dag Brattli) 177 | * streamz_: build pipelines to manage continuous streams of data (by Matthew Rocklin) 178 | * MQTT_: M2M connectivity protocol 179 | * `Florian Feurstein `_: spending hours of discussion, coming up with great ideas and help me understand the concepts! 180 | 181 | .. _RxPY: https://github.com/ReactiveX/RxPY 182 | .. _aioreactive: https://github.com/dbrattli/aioreactive 183 | .. _streamz: https://github.com/mrocklin/streamz 184 | .. _MQTT: http://mqtt.org/ 185 | .. _Value: https://python-broqer.readthedocs.io/en/latest/subjects.html#value 186 | .. _Publisher: https://python-broqer.readthedocs.io/en/latest/publishers.html#publisher 187 | .. _Subscriber: https://python-broqer.readthedocs.io/en/latest/subscribers.html#subscriber 188 | .. _CombineLatest: https://python-broqer.readthedocs.io/en/latest/operators/combine_latest.py 189 | .. _Filter: https://python-broqer.readthedocs.io/en/latest/operators/filter_.py 190 | .. _Map: https://python-broqer.readthedocs.io/en/latest/operators/map_.py 191 | .. _MapAsync: https://python-broqer.readthedocs.io/en/latest/operators/map_async.py 192 | .. _Sink: https://python-broqer.readthedocs.io/en/latest/operators/subscribers/sink.py 193 | .. _SinkAsync: https://python-broqer.readthedocs.io/en/latest/operators/subscribers/sink_async.py 194 | .. _OnEmitFuture: https://python-broqer.readthedocs.io/en/latest/subscribers.html#trace 195 | .. _Trace: https://python-broqer.readthedocs.io/en/latest/subscribers.html#trace 196 | 197 | .. api 198 | 199 | API 200 | === 201 | 202 | Publishers 203 | ---------- 204 | 205 | A Publisher_ is the source for messages. 206 | 207 | +------------------------------------+--------------------------------------------------------------------------+ 208 | | Publisher_ () | Basic publisher | 209 | +------------------------------------+--------------------------------------------------------------------------+ 210 | 211 | Operators 212 | --------- 213 | 214 | +-------------------------------------+-----------------------------------------------------------------------------+ 215 | | CombineLatest_ (\*publishers) | Combine the latest emit of multiple publishers and emit the combination | 216 | +-------------------------------------+-----------------------------------------------------------------------------+ 217 | | Filter_ (predicate, ...) | Filters values based on a ``predicate`` function | 218 | +-------------------------------------+-----------------------------------------------------------------------------+ 219 | | Map_ (map_func, \*args, \*\*kwargs) | Apply ``map_func(*args, value, **kwargs)`` to each emitted value | 220 | +-------------------------------------+-----------------------------------------------------------------------------+ 221 | | MapAsync_ (coro, mode, ...) | Apply ``coro(*args, value, **kwargs)`` to each emitted value | 222 | +-------------------------------------+-----------------------------------------------------------------------------+ 223 | | Throttle (duration) | Limit the number of emits per duration | 224 | +-------------------------------------+-----------------------------------------------------------------------------+ 225 | 226 | Subscribers 227 | ----------- 228 | 229 | A Subscriber_ is the sink for messages. 230 | 231 | +----------------------------------+--------------------------------------------------------------+ 232 | | Sink_ (func, \*args, \*\*kwargs) | Apply ``func(*args, value, **kwargs)`` to each emitted value | 233 | +----------------------------------+--------------------------------------------------------------+ 234 | | SinkAsync_ (coro, ...) | Apply ``coro(*args, value, **kwargs)`` to each emitted value | 235 | +----------------------------------+--------------------------------------------------------------+ 236 | | OnEmitFuture_ (timeout=None) | Build a future able to await for | 237 | +----------------------------------+--------------------------------------------------------------+ 238 | | Trace_ (d) | Debug output for publishers | 239 | +----------------------------------+--------------------------------------------------------------+ 240 | 241 | Values 242 | -------- 243 | 244 | +--------------------------+--------------------------------------------------------------+ 245 | | Value_ (\*init) | Publisher and Subscriber | 246 | +--------------------------+--------------------------------------------------------------+ 247 | -------------------------------------------------------------------------------- /broqer/__init__.py: -------------------------------------------------------------------------------- 1 | """ Broqer is a carefully crafted library to operate with continuous streams 2 | of data in a reactive style with publish/subscribe and broker functionality. 3 | """ 4 | 5 | from .error_handler import default_error_handler 6 | from .disposable import Disposable 7 | from .types import NONE 8 | from .publisher import Publisher, SubscriptionDisposable, SubscriptionError 9 | from .subscriber import Subscriber 10 | from .subscribers import (OnEmitFuture, Sink, Trace, build_sink, 11 | build_sink_factory, sink_property, SinkAsync, 12 | build_sink_async, build_sink_async_factory, 13 | sink_async_property) 14 | from .value import Value 15 | 16 | from .operator_overloading import apply_operator_overloading 17 | 18 | apply_operator_overloading() 19 | 20 | 21 | __author__ = 'Günther Jena' 22 | __email__ = 'guenther@jena.at' 23 | 24 | try: 25 | from ._version import version as __version__ # type: ignore 26 | except ImportError: 27 | __version__ = 'not available' 28 | 29 | __all__ = [ 30 | 'default_error_handler', 'Disposable', 'NONE', 'Publisher', 31 | 'SubscriptionDisposable', 'SubscriptionError', 'Subscriber', 32 | 'OnEmitFuture', 'Sink', 'Trace', 'build_sink', 'build_sink_factory', 33 | 'sink_property', 'Value', 'op', 'SinkAsync', 'build_sink_async', 34 | 'build_sink_async_factory', 'sink_async_property' 35 | ] 36 | -------------------------------------------------------------------------------- /broqer/coro_queue.py: -------------------------------------------------------------------------------- 1 | """ CoroQueue handles running a coroutine depending on a given mode 2 | """ 3 | import asyncio 4 | from collections import deque 5 | from enum import Enum 6 | from typing import Any, Deque, Optional, Tuple # noqa: F401 7 | from functools import partial 8 | 9 | from broqer import NONE 10 | 11 | 12 | def wrap_coro(coro, unpack, *args, **kwargs): 13 | """ building a coroutine receiving one argument and call it curried 14 | with *args and **kwargs and unpack it (if unpack is set) 15 | """ 16 | if unpack: 17 | async def _coro(value): 18 | return await coro(*args, *value, **kwargs) 19 | else: 20 | async def _coro(value): 21 | return await coro(*args, value, **kwargs) 22 | 23 | return _coro 24 | 25 | 26 | class AsyncMode(Enum): 27 | """ AyncMode defines how to act when an emit happens while an scheduled 28 | coroutine is running """ 29 | CONCURRENT = 1 # just run coroutines concurrent 30 | INTERRUPT = 2 # cancel running and call for new value 31 | QUEUE = 3 # queue the value(s) and call after coroutine is finished 32 | LAST = 4 # use last emitted value after coroutine is finished 33 | LAST_DISTINCT = 5 # like LAST but only when value has changed 34 | SKIP = 6 # skip values emitted during coroutine is running 35 | 36 | 37 | class CoroQueue: # pylint: disable=too-few-public-methods 38 | """ Schedules the running of a coroutine given on a mode 39 | :param coro: Coroutine to be scheduled 40 | :param mode: scheduling mode (see AsyncMode) 41 | """ 42 | def __init__(self, coro, mode=AsyncMode.CONCURRENT): 43 | self._coro = coro 44 | self._mode = mode 45 | 46 | # ._last_args is used for LAST_DISTINCT and keeps the last arguments 47 | self._last_args = None # type: Optional[Tuple] 48 | 49 | # ._task is the reference to a running coroutine encapsulated as task 50 | self._task = None # type: Optional[asyncio.Future] 51 | 52 | # queue is initialized with following sizes: 53 | # Mode: size: 54 | # QUEUE unlimited 55 | # LAST, LAST_DISTINCT 1 56 | # all others no queue used 57 | self._queue = \ 58 | None # type: Optional[Deque[Tuple[Tuple, asyncio.Future]]] 59 | 60 | if mode in (AsyncMode.QUEUE, AsyncMode.LAST, AsyncMode.LAST_DISTINCT): 61 | maxlen = (None if mode is AsyncMode.QUEUE else 1) 62 | self._queue = deque(maxlen=maxlen) 63 | 64 | def schedule(self, *args: Any) -> asyncio.Future: 65 | """ Schedule a coroutine run with the given arguments 66 | :param *args: variable length arguments 67 | """ 68 | future = asyncio.Future() # type: asyncio.Future 69 | 70 | # check if a coroutine is already running 71 | if self._task is not None: 72 | # append to queue if a queue is used in this mode 73 | if self._queue is not None: 74 | if self._queue.maxlen == 1 and len(self._queue) == 1: 75 | _, queued_future = self._queue.popleft() 76 | queued_future.set_result(NONE) 77 | 78 | self._queue.append((args, future)) 79 | return future 80 | 81 | # in SKIP mode just do nothin with this emit 82 | if self._mode is AsyncMode.SKIP: 83 | future.set_result(NONE) 84 | return future 85 | 86 | # cancel the task if INTERRUPT mode is used 87 | if self._mode is AsyncMode.INTERRUPT and not self._task.done(): 88 | self._task.cancel() 89 | 90 | # start the coroutine 91 | self._start_task(args, future) 92 | 93 | return future 94 | 95 | def _start_task(self, args: Tuple, future: asyncio.Future): 96 | """ Start the coroutine as task """ 97 | 98 | # when LAST_DISTINCT is used only start coroutine when value changed 99 | if self._mode is AsyncMode.LAST_DISTINCT and args == self._last_args: 100 | self._task = None 101 | future.set_result(NONE) 102 | return 103 | 104 | # store the value to be emitted for LAST_DISTINCT 105 | self._last_args = args 106 | 107 | # create a task out of it and add ._task_done as callback 108 | self._task = asyncio.ensure_future(self._coro(*args)) 109 | self._task.add_done_callback(partial(self._handle_done, future)) 110 | 111 | def _handle_done(self, result_future: asyncio.Future, task: asyncio.Task): 112 | try: 113 | result = task.result() 114 | except asyncio.CancelledError: # happend in INTERRUPT mode 115 | result_future.set_result(NONE) 116 | except Exception as exception: # pylint: disable=broad-except 117 | result_future.set_exception(exception) 118 | else: 119 | result_future.set_result(result) 120 | 121 | if self._queue: 122 | args, future = self._queue.popleft() 123 | 124 | # start the coroutine 125 | self._start_task(args, future) 126 | else: 127 | self._task = None 128 | -------------------------------------------------------------------------------- /broqer/disposable.py: -------------------------------------------------------------------------------- 1 | """ Implementation of abstract Disposable. 2 | """ 3 | from abc import ABCMeta, abstractmethod 4 | 5 | 6 | class Disposable(metaclass=ABCMeta): 7 | """ 8 | Implementation of the disposable pattern. A disposable is usually 9 | returned on resource allocation. Calling .dispose() on the returned 10 | disposable is freeing the resource. 11 | 12 | Note: Multiple calls to .dispose() have to be handled by the 13 | implementation. 14 | 15 | >>> class MyDisposable(Disposable): 16 | ... def dispose(self): 17 | ... print('DISPOSED') 18 | 19 | >>> with MyDisposable(): 20 | ... print('working') 21 | working 22 | DISPOSED 23 | """ 24 | @abstractmethod 25 | def dispose(self) -> None: 26 | """ .dispose() method has to be overwritten""" 27 | 28 | def __enter__(self): 29 | """ Called on entry of a new context """ 30 | return self 31 | 32 | def __exit__(self, _type, _value, _traceback): 33 | """ Called on exit of the context. .dispose() is called here """ 34 | self.dispose() 35 | -------------------------------------------------------------------------------- /broqer/error_handler.py: -------------------------------------------------------------------------------- 1 | """ Implementing DefaultErrorHandler. Object default_error_handler is used 2 | as global object to register a callbacks for exceptions in asynchronous 3 | operators, """ 4 | 5 | import logging 6 | 7 | 8 | log = logging.getLogger(__name__) 9 | 10 | 11 | def _default_error_callback(exc_type, exc_value, exc_traceback): 12 | """ Default error callback is printing traceback of the exception 13 | """ 14 | exc_info = (exc_type, exc_value, exc_traceback) 15 | log.warning('broqer catched exception', exc_info=exc_info) 16 | 17 | raise exc_value.with_traceback(exc_traceback) 18 | 19 | 20 | class DefaultErrorHandler: 21 | """ DefaultErrorHandler object is a callable which is calling a registered 22 | callback and is used for handling exceptions when asynchronous operators 23 | receiving an exception during .emit(). The callback can be registered via 24 | the .set(callback) method. The default callback is _default_error_callback 25 | which is dumping the traceback of the exception. 26 | """ 27 | def __init__(self): 28 | self._error_callback = _default_error_callback 29 | 30 | def __call__(self, exc_type, exc_value, exc_traceback): 31 | """ When calling the call will be forwarded to the registered 32 | callback """ 33 | self._error_callback(exc_type, exc_value, exc_traceback) 34 | 35 | def set(self, error_callback): 36 | """ Register a new callback 37 | 38 | :param error_callback: the callback to be registered 39 | """ 40 | self._error_callback = error_callback 41 | 42 | def reset(self): 43 | """ Reset to the default callback (dumping traceback) 44 | """ 45 | self._error_callback = _default_error_callback 46 | 47 | 48 | default_error_handler = DefaultErrorHandler() # pylint: disable=invalid-name 49 | -------------------------------------------------------------------------------- /broqer/op/__init__.py: -------------------------------------------------------------------------------- 1 | """ The op module contains all operators broqer offers """ 2 | 3 | # synchronous operators 4 | from broqer.op.combine_latest import CombineLatest, build_combine_latest 5 | from broqer.op.filter_ import Filter, EvalTrue, EvalFalse, build_filter, \ 6 | build_filter_factory 7 | from broqer.op.map_ import Map, build_map, build_map_factory 8 | from broqer.op.map_async import MapAsync, build_map_async, \ 9 | build_map_async_factory, AsyncMode 10 | from broqer.op.bitwise import BitwiseCombineLatest, map_bit 11 | from broqer.op.cache import Cache 12 | from broqer.op.throttle import Throttle 13 | 14 | # enable operator overloading 15 | from .py_operators import Str, Bool, Int, Float, Repr, Len, In, All, Any, \ 16 | BitwiseAnd, BitwiseOr, Not 17 | 18 | __all__ = [ 19 | 'CombineLatest', 'BitwiseCombineLatest', 20 | 'Filter', 'Map', 'EvalTrue', 'MapAsync', 'build_map_async', 'AsyncMode', 21 | 'EvalFalse', 'build_map', 'build_map_factory', 'build_combine_latest', 22 | 'build_filter', 'build_filter_factory', 'Str', 'Bool', 'Int', 23 | 'Float', 'Repr', 'map_bit', 'build_map_async_factory', 24 | 'Len', 'In', 'All', 'Any', 'BitwiseAnd', 'BitwiseOr', 'Not', 'Throttle', 25 | 'Cache' 26 | ] 27 | -------------------------------------------------------------------------------- /broqer/op/bitwise.py: -------------------------------------------------------------------------------- 1 | """ Enables accessing bits directly. Provides BitwiseCombineLatest to gather 2 | publishers to bit indicies and evaluate a value from them. 3 | 4 | map_bit builds a Publisher which is mapping to a specific bit on another 5 | Publisher. 6 | """ 7 | from typing import Any, Dict # noqa: F401 8 | 9 | # pylint: disable=cyclic-import 10 | from broqer import Publisher, Subscriber, NONE, SubscriptionDisposable 11 | from broqer.op import build_map_factory 12 | 13 | from broqer.operator import MultiOperator 14 | 15 | 16 | class BitwiseCombineLatest(MultiOperator): 17 | """ Bitwise combine the latest emit of multiple publishers and emit the 18 | combination. If a publisher is not emitting or is not defined for a bit, 19 | the init value will be used. 20 | 21 | :param bit_publisher_mapping: dictionary with bit index as key and source 22 | publisher as value 23 | :param init: optional init value used for undefined bits (or initial state) 24 | """ 25 | def __init__(self, publisher_bit_mapping: Dict, init: int = 0) -> None: 26 | MultiOperator.__init__(self, *publisher_bit_mapping) 27 | 28 | self._init = init 29 | self._missing = set(self._originators) 30 | self._publisher_bit_mapping = publisher_bit_mapping 31 | 32 | def subscribe(self, subscriber: 'Subscriber', 33 | prepend: bool = False) -> SubscriptionDisposable: 34 | disposable = MultiOperator.subscribe(self, subscriber, prepend) 35 | 36 | if self._missing: 37 | self._missing.clear() 38 | if self._state is NONE: 39 | Publisher.notify(self, self._init) 40 | else: 41 | Publisher.notify(self, self._state) 42 | 43 | return disposable 44 | 45 | def unsubscribe(self, subscriber: Subscriber) -> None: 46 | MultiOperator.unsubscribe(self, subscriber) 47 | if not self._subscriptions: 48 | self._missing.update(self._originators) 49 | self._state = NONE 50 | 51 | def get(self): 52 | if self._subscriptions: 53 | return self._state 54 | 55 | state = self._init 56 | 57 | for publisher, bit_index in self._publisher_bit_mapping.items(): 58 | value = publisher.get() 59 | 60 | if value is NONE: 61 | continue 62 | 63 | if value: 64 | state |= 1 << bit_index 65 | else: 66 | state &= ~(1 << bit_index) 67 | 68 | return state 69 | 70 | def emit(self, value: Any, who: Publisher) -> None: 71 | if all(who is not p for p in self._originators): 72 | raise ValueError('Emit from non assigned publisher') 73 | 74 | # remove source publisher from ._missing 75 | self._missing.discard(who) 76 | 77 | # evaluate 78 | bit_index = self._publisher_bit_mapping[who] 79 | 80 | if self._state is NONE: 81 | self._state = self._init 82 | 83 | if value: 84 | self._state |= 1 << bit_index 85 | else: 86 | self._state &= ~(1 << bit_index) 87 | 88 | if self._missing: 89 | return None 90 | 91 | return Publisher.notify(self, self._state) 92 | 93 | 94 | @build_map_factory() 95 | def map_bit(bit_index, value): 96 | """ Provide value of a specific bit """ 97 | return bool(value & (1 << bit_index)) 98 | -------------------------------------------------------------------------------- /broqer/op/cache.py: -------------------------------------------------------------------------------- 1 | """ 2 | Cache the latest emit - the result is suppressing multiple emits with the same 3 | value. Also initialization can be defined in the case the source publisher does 4 | not emit on subscription. 5 | 6 | Usage: 7 | 8 | >>> from broqer import Value, op, Sink 9 | >>> s = Value(1) 10 | 11 | >>> cached_publisher = s | op.Cache() 12 | >>> _disposable = cached_publisher.subscribe(Sink(print)) 13 | 1 14 | >>> s.emit(2) 15 | 2 16 | >>> s.emit(2) 17 | >>> _disposable.dispose() 18 | 19 | Using the initial value for cache: 20 | 21 | >>> from broqer import Value, op, Sink 22 | >>> s = Value() 23 | 24 | >>> cached_publisher = s | op.Cache(1) 25 | >>> _disposable = cached_publisher.subscribe(Sink(print)) 26 | 1 27 | >>> s.emit(1) 28 | >>> s.emit(2) 29 | 2 30 | >>> _disposable.dispose() 31 | """ 32 | from typing import Any 33 | 34 | from broqer import Publisher, NONE 35 | from broqer.publisher import ValueT 36 | from broqer.operator import Operator 37 | 38 | 39 | class Cache(Operator): 40 | """ Cache object applied to publisher (see Map) """ 41 | def __init__(self, init: Any = NONE) -> None: 42 | Operator.__init__(self) 43 | self._state = init 44 | 45 | def get(self) -> ValueT: 46 | if self._originator is None: 47 | raise ValueError('Operator is missing originator') 48 | 49 | return self._originator.get() 50 | 51 | def emit(self, value: ValueT, who: Publisher) -> None: 52 | if who is not self._originator: 53 | raise ValueError('Emit from non assigned publisher') 54 | 55 | if value != self._state: 56 | self._state = value 57 | return Publisher.notify(self, value) 58 | 59 | return None 60 | -------------------------------------------------------------------------------- /broqer/op/combine_latest.py: -------------------------------------------------------------------------------- 1 | """ 2 | >>> from broqer import Value, Sink, op 3 | >>> s1 = Value() 4 | >>> s2 = Value() 5 | 6 | >>> combination = op.CombineLatest(s1, s2) 7 | >>> disposable = combination.subscribe(Sink(print)) 8 | 9 | CombineLatest is only emitting, when all values are collected: 10 | 11 | >>> s1.emit(1) 12 | >>> s2.emit(2) 13 | (1, 2) 14 | >>> s2.emit(3) 15 | (1, 3) 16 | 17 | Subscribing to a CombineLatest with all values available is emitting the values 18 | immediate on subscription: 19 | 20 | >>> combination.subscribe(Sink(print, 'Second sink:')) 21 | Second sink: (1, 3) 22 | <...> 23 | 24 | """ 25 | from functools import wraps 26 | from typing import Any, Dict, MutableSequence, Callable # noqa: F401 27 | 28 | from broqer import Publisher, Subscriber, NONE 29 | 30 | from broqer.operator import MultiOperator 31 | 32 | 33 | class CombineLatest(MultiOperator): 34 | """ Combine the latest emit of multiple publishers and emit the combination 35 | 36 | :param publishers: source publishers 37 | :param map_: optional function to be called for evaluation of current state 38 | :param emit_on: publisher or list of publishers - only emitting result when 39 | emit comes from one of this list. If None, emit on any source 40 | publisher. 41 | :param emit_partial: if True, emit even if not all source publishers have a 42 | state. emit_partial should only be used if an emit_on publisher is 43 | defined. 44 | """ 45 | def __init__(self, *publishers: Publisher, map_: Callable[..., Any] = None, 46 | emit_on=None, emit_partial: bool = False) -> None: 47 | MultiOperator.__init__(self, *publishers) 48 | 49 | # ._partial_state is a list keeping the latest emitted values from 50 | # each publisher. Stateless publishers will always keep the NONE entry 51 | # in this list. 52 | self._partial_state = [ 53 | NONE for _ in publishers] # type: MutableSequence[Any] 54 | 55 | # ._missing is keeping a set of source publishers which are required to 56 | # emit a value. This set starts with all source publishers. Stateful 57 | # publishers are required, stateless publishers not (will be removed 58 | # in .subscribe). 59 | self._missing = set(publishers) 60 | 61 | # ._index is a lookup table to get the list index based on publisher 62 | self._index = \ 63 | {p: i for i, p in enumerate(publishers) 64 | } # type: Dict[Publisher, int] 65 | 66 | # .emit_on is a set of publishers. When a source publisher is emitting 67 | # and is not in this set the CombineLatest will not emit a value. 68 | # If emit_on is None all the publishers will be in the set. 69 | if isinstance(emit_on, Publisher): 70 | self._emit_on = (emit_on,) 71 | else: 72 | self._emit_on = emit_on 73 | 74 | assert emit_partial is False or emit_on is not None, \ 75 | 'emit_on must be defined if emit_partial is True' 76 | self._emit_partial = emit_partial 77 | 78 | self._map = map_ 79 | 80 | def unsubscribe(self, subscriber: Subscriber) -> None: 81 | MultiOperator.unsubscribe(self, subscriber) 82 | if not self._subscriptions: 83 | self._missing = set(self._originators) 84 | self._partial_state[:] = [NONE for _ in self._partial_state] 85 | 86 | def get(self): 87 | if self._subscriptions: 88 | return self._state 89 | 90 | values = tuple(p.get() for p in self._originators) 91 | 92 | if NONE in values: 93 | return NONE 94 | 95 | if not self._map: 96 | return tuple(values) 97 | 98 | return self._map(*values) 99 | 100 | def emit(self, value: Any, who: Publisher) -> None: 101 | if all(who is not p for p in self._originators): 102 | raise ValueError('Emit from non assigned publisher') 103 | 104 | # remove source publisher from ._missing 105 | self._missing.discard(who) 106 | 107 | index = self._index[who] 108 | 109 | # remember state of this source 110 | self._partial_state[index] = value 111 | 112 | # if emit_partial is False and emits from publishers are missing 113 | # or source of this emit is not one of emit_on -> don't evaluate 114 | # and notify subscribers 115 | 116 | if (self._emit_on is not None and 117 | all(who is not p for p in self._emit_on)) or \ 118 | (not self._emit_partial and self._missing): 119 | return None 120 | 121 | # evaluate 122 | if self._map: 123 | state = self._map(*self._partial_state) 124 | else: 125 | state = tuple(self._partial_state) 126 | 127 | # if result of _map() was NONE don't emit 128 | if state is NONE: 129 | return None 130 | 131 | self._state = state 132 | 133 | return Publisher.notify(self, state) 134 | 135 | 136 | def build_combine_latest(map_: Callable[..., Any] = None, *, emit_on=None, 137 | emit_partial: bool = False) -> Callable: 138 | """ Decorator to wrap a function to return a CombineLatest operator. 139 | 140 | :param emit_on: publisher or list of publishers - only emitting result when 141 | emit comes from one of this list. If None, emit on any source 142 | publisher. 143 | :param emit_partial: if True, emit even if not all source publishers have a 144 | state. emit_partial should only be used if an emit_on publisher is 145 | defined. 146 | """ 147 | def _build_combine_latest(map_: Callable[..., Any]): 148 | @wraps(map_) 149 | def _wrapper(*publishers) -> CombineLatest: 150 | return CombineLatest(*publishers, map_=map_, emit_on=emit_on, 151 | emit_partial=emit_partial) 152 | return _wrapper 153 | 154 | if map_: 155 | return _build_combine_latest(map_) 156 | 157 | return _build_combine_latest 158 | -------------------------------------------------------------------------------- /broqer/op/filter_.py: -------------------------------------------------------------------------------- 1 | """ 2 | Filters values based on a ``predicate`` function 3 | 4 | Usage: 5 | 6 | >>> from broqer import Value, op, Sink 7 | >>> s = Value() 8 | 9 | >>> filtered_publisher = s | op.Filter(lambda v:v>0) 10 | >>> _disposable = filtered_publisher.subscribe(Sink(print)) 11 | 12 | >>> s.emit(1) 13 | 1 14 | >>> s.emit(-1) 15 | >>> s.emit(0) 16 | >>> _disposable.dispose() 17 | 18 | Also possible with additional args and kwargs: 19 | 20 | >>> import operator 21 | >>> filtered_publisher = s | op.Filter(operator.and_, 0x01) 22 | >>> _disposable = filtered_publisher.subscribe(Sink(print)) 23 | >>> s.emit(100) 24 | >>> s.emit(101) 25 | 101 26 | 27 | """ 28 | from functools import partial, wraps 29 | from typing import Any, Callable 30 | 31 | from broqer import NONE, Publisher 32 | from broqer.operator import Operator 33 | 34 | 35 | class Filter(Operator): 36 | """ Filter object applied to publisher 37 | 38 | :param predicate: function to evaluate the filtering 39 | :param \\*args: variable arguments to be used for evaluating predicate 40 | :param unpack: value from emits will be unpacked (\\*value) 41 | :param \\*\\*kwargs: keyword arguments to be used for evaluating predicate 42 | """ 43 | def __init__(self, predicate: Callable[[Any], bool], *args, 44 | unpack: bool = False, **kwargs) -> None: 45 | Operator.__init__(self) 46 | self._predicate = partial(predicate, *args, **kwargs) # type: Callable 47 | self._unpack = unpack 48 | 49 | def get(self) -> Any: 50 | if self._originator is None: 51 | raise ValueError('Operator is missing originator') 52 | 53 | if self._subscriptions: 54 | return self._state 55 | 56 | value = self._originator.get() # type: Any 57 | 58 | if self._unpack: 59 | # assert isinstance(value, (list, tuple)) 60 | if self._predicate(*value): 61 | return value 62 | 63 | elif self._predicate(value): 64 | return value 65 | 66 | return NONE 67 | 68 | def emit(self, value: Any, who: Publisher) -> None: 69 | if who is not self._originator: 70 | raise ValueError('Emit from non assigned publisher') 71 | 72 | if self._unpack: 73 | if self._predicate(*value): 74 | return Publisher.notify(self, value) 75 | elif self._predicate(value): 76 | return Publisher.notify(self, value) 77 | return None 78 | 79 | 80 | class EvalTrue(Operator): 81 | """ Emits all values which evaluates for True. 82 | 83 | This operator can be used in the pipline style (v | EvalTrue()) or as 84 | standalone operation (EvalTrue(v)). 85 | """ 86 | def __init__(self, publisher: Publisher = None) -> None: 87 | Operator.__init__(self) 88 | self._originator = publisher 89 | 90 | def get(self) -> Any: 91 | if self._subscriptions: 92 | return self._state 93 | 94 | assert isinstance(self._originator, Publisher) 95 | 96 | value = self._originator.get() # type: Any 97 | 98 | if bool(value): 99 | return value 100 | 101 | return NONE 102 | 103 | def emit(self, value: Any, who: Publisher) -> None: 104 | if who is not self._originator: 105 | raise ValueError('Emit from non assigned publisher') 106 | 107 | if bool(value): 108 | return Publisher.notify(self, value) 109 | return None 110 | 111 | 112 | class EvalFalse(Operator): 113 | """ Filters all emits which evaluates for False. 114 | 115 | This operator can be used in the pipline style (v | EvalFalse() or as 116 | standalone operation (EvalFalse(v)).""" 117 | def __init__(self, publisher: Publisher = None) -> None: 118 | Operator.__init__(self) 119 | self._originator = publisher 120 | 121 | def get(self) -> Any: 122 | if self._subscriptions: 123 | return self._state 124 | 125 | assert isinstance(self._originator, Publisher) 126 | 127 | value = self._originator.get() # type: Any 128 | 129 | if not bool(value): 130 | return value 131 | 132 | return NONE 133 | 134 | def emit(self, value: Any, who: Publisher) -> None: 135 | if who is not self._originator: 136 | raise ValueError('Emit from non assigned publisher') 137 | 138 | if not bool(value): 139 | return Publisher.notify(self, value) 140 | return None 141 | 142 | 143 | def build_filter(predicate: Callable[[Any], bool] = None, *, 144 | unpack: bool = False): 145 | """ Decorator to wrap a function to return a Filter operator. 146 | 147 | :param function: function to be wrapped 148 | :param unpack: value from emits will be unpacked (*value) 149 | """ 150 | def _build_filter(predicate): 151 | return Filter(predicate, unpack=unpack) 152 | 153 | if predicate: 154 | return _build_filter(predicate) 155 | 156 | return _build_filter 157 | 158 | 159 | def build_filter_factory(predicate: Callable[[Any], bool] = None, *, 160 | unpack: bool = False): 161 | """ Decorator to wrap a function to return a factory for Filter operators. 162 | 163 | :param predicate: function to be wrapped 164 | :param unpack: value from emits will be unpacked (*value) 165 | """ 166 | def _build_filter(predicate: Callable[[Any], bool]): 167 | @wraps(predicate) 168 | def _wrapper(*args, **kwargs) -> Filter: 169 | if 'unpack' in kwargs: 170 | raise TypeError('"unpack" has to be defined by decorator') 171 | return Filter(predicate, *args, unpack=unpack, **kwargs) 172 | return _wrapper 173 | 174 | if predicate: 175 | return _build_filter(predicate) 176 | 177 | return _build_filter 178 | -------------------------------------------------------------------------------- /broqer/op/map_.py: -------------------------------------------------------------------------------- 1 | """ 2 | Apply ``function(*args, value, **kwargs)`` to each emitted value 3 | 4 | Usage: 5 | 6 | >>> from broqer import Value, op, Sink 7 | >>> s = Value() 8 | 9 | >>> mapped_publisher = s | op.Map(lambda v:v*2) 10 | >>> _disposable = mapped_publisher.subscribe(Sink(print)) 11 | 12 | >>> s.emit(1) 13 | 2 14 | >>> s.emit(-1) 15 | -2 16 | >>> s.emit(0) 17 | 0 18 | >>> _disposable.dispose() 19 | 20 | Also possible with additional args and kwargs: 21 | 22 | >>> import operator 23 | >>> mapped_publisher = s | op.Map(operator.add, 3) 24 | >>> _disposable = mapped_publisher.subscribe(Sink(print)) 25 | 3 26 | >>> s.emit(100) 27 | 103 28 | >>> _disposable.dispose() 29 | 30 | >>> _disposable = (s | op.Map(print, 'Output:')).subscribe(\ 31 | Sink(print, 'EMITTED')) 32 | Output: 100 33 | EMITTED None 34 | >>> s.emit(1) 35 | Output: 1 36 | EMITTED None 37 | """ 38 | from functools import partial, wraps 39 | from typing import Any, Callable 40 | 41 | from broqer import Publisher, NONE 42 | from broqer.publisher import ValueT 43 | from broqer.operator import Operator 44 | 45 | 46 | class Map(Operator): 47 | """ Map object applied to publisher 48 | 49 | :param function: function to be applied for each emit 50 | :param \\*args: variable arguments to be used for calling function 51 | :param unpack: value from emits will be unpacked (\\*value) 52 | :param \\*\\*kwargs: keyword arguments to be used for calling function 53 | """ 54 | def __init__(self, function: Callable[[Any], Any], *args, 55 | unpack: bool = False, **kwargs) -> None: 56 | """ Special care for return values: 57 | - return `None` (or nothing) if you don't want to return a result 58 | - return `None, ` if you want to return `None` 59 | - return `(a, b), ` to return a tuple as value 60 | - every other return value will be unpacked 61 | """ 62 | 63 | Operator.__init__(self) 64 | self._function = partial(function, *args, **kwargs) 65 | self._unpack = unpack 66 | 67 | def get(self) -> ValueT: 68 | if self._subscriptions: 69 | return self._state 70 | 71 | if self._originator is None: 72 | raise ValueError('Operator is missing originator') 73 | 74 | value = self._originator.get() # type: ValueT 75 | 76 | if value is NONE: 77 | return value 78 | 79 | if self._unpack: 80 | assert isinstance(value, (list, tuple)) 81 | return self._function(*value) 82 | 83 | return self._function(value) 84 | 85 | def emit(self, value: ValueT, who: Publisher) -> None: 86 | if who is not self._originator: 87 | raise ValueError('Emit from non assigned publisher') 88 | 89 | if self._unpack: 90 | assert isinstance(value, (list, tuple)) 91 | result = self._function(*value) 92 | else: 93 | result = self._function(value) 94 | 95 | if result is not NONE: 96 | return Publisher.notify(self, result) 97 | 98 | return None 99 | 100 | 101 | def build_map(function: Callable[..., None] = None, *, 102 | unpack: bool = False): 103 | """ Decorator to wrap a function to return a Map operator. 104 | 105 | :param function: function to be wrapped 106 | :param unpack: value from emits will be unpacked (*value) 107 | """ 108 | def _build_map(function): 109 | return Map(function, unpack=unpack) 110 | 111 | if function: 112 | return _build_map(function) 113 | 114 | return _build_map 115 | 116 | 117 | def build_map_factory(function: Callable[[Any], Any] = None, 118 | unpack: bool = False): 119 | """ Decorator to wrap a function to return a factory for Map operators. 120 | 121 | :param function: function to be wrapped 122 | :param unpack: value from emits will be unpacked (*value) 123 | """ 124 | def _build_map(function: Callable[[Any], Any]): 125 | @wraps(function) 126 | def _wrapper(*args, **kwargs) -> Map: 127 | if 'unpack' in kwargs: 128 | raise TypeError('"unpack" has to be defined by decorator') 129 | return Map(function, *args, unpack=unpack, **kwargs) 130 | return _wrapper 131 | 132 | if function: 133 | return _build_map(function) 134 | 135 | return _build_map 136 | -------------------------------------------------------------------------------- /broqer/op/map_async.py: -------------------------------------------------------------------------------- 1 | """ 2 | Apply ``coro`` to each emitted value allowing async processing 3 | 4 | Usage: 5 | 6 | >>> import asyncio 7 | >>> from broqer import Value, Sink, op 8 | >>> s = Value() 9 | 10 | >>> async def delay_add(a): 11 | ... print('Starting with argument', a) 12 | ... await asyncio.sleep(0.015) 13 | ... result = a + 1 14 | ... print('Finished with argument', a) 15 | ... return result 16 | 17 | AsyncMode: CONCURRENT (is default) 18 | 19 | >>> s.emit(0) 20 | >>> _d = (s | op.MapAsync(delay_add)).subscribe(Sink()) 21 | >>> s.emit(1) 22 | >>> asyncio.get_event_loop().run_until_complete(asyncio.sleep(0.02)) 23 | Starting with argument 0 24 | Starting with argument 1 25 | Finished with argument 0 26 | Finished with argument 1 27 | >>> _d.dispose() 28 | 29 | AsyncMode: INTERRUPT 30 | 31 | >>> s.emit(0) 32 | >>> o = (s | op.MapAsync(delay_add, mode=op.AsyncMode.INTERRUPT)) 33 | >>> _d = o.subscribe(Sink(print)) 34 | >>> asyncio.get_event_loop().run_until_complete(asyncio.sleep(0.005)) 35 | Starting with argument 0 36 | >>> s.emit(1) 37 | >>> asyncio.get_event_loop().run_until_complete(asyncio.sleep(0.02)) 38 | Starting with argument 1 39 | Finished with argument 1 40 | 2 41 | >>> _d.dispose() 42 | 43 | AsyncMode: QUEUE 44 | 45 | >>> s.emit(0) 46 | >>> o = (s | op.MapAsync(delay_add, mode=op.AsyncMode.QUEUE)) 47 | >>> _d = o.subscribe(Sink(print)) 48 | >>> s.emit(1) 49 | >>> asyncio.get_event_loop().run_until_complete(asyncio.sleep(0.04)) 50 | Starting with argument 0 51 | Finished with argument 0 52 | 1 53 | Starting with argument 1 54 | Finished with argument 1 55 | 2 56 | >>> _d.dispose() 57 | 58 | AsyncMode: LAST 59 | 60 | >>> s.emit(0) 61 | >>> o = (s | op.MapAsync(delay_add, mode=op.AsyncMode.LAST)) 62 | >>> _d = o.subscribe(Sink(print)) 63 | >>> s.emit(1) 64 | >>> s.emit(2) 65 | >>> asyncio.get_event_loop().run_until_complete(asyncio.sleep(0.04)) 66 | Starting with argument 0 67 | Finished with argument 0 68 | 1 69 | Starting with argument 2 70 | Finished with argument 2 71 | 3 72 | >>> _d.dispose() 73 | 74 | AsyncMode: SKIP 75 | 76 | >>> s.emit(0) 77 | >>> o = (s | op.MapAsync(delay_add, mode=op.AsyncMode.SKIP)) 78 | >>> _d = o.subscribe(Sink(print)) 79 | >>> s.emit(1) 80 | >>> s.emit(2) 81 | >>> asyncio.get_event_loop().run_until_complete(asyncio.sleep(0.04)) 82 | Starting with argument 0 83 | Finished with argument 0 84 | 1 85 | >>> _d.dispose() 86 | 87 | Using error_callback: 88 | 89 | >>> def cb(*e): 90 | ... print('Got error') 91 | 92 | >>> s.emit('abc') 93 | >>> _d = (s | op.MapAsync(delay_add, error_callback=cb)).subscribe(Sink(print)) 94 | >>> asyncio.get_event_loop().run_until_complete(asyncio.sleep(0.02)) 95 | Starting with argument abc 96 | Got error 97 | >>> _d.dispose() 98 | """ 99 | import asyncio 100 | import sys 101 | from functools import wraps 102 | from typing import Any # noqa: F401 103 | 104 | # pylint: disable=cyclic-import 105 | from broqer.operator import Operator 106 | from broqer.coro_queue import CoroQueue, AsyncMode, wrap_coro 107 | from broqer import Publisher, default_error_handler, NONE 108 | 109 | 110 | class MapAsync(Operator): # pylint: disable=too-many-instance-attributes 111 | """ Apply ``coro(*args, value, **kwargs)`` to each emitted value allow 112 | async processing. 113 | 114 | :param coro: coroutine to be applied on emit 115 | :param \\*args: variable arguments to be used for calling coro 116 | :param mode: behavior when a value is currently processed 117 | :param error_callback: error callback to be registered 118 | :param unpack: value from emits will be unpacked as (\\*value) 119 | :param \\*\\*kwargs: keyword arguments to be used for calling coro 120 | 121 | :ivar scheduled: Publisher emitting the value when coroutine is actually 122 | started. 123 | """ 124 | def __init__(self, 125 | coro, *args, mode=AsyncMode.CONCURRENT, 126 | error_callback=default_error_handler, unpack: bool = False, 127 | **kwargs 128 | ) -> None: 129 | Operator.__init__(self) 130 | _coro = wrap_coro(coro, unpack, *args, **kwargs) 131 | self._coro_queue = CoroQueue(_coro, mode=mode) 132 | self._error_callback = error_callback 133 | 134 | def emit(self, value: Any, who: Publisher) -> None: 135 | if who is not self._originator: 136 | raise ValueError('Emit from non assigned publisher') 137 | 138 | future = self._coro_queue.schedule(value) 139 | future.add_done_callback(self._done) 140 | 141 | def _done(self, future: asyncio.Future): 142 | try: 143 | result = future.result() 144 | except Exception: # pylint: disable=broad-except 145 | self._error_callback(*sys.exc_info()) 146 | else: 147 | if result != NONE: 148 | Publisher.notify(self, result) 149 | 150 | 151 | def build_map_async(coro=None, *, 152 | mode: AsyncMode = AsyncMode.CONCURRENT, 153 | error_callback=default_error_handler, 154 | unpack: bool = False): 155 | """ Decorator to wrap a function to return a Map operator. 156 | 157 | :param coro: coroutine to be wrapped 158 | :param mode: behavior when a value is currently processed 159 | :param error_callback: error callback to be registered 160 | :param unpack: value from emits will be unpacked (*value) 161 | """ 162 | def _build_map_async(coro): 163 | return MapAsync(coro, mode=mode, error_callback=error_callback, 164 | unpack=unpack) 165 | 166 | if coro: 167 | return _build_map_async(coro) 168 | 169 | return _build_map_async 170 | 171 | 172 | def build_map_async_factory(coro=None, *, 173 | mode: AsyncMode = AsyncMode.CONCURRENT, 174 | error_callback=default_error_handler, 175 | unpack: bool = False): 176 | """ Decorator to wrap a coroutine to return a factory for MapAsync 177 | operators. 178 | 179 | :param coro: coroutine to be wrapped 180 | :param mode: behavior when a value is currently processed 181 | :param error_callback: error callback to be registered 182 | :param unpack: value from emits will be unpacked (*value) 183 | """ 184 | _mode = mode 185 | 186 | def _build_map_async(coro): 187 | @wraps(coro) 188 | def _wrapper(*args, mode=None, **kwargs) -> MapAsync: 189 | if ('unpack' in kwargs) or ('error_callback' in kwargs): 190 | raise TypeError('"unpack" and "error_callback" has to ' 191 | 'be defined by decorator') 192 | if mode is None: 193 | mode = _mode 194 | return MapAsync(coro, *args, mode=mode, unpack=unpack, 195 | error_callback=error_callback, **kwargs) 196 | return _wrapper 197 | 198 | if coro: 199 | return _build_map_async(coro) 200 | 201 | return _build_map_async 202 | -------------------------------------------------------------------------------- /broqer/op/py_operators.py: -------------------------------------------------------------------------------- 1 | """ Python operators """ 2 | from typing import Any as Any_ 3 | from functools import partial, reduce 4 | import operator 5 | 6 | from broqer import Publisher 7 | from broqer.operator_overloading import MapUnary 8 | from .combine_latest import CombineLatest 9 | 10 | 11 | class Str(MapUnary): 12 | """ Implementing the functionality of str() for publishers. 13 | 14 | Usage: 15 | >>> from broqer import op, Value 16 | >>> num = Value(0) 17 | >>> literal = op.Str(num) 18 | >>> literal.get() 19 | '0' 20 | """ 21 | def __init__(self, publisher: Publisher) -> None: 22 | MapUnary.__init__(self, publisher, str) 23 | 24 | 25 | class Bool(MapUnary): 26 | """ Implementing the functionality of bool() for publishers. """ 27 | def __init__(self, publisher: Publisher) -> None: 28 | MapUnary.__init__(self, publisher, bool) 29 | 30 | 31 | class Not(MapUnary): 32 | """ Implementing the functionality of not for publishers. """ 33 | def __init__(self, publisher: Publisher) -> None: 34 | MapUnary.__init__(self, publisher, operator.not_) 35 | 36 | 37 | class Int(MapUnary): 38 | """ Implementing the functionality of int() for publishers. """ 39 | def __init__(self, publisher: Publisher) -> None: 40 | MapUnary.__init__(self, publisher, int) 41 | 42 | 43 | class Float(MapUnary): 44 | """ Implementing the functionality of float() for publishers. """ 45 | def __init__(self, publisher: Publisher) -> None: 46 | MapUnary.__init__(self, publisher, float) 47 | 48 | 49 | class Repr(MapUnary): 50 | """ Implementing the functionality of repr() for publishers. """ 51 | def __init__(self, publisher: Publisher) -> None: 52 | MapUnary.__init__(self, publisher, repr) 53 | 54 | 55 | class Len(MapUnary): 56 | """ Implementing the functionality of len() for publishers. """ 57 | def __init__(self, publisher: Publisher) -> None: 58 | MapUnary.__init__(self, publisher, len) 59 | 60 | 61 | def _in(item, container): 62 | return item in container 63 | 64 | 65 | class In(CombineLatest): 66 | """ Implementing the functionality of ``in`` operator for publishers. 67 | :param item: publisher or constant to check for availability in container. 68 | :param container: container (publisher or constant) 69 | """ 70 | def __init__(self, item: Any_, container: Any_) -> None: 71 | if isinstance(item, Publisher) and isinstance(container, Publisher): 72 | CombineLatest.__init__(self, item, container, map_=_in) 73 | elif isinstance(item, Publisher): 74 | function = partial(_in, container=container) 75 | CombineLatest.__init__(self, item, map_=function) 76 | else: 77 | if not isinstance(container, Publisher): 78 | raise TypeError('Item or container has to be a publisher') 79 | function = partial(_in, item) 80 | CombineLatest.__init__(self, container, map_=function) 81 | 82 | 83 | def _all(*items): 84 | return all(items) 85 | 86 | 87 | class All(CombineLatest): 88 | """ Implement the functionality of ``all`` operator for publishers. 89 | One big difference is that ``all`` takes an iterator and ``All`` take a 90 | variable amount of publishers as arguments. 91 | :param publishers: Publishers evaluated for all to be True 92 | """ 93 | def __init__(self, *publishers: Any_) -> None: 94 | CombineLatest.__init__(self, *publishers, map_=_all) 95 | 96 | 97 | def _any(*items): 98 | return any(items) 99 | 100 | 101 | class Any(CombineLatest): 102 | """ Implement the functionality of ``any`` operator for publishers. 103 | One big difference is that ``any`` takes an iterator and ``Any`` take a 104 | variable amount of publishers as arguments. 105 | :param publishers: Publishers evaluated for one to be True 106 | """ 107 | def __init__(self, *publishers: Any_) -> None: 108 | CombineLatest.__init__(self, *publishers, map_=_any) 109 | 110 | 111 | def _bitwise_or(*items): 112 | return reduce(operator.or_, items) 113 | 114 | 115 | class BitwiseOr(CombineLatest): 116 | """ Implement the functionality of bitwise or (``|``) operator for 117 | publishers. 118 | :param publishers: Publishers evaluated for bitwise or 119 | """ 120 | def __init__(self, *publishers: Any_) -> None: 121 | CombineLatest.__init__(self, *publishers, map_=_bitwise_or) 122 | 123 | 124 | def _bitwise_and(*items): 125 | return reduce(operator.and_, items) 126 | 127 | 128 | class BitwiseAnd(CombineLatest): 129 | """ Implement the functionality of bitwise and (``&``) operator for 130 | publishers. 131 | :param publishers: Publishers evaluated for bitwise and 132 | """ 133 | def __init__(self, *publishers: Any_) -> None: 134 | CombineLatest.__init__(self, *publishers, map_=_bitwise_and) 135 | -------------------------------------------------------------------------------- /broqer/op/throttle.py: -------------------------------------------------------------------------------- 1 | """ 2 | Rate limit emits by the given time. 3 | Usage: 4 | >>> import asyncio 5 | >>> from broqer import Value, op, Sink 6 | >>> v = Value() 7 | >>> throttle_publisher = v | op.Throttle(0.1) 8 | >>> _d = throttle_publisher.subscribe(Sink(print)) 9 | >>> v.emit(1) 10 | 1 11 | >>> v.emit(2) 12 | >>> asyncio.get_event_loop().run_until_complete(asyncio.sleep(0.05)) 13 | >>> v.emit(3) 14 | >>> asyncio.get_event_loop().run_until_complete(asyncio.sleep(0.2)) 15 | 3 16 | >>> # It's also possible to reset the throttling duration: 17 | >>> v.emit(4) 18 | 4 19 | >>> v.emit(5) 20 | >>> asyncio.get_event_loop().run_until_complete(asyncio.sleep(0.05)) 21 | >>> throttle_publisher.reset() 22 | """ 23 | import asyncio 24 | import sys 25 | from typing import Any # noqa: F401 26 | 27 | from broqer import Publisher, default_error_handler, NONE 28 | 29 | from broqer.operator import Operator 30 | from broqer.timer import Timer 31 | 32 | 33 | class Throttle(Operator): 34 | """ Rate limit emits by the given time. 35 | 36 | :param duration: time for throttling in seconds 37 | :param error_callback: the error callback to be registered 38 | :param loop: asyncio event loop to use 39 | """ 40 | def __init__(self, duration: float, 41 | error_callback=default_error_handler, loop=None) -> None: 42 | 43 | Operator.__init__(self) 44 | 45 | if duration < 0: 46 | raise ValueError('Duration has to be bigger than zero') 47 | 48 | self._duration = duration 49 | self._loop = loop or asyncio.get_event_loop() 50 | self._timer = Timer(self._delayed_emit_cb, loop=loop) 51 | self._error_callback = error_callback 52 | 53 | def get(self): 54 | return Publisher.get(self) 55 | 56 | def emit(self, value: Any, who: Publisher) -> None: 57 | if who is not self._originator: 58 | raise ValueError('Emit from non assigned publisher') 59 | 60 | if not self._timer.is_running(): 61 | self._timer.start(timeout=0, args=(value,)) 62 | else: 63 | self._timer.change_arguments(args=(value,)) 64 | 65 | def _delayed_emit_cb(self, value=NONE): 66 | if value is NONE: 67 | # since the last emit the given duration has passed without another 68 | # emit 69 | return 70 | 71 | try: 72 | Publisher.notify(self, value) 73 | except Exception: # pylint: disable=broad-except 74 | self._error_callback(*sys.exc_info()) 75 | 76 | self._timer.start(self._duration) 77 | 78 | def reset(self): 79 | """ Reseting duration for throttling """ 80 | self._timer.cancel() 81 | -------------------------------------------------------------------------------- /broqer/operator.py: -------------------------------------------------------------------------------- 1 | """ Module implementing Operator, MultiOperator. 2 | """ 3 | import typing 4 | from abc import abstractmethod 5 | 6 | # pylint: disable=cyclic-import 7 | from broqer import Publisher, SubscriptionDisposable, Subscriber 8 | from broqer.publisher import ValueT 9 | 10 | 11 | class Operator(Publisher, Subscriber): 12 | """ Base class for operators depending on a single publisher. This 13 | publisher will be subscribed as soon as this operator is subscribed the 14 | first time. 15 | 16 | On unsubscription of the last subscriber the dependent publisher will also 17 | be unsubscripted. 18 | """ 19 | def __init__(self) -> None: 20 | Publisher.__init__(self) 21 | Subscriber.__init__(self) 22 | self._originator = None # type: typing.Optional[Publisher] 23 | 24 | @property 25 | def originator(self): 26 | """ Property returning originator publisher """ 27 | return self._originator 28 | 29 | @originator.setter 30 | def originator(self, publisher: Publisher): 31 | """ Setter for originator """ 32 | if self._originator is not None: 33 | raise TypeError('Operator already assigned to originator') 34 | 35 | self._originator = publisher 36 | self.add_dependencies(publisher) 37 | if self._subscriptions: 38 | self._originator.subscribe(self) 39 | 40 | def subscribe(self, subscriber: 'Subscriber', 41 | prepend: bool = False) -> SubscriptionDisposable: 42 | disposable = Publisher.subscribe(self, subscriber, prepend) 43 | 44 | if len(self._subscriptions) == 1 and self._originator is not None: 45 | # if this was the first subscription 46 | self._originator.subscribe(self) 47 | 48 | return disposable 49 | 50 | def unsubscribe(self, subscriber: Subscriber) -> None: 51 | Publisher.unsubscribe(self, subscriber) 52 | 53 | if not self._subscriptions and self._originator is not None: 54 | self._originator.unsubscribe(self) 55 | Publisher.reset_state(self) 56 | 57 | def notify(self, value: ValueT) -> None: 58 | raise ValueError('Operator doesn\'t support .notify()') 59 | 60 | @abstractmethod 61 | def emit(self, value: typing.Any, who: Publisher) -> None: 62 | """ Send new value to the operator 63 | :param value: value to be send 64 | :param who: reference to which publisher is emitting 65 | """ 66 | 67 | 68 | class MultiOperator(Publisher, Subscriber): 69 | """ Base class for operators depending on multiple publishers. Like 70 | Operator all publishers will be subscribed on first subscription to this 71 | operator. Accordingly all publishers get unsubscribed on unsubscription 72 | of the last subscriber. 73 | """ 74 | def __init__(self, *publishers: Publisher) -> None: 75 | Publisher.__init__(self) 76 | Subscriber.__init__(self) 77 | self._originators = publishers 78 | self.add_dependencies(*publishers) 79 | 80 | def subscribe(self, subscriber: 'Subscriber', 81 | prepend: bool = False) -> SubscriptionDisposable: 82 | disposable = Publisher.subscribe(self, subscriber, prepend) 83 | 84 | if len(self._subscriptions) == 1: # if this was the first subscription 85 | for publisher in self._originators: 86 | # subscribe to all dependent publishers 87 | publisher.subscribe(self) 88 | 89 | return disposable 90 | 91 | def unsubscribe(self, subscriber: Subscriber) -> None: 92 | Publisher.unsubscribe(self, subscriber) 93 | if not self._subscriptions: 94 | for publisher in self._originators: 95 | publisher.unsubscribe(self) 96 | Publisher.reset_state(self) 97 | 98 | def notify(self, value: ValueT) -> None: 99 | raise ValueError('Operator doesn\'t support .notify()') 100 | 101 | def emit(self, value: typing.Any, who: Publisher) -> None: 102 | """ Send new value to the operator 103 | :param value: value to be send 104 | :param who: reference to which publisher is emitting 105 | """ 106 | raise NotImplementedError('.emit not implemented') 107 | -------------------------------------------------------------------------------- /broqer/operator_overloading.py: -------------------------------------------------------------------------------- 1 | """ This module enables the operator overloading of publishers """ 2 | import math 3 | import operator 4 | from typing import Any as Any_ 5 | 6 | # pylint: disable=cyclic-import 7 | from broqer import Publisher 8 | from broqer.operator import Operator 9 | 10 | 11 | class MapConstant(Operator): 12 | """ MapConstant TODO Docstring """ 13 | def __init__(self, publisher: Publisher, value, operation) -> None: 14 | Operator.__init__(self) 15 | self.originator = publisher 16 | self._value = value 17 | self._operation = operation 18 | 19 | if publisher.inherited_type is not None: 20 | self.inherit_type(publisher.inherited_type) 21 | 22 | def get(self): 23 | return self._operation(self._originator.get(), self._value) 24 | 25 | def emit(self, value: Any_, who: Publisher) -> None: 26 | if who is not self._originator: 27 | raise ValueError('Emit from non assigned publisher') 28 | 29 | result = self._operation(value, self._value) 30 | 31 | return Publisher.notify(self, result) 32 | 33 | 34 | class MapConstantReverse(Operator): 35 | """ MapConstantReverse TODO """ 36 | def __init__(self, publisher: Publisher, value, operation) -> None: 37 | Operator.__init__(self) 38 | self.originator = publisher 39 | self._value = value 40 | self._operation = operation 41 | 42 | if publisher.inherited_type is not None: 43 | self.inherit_type(publisher.inherited_type) 44 | 45 | def get(self): 46 | return self._operation(self._value, self._originator.get()) 47 | 48 | def emit(self, value: Any_, who: Publisher) -> None: 49 | if who is not self._originator: 50 | raise ValueError('Emit from non assigned publisher') 51 | 52 | result = self._operation(self._value, value) 53 | 54 | return Publisher.notify(self, result) 55 | 56 | 57 | class MapUnary(Operator): 58 | """ MapUnary TODO """ 59 | def __init__(self, publisher: Publisher, operation) -> None: 60 | Operator.__init__(self) 61 | self.originator = publisher 62 | self._operation = operation 63 | 64 | if publisher.inherited_type is not None: 65 | self.inherit_type(publisher.inherited_type) 66 | 67 | def get(self): 68 | return self._operation(self._originator.get()) 69 | 70 | def emit(self, value: Any_, who: Publisher) -> None: 71 | if who is not self._originator: 72 | raise ValueError('Emit from non assigned publisher') 73 | 74 | result = self._operation(value) 75 | 76 | return Publisher.notify(self, result) 77 | 78 | 79 | class _GetAttr(Operator): 80 | def __init__(self, publisher: Publisher, attribute_name) -> None: 81 | Operator.__init__(self) 82 | self.originator = publisher 83 | self._attribute_name = attribute_name 84 | self._args = None 85 | self._kwargs = None 86 | 87 | self.inherit_type(publisher.inherited_type) 88 | 89 | def get(self): 90 | value = self._originator.get() # may raise ValueError 91 | attribute = getattr(value, self._attribute_name) 92 | if self._args is None: 93 | return attribute 94 | return attribute(*self._args, **self._kwargs) 95 | 96 | def __call__(self, *args, **kwargs): 97 | self._args = args 98 | self._kwargs = kwargs 99 | return self 100 | 101 | def emit(self, value: Any_, who: Publisher) -> None: 102 | if who is not self._originator: 103 | raise ValueError('Emit from non assigned publisher') 104 | 105 | attribute = getattr(value, self._attribute_name) 106 | 107 | if self._args is None: 108 | return Publisher.notify(self, attribute) 109 | 110 | return Publisher.notify(self, attribute(*self._args, **self._kwargs)) 111 | 112 | 113 | def apply_operator_overloading(): 114 | """ Function to apply operator overloading to Publisher class """ 115 | # operator overloading is (unfortunately) not working for the following 116 | # cases: 117 | # int, float, str - should return appropriate type instead of a Publisher 118 | # len - should return an integer 119 | # 'x in y' - is using __bool__ which is not working with Publisher 120 | for method in ( 121 | '__lt__', '__le__', '__eq__', '__ne__', '__ge__', '__gt__', 122 | '__add__', '__and__', '__lshift__', '__mod__', '__mul__', 123 | '__pow__', '__rshift__', '__sub__', '__xor__', '__concat__', 124 | '__getitem__', '__floordiv__', '__truediv__'): 125 | def _op(operand_left, operand_right, operation=method): 126 | if isinstance(operand_right, Publisher): 127 | from broqer import op # pylint: disable=C0415 128 | return op.CombineLatest(operand_left, operand_right, 129 | map_=getattr(operator, 130 | operation)) 131 | return MapConstant(operand_left, operand_right, 132 | getattr(operator, operation)) 133 | 134 | setattr(Publisher, method, _op) 135 | 136 | for method, _method in ( 137 | ('__radd__', '__add__'), ('__rand__', '__and__'), 138 | ('__rlshift__', '__lshift__'), ('__rmod__', '__mod__'), 139 | ('__rmul__', '__mul__'), ('__rpow__', '__pow__'), 140 | ('__rrshift__', '__rshift__'), ('__rsub__', '__sub__'), 141 | ('__rxor__', '__xor__'), ('__rfloordiv__', '__floordiv__'), 142 | ('__rtruediv__', '__truediv__')): 143 | def _op(operand_left, operand_right, operation=_method): 144 | return MapConstantReverse(operand_left, operand_right, 145 | getattr(operator, operation)) 146 | 147 | setattr(Publisher, method, _op) 148 | 149 | for method, _method in ( 150 | ('__neg__', operator.neg), ('__pos__', operator.pos), 151 | ('__abs__', operator.abs), ('__invert__', operator.invert), 152 | ('__round__', round), ('__trunc__', math.trunc), 153 | ('__floor__', math.floor), ('__ceil__', math.ceil)): 154 | def _op_unary(operand, operation=_method): 155 | return MapUnary(operand, operation) 156 | 157 | setattr(Publisher, method, _op_unary) 158 | 159 | def _getattr(publisher, attribute_name): 160 | if not publisher.inherited_type or \ 161 | not hasattr(publisher.inherited_type, attribute_name): 162 | raise AttributeError(f'Attribute {attribute_name!r} not found') 163 | return _GetAttr(publisher, attribute_name) 164 | 165 | setattr(Publisher, '__getattr__', _getattr) 166 | -------------------------------------------------------------------------------- /broqer/publisher.py: -------------------------------------------------------------------------------- 1 | """ Implementing Publisher """ 2 | import sys 3 | from typing import (TYPE_CHECKING, TypeVar, Type, Tuple, Callable, Optional, 4 | overload) 5 | 6 | from broqer import NONE, Disposable, default_error_handler 7 | import broqer 8 | 9 | if TYPE_CHECKING: 10 | # pylint: disable=cyclic-import 11 | from typing import List 12 | from broqer import Subscriber 13 | from broqer.operator import Operator 14 | 15 | 16 | class SubscriptionError(ValueError): 17 | """ Special exception raised when subscription is failing (subscriber 18 | already subscribed) or on unsubscribe when subscriber is not subscribed 19 | """ 20 | 21 | 22 | ValueT = TypeVar('ValueT') # Type of publisher state and emitted value 23 | SubscriptionCBT = Callable[[bool], None] 24 | 25 | 26 | class Publisher: 27 | """ In broqer a subscriber can subscribe to a publisher. After subscription 28 | the subscriber is notified about emitted values from the publisher ( 29 | starting with the current state). In other frameworks 30 | *publisher*/*subscriber* are referenced as *observable*/*observer*. 31 | 32 | broqer.NONE is used as default initialisation. .get() will always 33 | return the internal state (even when it's broqer.NONE). .subscribe() will 34 | emit the actual state to the new subscriber only if it is something else 35 | than broqer.NONE . 36 | 37 | To receive information use following methods to interact with Publisher: 38 | 39 | - ``.subscribe(subscriber)`` to subscribe for events on this publisher 40 | - ``.unsubscribe(subscriber)`` to unsubscribe 41 | - ``.get()`` to get the current state 42 | 43 | When implementing a Publisher use the following methods: 44 | 45 | - ``.notify(value)`` calls .emit(value) on all subscribers 46 | 47 | :param init: the initial state. 48 | 49 | :ivar _state: state of the publisher 50 | :ivar _inherited_type: type class for method lookup 51 | :ivar _subscriptions: holding a list of subscribers 52 | :ivar _on_subscription_cb: callback with boolean as argument, telling 53 | if at least one subscription exists 54 | :ivar _dependencies: list with publishers this publisher is (directly or 55 | indirectly) dependent on. 56 | """ 57 | @overload # noqa: F811 58 | def __init__(self, *, type_: Type[ValueT] = None): 59 | pass 60 | 61 | @overload # noqa: F811 62 | def __init__(self, init: ValueT, type_: Type[ValueT] = None): # noqa: F811 63 | pass 64 | 65 | def __init__(self, init=NONE, type_=None): # noqa: F811 66 | self._state = init 67 | 68 | if type_: 69 | self._inherited_type = type_ # type: Optional[Type] 70 | elif init is not NONE: 71 | self._inherited_type = type(init) 72 | else: 73 | self._inherited_type = None 74 | 75 | self._subscriptions = [] # type: List[Subscriber] 76 | self._on_subscription_cb = None # type: Optional[SubscriptionCBT] 77 | self._dependencies = () # type: Tuple[Publisher, ...] 78 | 79 | def subscribe(self, subscriber: 'Subscriber', 80 | prepend: bool = False) -> 'SubscriptionDisposable': 81 | """ Subscribing the given subscriber. 82 | 83 | :param subscriber: subscriber to add 84 | :param prepend: For internal use - usually the subscribers will be 85 | added at the end of a list. When prepend is True, it will be added 86 | in front of the list. This will habe an effect in the order the 87 | subscribers are called. 88 | :raises SubscriptionError: if subscriber already subscribed 89 | """ 90 | 91 | # `subscriber in self._subscriptions` is not working because 92 | # tuple.__contains__ is using __eq__ which is overwritten and returns 93 | # a new publisher - not helpful here 94 | if any(subscriber is s for s in self._subscriptions): 95 | raise SubscriptionError('Subscriber already registered') 96 | 97 | if not self._subscriptions and self._on_subscription_cb: 98 | self._on_subscription_cb(True) 99 | 100 | if prepend: 101 | self._subscriptions.insert(0, subscriber) 102 | else: 103 | self._subscriptions.append(subscriber) 104 | 105 | disposable_obj = SubscriptionDisposable(self, subscriber) 106 | 107 | if self._state is not NONE: 108 | subscriber.emit(self._state, who=self) 109 | 110 | return disposable_obj 111 | 112 | def unsubscribe(self, subscriber: 'Subscriber') -> None: 113 | """ Unsubscribe the given subscriber 114 | 115 | :param subscriber: subscriber to unsubscribe 116 | :raises SubscriptionError: if subscriber is not subscribed (anymore) 117 | """ 118 | # here is a special implementation which is replacing the more 119 | # obvious one: self._subscriptions.remove(subscriber) - this will not 120 | # work because list.remove(x) is doing comparison for equality. 121 | # Applied to publishers this will return another publisher instead of 122 | # a boolean result 123 | for i, _s in enumerate(self._subscriptions): 124 | if _s is subscriber: 125 | self._subscriptions.pop(i) 126 | 127 | if not self._subscriptions and self._on_subscription_cb: 128 | self._on_subscription_cb(False) 129 | 130 | return 131 | 132 | raise SubscriptionError('Subscriber is not registered') 133 | 134 | def get(self) -> ValueT: 135 | """ Return the state of the publisher. """ 136 | return self._state 137 | 138 | def notify(self, value: ValueT) -> None: 139 | """ Calling .emit(value) on all subscribers and store state. 140 | 141 | :param value: value to be emitted to subscribers 142 | """ 143 | self._state = value 144 | for subscriber in tuple(self._subscriptions): 145 | try: 146 | subscriber.emit(value, who=self) 147 | except Exception: # pylint: disable=broad-except 148 | default_error_handler(*sys.exc_info()) 149 | 150 | def reset_state(self) -> None: 151 | """ Resets the state. Calling this method will not trigger a 152 | notification, but will call .reset_state for all subscribers 153 | 154 | """ 155 | self._state = NONE 156 | for subscriber in tuple(self._subscriptions): 157 | try: 158 | subscriber.reset_state() 159 | except Exception: # pylint: disable=broad-except 160 | default_error_handler(*sys.exc_info()) 161 | 162 | @property 163 | def subscriptions(self) -> Tuple['Subscriber', ...]: 164 | """ Property returning a tuple with all current subscribers """ 165 | return tuple(self._subscriptions) 166 | 167 | def register_on_subscription_callback(self, 168 | callback: SubscriptionCBT) -> None: 169 | """ This callback will be called, when the subscriptions are changing. 170 | When a subscription is done and no subscription was present the 171 | callback is called with True as argument. When after unsubscribe no 172 | subscription is left, it will be called with False. 173 | 174 | :param callback: callback(subscription: bool) to be called. 175 | when `callback` is None the callback will be reset 176 | :raises ValueError: when a callback is already registered 177 | """ 178 | if callback is None: 179 | self._on_subscription_cb = None 180 | return 181 | 182 | if self._on_subscription_cb is not None: 183 | raise ValueError('A callback is already registered') 184 | 185 | self._on_subscription_cb = callback 186 | if self._subscriptions: 187 | callback(True) 188 | 189 | def __await__(self): 190 | """ Makes publisher awaitable. When publisher has a state it will 191 | immediatly return its state as result. Otherwise it will wait forever 192 | until it will change its state. 193 | """ 194 | future = self.as_future(timeout=None, omit_subscription=False) 195 | return future.__await__() 196 | 197 | def as_future(self, timeout: float, omit_subscription: bool = True, 198 | loop=None): 199 | """ Returns a asyncio.Future which will be done on first change of this 200 | publisher. 201 | 202 | :param timeout: timeout in seconds. Use None for infinite waiting 203 | :param omit_subscription: if True the first emit (which can be on the 204 | subscription) will be ignored. 205 | :param loop: asyncio loop to be used 206 | :returns: a future returning the emitted value 207 | """ 208 | return broqer.OnEmitFuture(self, timeout, omit_subscription, loop) 209 | 210 | def __bool__(self): 211 | """ A new Publisher is the result of a comparision between a publisher 212 | and something else (may also be a second publisher). This result should 213 | never be used in a boolean sense (e.g. in `if p1 == p2:`). To prevent 214 | this __bool__ is overwritten to raise a ValueError. 215 | """ 216 | raise ValueError('Evaluation of comparison of publishers is not ' 217 | 'supported') 218 | 219 | def __iter__(self): 220 | """ To prevent iterating over a publisher this method is implemented 221 | to throw an exception. Otherwise it will fallback to __getitem__. 222 | """ 223 | raise ValueError('Iteration over a publisher is not possible') 224 | 225 | def inherit_type(self, type_cls: Optional[Type]) -> None: 226 | """ Enables the usage of method and attribute overloading for this 227 | publisher. 228 | """ 229 | self._inherited_type = type_cls 230 | 231 | @property 232 | def inherited_type(self) -> Optional[Type]: 233 | """ Property inherited_type returns used type class (or None) """ 234 | return self._inherited_type 235 | 236 | @property 237 | def dependencies(self) -> Tuple['Publisher', ...]: 238 | """ Returning a list of publishers this publisher is dependent on. """ 239 | return self._dependencies 240 | 241 | def add_dependencies(self, *publishers: 'Publisher') -> None: 242 | """ Add publishers which are directly or indirectly controlling the 243 | behavior of this publisher 244 | 245 | :param *publishers: variable argument list with publishers 246 | """ 247 | self._dependencies = self._dependencies + publishers 248 | 249 | def __or__(self, operator: 'Operator'): 250 | operator.originator = self 251 | return operator 252 | 253 | def __dir__(self): 254 | """ Extending __dir__ with inherited type """ 255 | attrs = set(super().__dir__()) 256 | if self._inherited_type: 257 | attrs.update(set(dir(self._inherited_type))) 258 | return tuple(attrs) 259 | 260 | 261 | class SubscriptionDisposable(Disposable): 262 | """ This disposable is returned on Publisher.subscribe(subscriber). 263 | :param publisher: publisher the subscription is made to 264 | :param subscriber: subscriber used for subscription 265 | """ 266 | def __init__(self, publisher: 'Publisher', subscriber: 'Subscriber') \ 267 | -> None: 268 | self._publisher = publisher 269 | self._subscriber = subscriber 270 | 271 | def dispose(self) -> None: 272 | self._publisher.unsubscribe(self._subscriber) 273 | 274 | @property 275 | def publisher(self) -> 'Publisher': 276 | """ Subscripted publisher """ 277 | return self._publisher 278 | 279 | @property 280 | def subscriber(self) -> 'Subscriber': 281 | """ Subscriber used in this subscription """ 282 | return self._subscriber 283 | -------------------------------------------------------------------------------- /broqer/publishers/__init__.py: -------------------------------------------------------------------------------- 1 | """ Module containing Publishers """ 2 | from .poll import PollPublisher 3 | 4 | __all__ = ['PollPublisher'] 5 | -------------------------------------------------------------------------------- /broqer/publishers/poll.py: -------------------------------------------------------------------------------- 1 | """ Implementing PollPublisher """ 2 | import asyncio 3 | from typing import Callable, Type, Any, Optional 4 | 5 | from broqer.publisher import Publisher, ValueT, SubscriptionDisposable 6 | from broqer.subscriber import Subscriber 7 | 8 | 9 | class PollPublisher(Publisher): 10 | """ A PollPublisher is periodically calling a given function and is using 11 | the returned value to notify the subscribers. When no subscriber is 12 | present, the polling function is not called. 13 | 14 | :param poll_cb: Function to be called 15 | :param interval: Time in seconds between polling calls 16 | """ 17 | def __init__(self, poll_cb: Callable[[], Any], interval: float, *, 18 | type_: Type[ValueT] = None): 19 | Publisher.__init__(self, type_=type_) 20 | self.poll_cb = poll_cb 21 | self.interval = interval 22 | self._poll_handler = None # type: Optional[asyncio.TimerHandle] 23 | 24 | def subscribe(self, subscriber: Subscriber, 25 | prepend: bool = False) -> SubscriptionDisposable: 26 | if not self._subscriptions: 27 | # call poll_cb once to set internal state and schedule a _poll call 28 | self._state = self.poll_cb() 29 | loop = asyncio.get_running_loop() 30 | assert self._poll_handler is None, '_poll_handler already assigned' 31 | self._poll_handler = loop.call_later(self.interval, self._poll) 32 | 33 | return Publisher.subscribe(self, subscriber, prepend) 34 | 35 | def unsubscribe(self, subscriber: 'Subscriber') -> None: 36 | Publisher.unsubscribe(self, subscriber) 37 | if not self._subscriptions and self._poll_handler: 38 | # when no subscription is left, cancel next _poll and reset state 39 | self._poll_handler.cancel() 40 | self._poll_handler = None 41 | self.reset_state() 42 | 43 | def _poll(self): 44 | assert self._poll_handler is not None 45 | 46 | value = self.poll_cb() 47 | Publisher.notify(self, value) 48 | 49 | loop = asyncio.get_running_loop() 50 | self._poll_handler = loop.call_later(self.interval, self._poll) 51 | 52 | def notify(self, value: ValueT) -> None: 53 | """ PollPublisher does not support .notify calls """ 54 | raise ValueError(self.notify.__doc__) 55 | -------------------------------------------------------------------------------- /broqer/subscriber.py: -------------------------------------------------------------------------------- 1 | """ Implementing the Subscriber class """ 2 | from typing import Any, TYPE_CHECKING 3 | 4 | if TYPE_CHECKING: 5 | # pylint: disable=cyclic-import 6 | from broqer import Publisher 7 | 8 | 9 | class Subscriber(): # pylint: disable=too-few-public-methods 10 | """ A Subscriber is listening to changes of a publisher. As soon as the 11 | publisher is emitting a value .emit(value) will be called. 12 | """ 13 | 14 | def emit(self, value: Any, who: 'Publisher') -> None: 15 | """ Send new value to the subscriber 16 | :param value: value to be send 17 | :param who: reference to which publisher is emitting 18 | """ 19 | raise NotImplementedError('.emit not implemented') 20 | 21 | def reset_state(self) -> None: 22 | """ Will be called by assigned publisher, when publisher was called 23 | to reset its state 24 | """ 25 | -------------------------------------------------------------------------------- /broqer/subscribers/__init__.py: -------------------------------------------------------------------------------- 1 | """ Module containing Subscribers """ 2 | from .on_emit_future import OnEmitFuture 3 | from .sink import Sink, build_sink, build_sink_factory, sink_property 4 | from .sink_async import SinkAsync, build_sink_async, \ 5 | build_sink_async_factory, sink_async_property 6 | from .trace import Trace 7 | 8 | __all__ = ['OnEmitFuture', 'Sink', 'build_sink', 9 | 'build_sink_factory', 'sink_property', 'SinkAsync', 10 | 'build_sink_async', 'build_sink_async_factory', 11 | 'sink_async_property', 'Trace'] 12 | -------------------------------------------------------------------------------- /broqer/subscribers/on_emit_future.py: -------------------------------------------------------------------------------- 1 | """ 2 | Build a future able to await for 3 | 4 | Usage: 5 | 6 | >>> import asyncio 7 | >>> from broqer import Value, op, OnEmitFuture 8 | >>> s = Value() 9 | 10 | >>> _ = asyncio.get_running_loop().call_later(0.05, s.emit, 1) 11 | 12 | >>> asyncio.get_running_loop().run_until_complete(OnEmitFuture(s) ) 13 | 1 14 | 15 | #>>> _ = asyncio.get_running_loop().call_later(0.05, s.emit, (1, 2)) 16 | #>>> asyncio.get_running_loop().run_until_complete(s) 17 | (1, 2) 18 | """ 19 | import asyncio 20 | from typing import Any, Optional, TYPE_CHECKING 21 | 22 | import broqer 23 | 24 | if TYPE_CHECKING: 25 | # pylint: disable=cyclic-import 26 | from broqer import Publisher 27 | 28 | 29 | class OnEmitFuture(broqer.Subscriber, asyncio.Future): 30 | """ Build a future able to await for. 31 | :param publisher: source publisher 32 | :param timeout: timeout in seconds, None for no timeout 33 | :param omit_subscription: omit any emit while subscription 34 | :param loop: asyncio loop to be used 35 | """ 36 | def __init__(self, publisher: 'Publisher', timeout=None, 37 | omit_subscription=False, loop=None): 38 | if loop is None: 39 | loop = asyncio.get_running_loop() 40 | 41 | asyncio.Future.__init__(self, loop=loop) 42 | self.add_done_callback(self._cleanup) 43 | 44 | self._publisher = publisher 45 | 46 | self._omit_subscription = omit_subscription 47 | 48 | if timeout is not None: 49 | self._timeout_handle = loop.call_later( 50 | timeout, self.set_exception, asyncio.TimeoutError) 51 | else: 52 | self._timeout_handle = None 53 | 54 | publisher.subscribe(self) 55 | self._omit_subscription = False 56 | 57 | def _cleanup(self, _future=None): 58 | self._publisher.unsubscribe(self) 59 | 60 | if self._timeout_handle is not None: 61 | self._timeout_handle.cancel() 62 | self._timeout_handle = None 63 | 64 | def emit(self, value: Any, who: Optional['Publisher'] = None) -> None: 65 | if who is not self._publisher: 66 | raise ValueError('Emit from non assigned publisher') 67 | 68 | if self._omit_subscription: 69 | return 70 | 71 | if not self.done(): 72 | self.remove_done_callback(self._cleanup) 73 | self._cleanup() 74 | self.set_result(value) 75 | -------------------------------------------------------------------------------- /broqer/subscribers/sink.py: -------------------------------------------------------------------------------- 1 | """ 2 | Apply ``func(*args, value, **kwargs)`` to each emitted value. It's also 3 | possible to omit ``func`` - in this case it's acting as dummy subscriber 4 | 5 | Usage: 6 | 7 | >>> from broqer import Value, op, Sink 8 | >>> s = Value() 9 | 10 | >>> len(s.subscriptions) 11 | 0 12 | >>> _d = s.subscribe(Sink(print, 'Sink', sep=':')) 13 | >>> len(s.subscriptions) 14 | 1 15 | 16 | >>> s.emit(1) 17 | Sink:1 18 | >>> s.emit((1, 2)) 19 | Sink:(1, 2) 20 | 21 | >>> _d.dispose() 22 | >>> len(s.subscriptions) 23 | 0 24 | """ 25 | from functools import partial, wraps 26 | from typing import Any, Callable, Optional, TYPE_CHECKING 27 | 28 | from broqer import Subscriber 29 | 30 | if TYPE_CHECKING: 31 | # pylint: disable=cyclic-import 32 | from broqer import Publisher 33 | 34 | 35 | class Sink(Subscriber): # pylint: disable=too-few-public-methods 36 | """ Apply ``function(*args, value, **kwargs)`` to each emitted value. It's 37 | also possible to omit ``function`` - in this case it's acting as dummy 38 | subscriber 39 | 40 | :param function: function to be called when source publisher emits 41 | :param \\*args: variable arguments to be used for calling function 42 | :param unpack: value from emits will be unpacked (\\*value) 43 | :param \\*\\*kwargs: keyword arguments to be used for calling function 44 | """ 45 | def __init__(self, # pylint: disable=keyword-arg-before-vararg 46 | function: Optional[Callable[..., None]] = None, 47 | *args, unpack=False, **kwargs) -> None: 48 | if function is None: 49 | self._function = None # type: Optional[Callable[..., None]] 50 | elif args or kwargs: 51 | self._function = \ 52 | partial(function, *args, **kwargs) 53 | else: 54 | self._function = function 55 | 56 | self._unpack = unpack 57 | 58 | def emit(self, value: Any, who: 'Publisher'): 59 | if self._function: 60 | if self._unpack: 61 | self._function(*value) 62 | else: 63 | self._function(value) 64 | 65 | 66 | def build_sink(function: Callable[..., None] = None, *, 67 | unpack: bool = False): 68 | """ Decorator to wrap a function to return a Sink subscriber. 69 | 70 | :param function: function to be wrapped 71 | :param unpack: value from emits will be unpacked (*value) 72 | """ 73 | def _build_sink(function): 74 | return Sink(function, unpack=unpack) 75 | 76 | if function: 77 | return _build_sink(function) 78 | 79 | return _build_sink 80 | 81 | 82 | def build_sink_factory(function: Callable[..., None] = None, *, 83 | unpack: bool = False): 84 | """ Decorator to wrap a function to return a Sink subscriber factory. 85 | :param function: function to be wrapped 86 | :param unpack: value from emits will be unpacked (*value) 87 | """ 88 | def _build_sink(function: Callable[..., None]): 89 | @wraps(function) 90 | def _wrapper(*args, **kwargs) -> Sink: 91 | if 'unpack' in kwargs: 92 | raise TypeError('"unpack" has to be defined by decorator') 93 | return Sink(function, *args, unpack=unpack, **kwargs) 94 | return _wrapper 95 | 96 | if function: 97 | return _build_sink(function) 98 | 99 | return _build_sink 100 | 101 | 102 | def sink_property(function: Callable[..., None] = None, unpack: bool = False): 103 | """ Decorator to build a property returning a Sink subscriber. 104 | :param function: function to be wrapped 105 | :param unpack: value from emits will be unpacked (*value) 106 | """ 107 | def build_sink_property(function): 108 | @property 109 | def _build_sink(self): 110 | return Sink(function, self, unpack=unpack) 111 | return _build_sink 112 | 113 | if function: 114 | return build_sink_property(function) 115 | 116 | return build_sink_property 117 | -------------------------------------------------------------------------------- /broqer/subscribers/sink_async.py: -------------------------------------------------------------------------------- 1 | """ 2 | Apply ``coro(*args, value, **kwargs)`` to each emitted value allowing async 3 | processing. 4 | 5 | Usage: 6 | 7 | >>> import asyncio 8 | >>> from broqer import Value, op 9 | >>> s = Value() 10 | 11 | >>> async def delay_add(a): 12 | ... print('Starting with argument', a) 13 | ... await asyncio.sleep(0.015) 14 | ... result = a + 1 15 | ... print('Finished with argument', a) 16 | ... return result 17 | 18 | MODE: CONCURRENT (is default) 19 | 20 | >>> _d = s.subscribe(SinkAsync(delay_add)) 21 | >>> s.emit(0) 22 | >>> s.emit(1) 23 | >>> asyncio.run(asyncio.sleep(0.02)) 24 | Starting with argument 0 25 | Starting with argument 1 26 | Finished with argument 0 27 | Finished with argument 1 28 | >>> _d.dispose() 29 | """ 30 | 31 | import asyncio 32 | import sys 33 | 34 | from functools import wraps 35 | from typing import Any 36 | 37 | # pylint: disable=cyclic-import 38 | from broqer import Subscriber, Publisher, default_error_handler 39 | from broqer.coro_queue import CoroQueue, AsyncMode, wrap_coro 40 | 41 | 42 | class SinkAsync(Subscriber): # pylint: disable=too-few-public-methods 43 | """ Apply ``coro`` to each emitted value allowing async processing 44 | 45 | :param coro: coroutine to be applied on emit 46 | :param \\*args: variable arguments to be used for calling coro 47 | :param mode: behavior when a value is currently processed 48 | :param error_callback: error callback to be registered 49 | :param unpack: value from emits will be unpacked as (\\*value) 50 | :param \\*\\*kwargs: keyword arguments to be used for calling coro 51 | """ 52 | def __init__(self, coro, *args, mode=AsyncMode.CONCURRENT, 53 | error_callback=default_error_handler, 54 | unpack: bool = False, **kwargs) -> None: 55 | 56 | _coro = wrap_coro(coro, unpack, *args, **kwargs) 57 | self._coro_queue = CoroQueue(_coro, mode=mode) 58 | self._error_callback = error_callback 59 | 60 | def emit(self, value: Any, who: Publisher): 61 | future = self._coro_queue.schedule(value) 62 | future.add_done_callback(self._done) 63 | 64 | def _done(self, future: asyncio.Future): 65 | try: 66 | future.result() 67 | except Exception: # pylint: disable=broad-except 68 | self._error_callback(*sys.exc_info()) 69 | 70 | 71 | def build_sink_async(coro=None, *, mode: AsyncMode = AsyncMode.CONCURRENT, 72 | unpack: bool = False): 73 | """ Decorator to wrap a coroutine to return a SinkAsync subscriber. 74 | 75 | :param coro: coroutine to be wrapped 76 | :param mode: behavior when a value is currently processed 77 | :param unpack: value from emits will be unpacked (*value) 78 | """ 79 | def _build_sink_async(coro): 80 | return SinkAsync(coro, mode=mode, unpack=unpack) 81 | 82 | if coro: 83 | return _build_sink_async(coro) 84 | 85 | return _build_sink_async 86 | 87 | 88 | def build_sink_async_factory(coro=None, *, 89 | mode: AsyncMode = AsyncMode.CONCURRENT, 90 | error_callback=default_error_handler, 91 | unpack: bool = False): 92 | """ Decorator to wrap a coroutine to return a factory for SinkAsync 93 | subscribers. 94 | 95 | :param coro: coroutine to be wrapped 96 | :param mode: behavior when a value is currently processed 97 | :param error_callback: error callback to be registered 98 | :param unpack: value from emits will be unpacked (*value) 99 | """ 100 | def _build_sink_async(coro): 101 | @wraps(coro) 102 | def _wrapper(*args, **kwargs) -> SinkAsync: 103 | if ('unpack' in kwargs) or ('mode' in kwargs) or \ 104 | ('error_callback' in kwargs): 105 | raise TypeError('"unpack", "mode" and "error_callback" has to ' 106 | 'be defined by decorator') 107 | 108 | return SinkAsync(coro, *args, mode=mode, 109 | error_callback=error_callback, unpack=unpack, 110 | **kwargs) 111 | return _wrapper 112 | 113 | if coro: 114 | return _build_sink_async(coro) 115 | 116 | return _build_sink_async 117 | 118 | 119 | def sink_async_property(coro=None, *, 120 | mode: AsyncMode = AsyncMode.CONCURRENT, 121 | error_callback=default_error_handler, 122 | unpack: bool = False): 123 | """ Decorator to build a property returning a SinkAsync subscriber. 124 | 125 | :param coro: coroutine to be wrapped 126 | :param mode: behavior when a value is currently processed 127 | :param error_callback: error callback to be registered 128 | :param unpack: value from emits will be unpacked (*value) 129 | """ 130 | def build_sink_async_property(coro): 131 | @property 132 | def _build_sink_async(self): 133 | return SinkAsync(coro, self, mode=mode, 134 | error_callback=error_callback, unpack=unpack) 135 | return _build_sink_async 136 | 137 | if coro: 138 | return build_sink_async_property(coro) 139 | 140 | return build_sink_async_property 141 | -------------------------------------------------------------------------------- /broqer/subscribers/trace.py: -------------------------------------------------------------------------------- 1 | """ Implements Trace subscriber """ 2 | from time import time 3 | from typing import TYPE_CHECKING, Any, Callable, Optional 4 | 5 | from .sink import Sink 6 | 7 | if TYPE_CHECKING: 8 | # pylint: disable=cyclic-import 9 | from broqer import Publisher 10 | 11 | 12 | class Trace(Sink): 13 | """ Trace is a subscriber used for debugging purpose. On subscription 14 | it will use the prepend flag to be the first callback called when the 15 | publisher of interest is emitting. 16 | :param callback: optional function to call 17 | :param \\*args: arguments used additionally when calling callback 18 | :param unpack: value from emits will be unpacked (\\*value) 19 | :param label: string to be used on output 20 | :param \\*\\*kwargs: keyword arguments used when calling callback 21 | """ 22 | def __init__(self, # pylint: disable=keyword-arg-before-vararg 23 | function: Optional[Callable[..., None]] = None, 24 | *args, unpack=False, label=None, **kwargs) -> None: 25 | Sink.__init__(self, function, *args, unpack=unpack, **kwargs) 26 | self._label = label 27 | 28 | def emit(self, value: Any, who: 'Publisher'): 29 | self._trace_handler(who, value, label=self._label) 30 | Sink.emit(self, value, who=who) 31 | 32 | @classmethod 33 | def set_handler(cls, handler): 34 | """ Setting the handler for tracing information """ 35 | cls._trace_handler = handler 36 | 37 | _timestamp_start = time() 38 | 39 | @staticmethod 40 | def _trace_handler(publisher: 'Publisher', value, label=None): 41 | """ Default trace handler is printing the timestamp, the publisher name 42 | and the emitted value 43 | """ 44 | time_diff = time() - Trace._timestamp_start 45 | line = f'--- {time_diff: 8.3f}: ' 46 | line += repr(publisher) if label is None else label 47 | line += f' {value!r}' 48 | print(line) 49 | -------------------------------------------------------------------------------- /broqer/timer.py: -------------------------------------------------------------------------------- 1 | """ Asynchronous timer object """ 2 | import asyncio 3 | from typing import Callable, Optional 4 | 5 | 6 | class Timer: 7 | """ The timer object is used to have an abstraction for handling time 8 | dependend functionality. The timer works non-periodically. 9 | 10 | :param callback: an optional callback function called when the time 11 | `timeout` has passed after calling `.start()` or when 12 | calling `.end_early()` 13 | :param loop: optional asyncio event loop 14 | """ 15 | def __init__(self, callback: Optional[Callable[[], None]] = None, 16 | loop: Optional[asyncio.BaseEventLoop] = None): 17 | self._callback = callback 18 | self._handle = None # type: Optional[asyncio.Handle] 19 | self._loop = loop or asyncio.get_running_loop() 20 | self._args = None 21 | 22 | def start(self, timeout: float, args=()) -> None: 23 | """ start the timer with given timeout. Optional arguments for the 24 | callback can be provided. When the timer is currently running, the 25 | timer will be re-set to the new timeout. 26 | 27 | :param timeout: time in seconds to the end of the timer 28 | :param args: optional tuple with arguments for the callback 29 | """ 30 | if self._handle: 31 | self._handle.cancel() 32 | 33 | self._args = args 34 | 35 | if timeout > 0: 36 | self._handle = self._loop.call_later(timeout, self._trigger) 37 | else: 38 | self._trigger() 39 | 40 | def change_arguments(self, args=()): 41 | """ Will chance the arguments for the scheduled callback 42 | 43 | :param args: Positional arguments 44 | """ 45 | self._args = args 46 | 47 | def cancel(self) -> None: 48 | """ Cancel the timer. An optional callback will not be called. """ 49 | 50 | if self._handle: 51 | self._handle.cancel() 52 | self._handle = None 53 | 54 | def end_early(self) -> None: 55 | """ immediate stopping the timer and call optional callback """ 56 | self._handle = None 57 | if self._handle and self._callback: 58 | self._callback(*self._args) 59 | 60 | def is_running(self) -> bool: 61 | """ tells if the timer is currently running 62 | :returns: boolean, True when timer is running 63 | """ 64 | return self._handle is not None 65 | 66 | def _trigger(self): 67 | """ internal method called when timer is finished """ 68 | self._handle = None 69 | if self._callback: 70 | self._callback(*self._args) 71 | -------------------------------------------------------------------------------- /broqer/types.py: -------------------------------------------------------------------------------- 1 | """ Types used in broqer""" 2 | 3 | 4 | class NONE: # pylint: disable=too-few-public-methods 5 | """ Marker class used for initialization of undefined state """ 6 | -------------------------------------------------------------------------------- /broqer/value.py: -------------------------------------------------------------------------------- 1 | """ Implementing Value """ 2 | 3 | from typing import Any 4 | 5 | # pylint: disable=cyclic-import 6 | from broqer import Publisher, NONE 7 | from broqer.operator import Operator 8 | 9 | 10 | class Value(Operator): 11 | """ 12 | Value is a publisher and subscriber. 13 | 14 | >>> from broqer import Sink 15 | 16 | >>> s = Value(0) 17 | >>> _d = s.subscribe(Sink(print)) 18 | 0 19 | >>> s.emit(1) 20 | 1 21 | """ 22 | def __init__(self, init=NONE): 23 | Operator.__init__(self) 24 | self._state = init 25 | 26 | def emit(self, value: Any, 27 | who: Publisher = None) -> None: # pylint: disable=unused-argument 28 | if self._originator is not None and self._originator is not who: 29 | raise ValueError('Emit from non assigned publisher') 30 | 31 | return Publisher.notify(self, value) 32 | 33 | notify = Publisher.notify 34 | 35 | 36 | def dependent_subscribe(publisher: Publisher, value: Value): 37 | """ Let `value` subscribe to `publisher` only when `value` itself is 38 | subscribed 39 | :param publisher: publisher to be subscribed, when value is subscribed 40 | :param value: value, which will receive .emit calls from `publisher` 41 | """ 42 | def _on_subscription(existing_subscription: bool): 43 | if existing_subscription: 44 | publisher.subscribe(value) 45 | else: 46 | publisher.unsubscribe(value) 47 | 48 | value.register_on_subscription_callback(_on_subscription) 49 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = broqer 8 | SOURCEDIR = . 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import sys 4 | sys.path.insert(0, os.path.abspath('..')) 5 | 6 | project = 'broqer' 7 | copyright = u'2018, Günther Jena' 8 | author = u'Günther Jena' 9 | from broqer import __version__ as version 10 | github_url = 'https://github.com/semiversus/python-broqer' 11 | 12 | extensions = [ 13 | 'sphinx.ext.autodoc', 14 | 'sphinx.ext.autosummary', 15 | 'sphinx.ext.doctest', 16 | 'sphinx.ext.viewcode', 17 | ] 18 | 19 | master_doc = 'index' 20 | source_suffix = '.rst' 21 | 22 | html_theme = 'sphinx_rtd_theme' 23 | htmlhelp_basename = 'broqer' 24 | 25 | html_context = { 26 | 'display_github': True, # Add 'Edit on Github' link instead of 'View page source' 27 | 'github_user': 'semiversus', 28 | 'github_repo': project, 29 | 'github_version': 'master', 30 | 'conf_py_path': '/docs/', 31 | 'source_suffix': source_suffix, 32 | } 33 | 34 | latex_documents = [ 35 | (master_doc, 'python-broqer.tex', 'python-broqer Documentation', 36 | u'Günther Jena', 'manual'), 37 | ] 38 | 39 | from sphinx.ext import autodoc 40 | 41 | class ClassDocDocumenter(autodoc.ClassDocumenter): 42 | objtype = 'classdoc' 43 | 44 | #do not indent the content 45 | content_indent = "" 46 | 47 | #do not add a header to the docstring 48 | def add_directive_header(self, sig): 49 | pass 50 | 51 | def setup(app): 52 | app.add_autodocumenter(ClassDocDocumenter) 53 | -------------------------------------------------------------------------------- /docs/hub.rst: -------------------------------------------------------------------------------- 1 | Hub 2 | === 3 | 4 | .. automodule:: broqer.hub 5 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | :end-before: header 3 | 4 | ======= 5 | Content 6 | ======= 7 | 8 | .. toctree:: 9 | :maxdepth: 2 10 | 11 | introduction.rst 12 | publishers.rst 13 | subscribers.rst 14 | operators.rst 15 | subjects.rst 16 | hub.rst 17 | 18 | .. include:: ../README.rst 19 | :start-after: header 20 | :end-before: api -------------------------------------------------------------------------------- /docs/introduction.rst: -------------------------------------------------------------------------------- 1 | Introduction 2 | ============ 3 | 4 | What's all the fuss about Reactive Programming? 5 | ----------------------------------------------- 6 | 7 | *Reactive Programming* is all about asynchronous data streams. In history 8 | of programming a lot of architectures and paradigms had come up solving 9 | this issues. Terms like *event driven*, *oberserver pattern* and others are 10 | coming up describing the idea behind *reactive programming*. 11 | 12 | In *reactive programming* you work on asynchronous data streams. And this 13 | can be nearly anything. 14 | 15 | Examples of sources for asynchronous data streams: 16 | 17 | * events in an user interface (clicks, mouse moves, ...) 18 | * requests from clients in a server environment 19 | * sensor and input data from an embedded system 20 | * variables, states or data structures in an application 21 | * ... 22 | 23 | *Reactive programming* gives you operators to work on those streams: 24 | 25 | * filter streams 26 | * map functions to streams 27 | * merge streams in various ways 28 | * process contained data 29 | * ... -------------------------------------------------------------------------------- /docs/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 20 | 22 | 44 | 46 | 47 | 49 | image/svg+xml 50 | 52 | 53 | 54 | 55 | 56 | 61 | 66 | 71 | 76 | 81 | 87 | 89 | 94 | 102 | 103 | 111 | 117 | 123 | 124 | 125 | -------------------------------------------------------------------------------- /docs/operators.rst: -------------------------------------------------------------------------------- 1 | Operators 2 | ========= 3 | 4 | ================================= =========== 5 | Operator Description 6 | ================================= =========== 7 | :doc:`operators/accumulate` Apply func(value, state) which is returning new state and value to emit 8 | :doc:`operators/cache` Caching the emitted values (make a stateless publisher stateful) 9 | :doc:`operators/catch_exception` Catching exceptions of following operators in the pipeline 10 | :doc:`operators/combine_latest` Combine the latest emit of multiple publishers and emit the combination 11 | :doc:`operators/debounce` Emit a value only after a given idle time (emits meanwhile are skipped). 12 | :doc:`operators/delay` Emit every value delayed by the given time. 13 | :doc:`operators/filter` Filters values based on a ``predicate`` function 14 | :doc:`operators/map` Apply a function to each emitted value 15 | :doc:`operators/map_async` Apply a coroutine to each emitted value allowing async processing 16 | :doc:`operators/map_threaded` Apply a blocking function to each emitted value allowing threaded processing 17 | :doc:`operators/merge` Merge emits of multiple publishers into one stream 18 | :doc:`operators/partition` Group ``size`` emits into one emit as tuple 19 | :doc:`operators/reduce` Like ``Map`` but with additional previous result as argument to the function. 20 | :doc:`operators/replace` Replace each received value by the given value 21 | :doc:`operators/sample` Emit the last received value periodically 22 | :doc:`operators/sliding_window` Group ``size`` emitted values overlapping 23 | :doc:`operators/switch` Emit selected source mapped by ``mapping`` 24 | :doc:`operators/throttle` Rate limit emits by the given time 25 | 26 | ================================= =========== 27 | 28 | .. toctree:: 29 | :hidden: 30 | 31 | operators/accumulate.rst 32 | operators/cache.rst 33 | operators/catch_exception.rst 34 | operators/combine_latest.rst 35 | operators/debounce.rst 36 | operators/delay.rst 37 | operators/filter.rst 38 | operators/map.rst 39 | operators/map_async.rst 40 | operators/map_threaded.rst 41 | operators/merge.rst 42 | operators/partition.rst 43 | operators/reduce.rst 44 | operators/replace.rst 45 | operators/sample.rst 46 | operators/sliding_window.rst 47 | operators/switch.rst 48 | operators/throttle.rst 49 | -------------------------------------------------------------------------------- /docs/operators/accumulate.rst: -------------------------------------------------------------------------------- 1 | Accumulate 2 | ========== 3 | 4 | Definition 5 | ---------- 6 | 7 | .. autoclass:: broqer.op.Accumulate 8 | :members: reset 9 | 10 | Usage 11 | ----- 12 | 13 | .. automodule:: broqer.op.accumulate -------------------------------------------------------------------------------- /docs/operators/cache.rst: -------------------------------------------------------------------------------- 1 | Cache 2 | ===== 3 | 4 | Definition 5 | ---------- 6 | 7 | .. autoclass:: broqer.op.Cache 8 | 9 | Usage 10 | ----- 11 | 12 | .. automodule:: broqer.op.cache -------------------------------------------------------------------------------- /docs/operators/catch_exception.rst: -------------------------------------------------------------------------------- 1 | CatchException 2 | ============== 3 | 4 | Definition 5 | ---------- 6 | 7 | .. autoclass:: broqer.op.CatchException 8 | 9 | Usage 10 | ----- 11 | 12 | .. automodule:: broqer.op.catch_exception -------------------------------------------------------------------------------- /docs/operators/combine_latest.rst: -------------------------------------------------------------------------------- 1 | CombineLatest 2 | ============= 3 | 4 | Definition 5 | ---------- 6 | 7 | .. autoclass:: broqer.op.CombineLatest 8 | 9 | Usage 10 | ----- 11 | 12 | .. automodule:: broqer.op.combine_latest -------------------------------------------------------------------------------- /docs/operators/debounce.rst: -------------------------------------------------------------------------------- 1 | Debounce 2 | ======== 3 | 4 | Definition 5 | ---------- 6 | 7 | .. autoclass:: broqer.op.Debounce 8 | 9 | Usage 10 | ----- 11 | 12 | .. automodule:: broqer.op.debounce -------------------------------------------------------------------------------- /docs/operators/delay.rst: -------------------------------------------------------------------------------- 1 | Delay 2 | ===== 3 | 4 | Definition 5 | ---------- 6 | 7 | .. autoclass:: broqer.op.Delay 8 | 9 | Usage 10 | ----- 11 | 12 | .. automodule:: broqer.op.delay -------------------------------------------------------------------------------- /docs/operators/filter.rst: -------------------------------------------------------------------------------- 1 | Filter 2 | ====== 3 | 4 | Definition 5 | ---------- 6 | 7 | .. autoclass:: broqer.op.Filter 8 | 9 | Usage 10 | ----- 11 | 12 | .. automodule:: broqer.op.filter_ -------------------------------------------------------------------------------- /docs/operators/map.rst: -------------------------------------------------------------------------------- 1 | Map 2 | === 3 | 4 | Definition 5 | ---------- 6 | 7 | .. autoclass:: broqer.op.Map 8 | 9 | Usage 10 | ----- 11 | 12 | .. automodule:: broqer.op.map_ -------------------------------------------------------------------------------- /docs/operators/map_async.rst: -------------------------------------------------------------------------------- 1 | MapAsync 2 | ======== 3 | 4 | Definition 5 | ---------- 6 | 7 | .. autoclass:: broqer.op.MapAsync 8 | 9 | Usage 10 | ----- 11 | 12 | .. automodule:: broqer.op.map_async -------------------------------------------------------------------------------- /docs/operators/map_threaded.rst: -------------------------------------------------------------------------------- 1 | MapThreaded 2 | =========== 3 | 4 | Definition 5 | ---------- 6 | 7 | .. autoclass:: broqer.op.MapThreaded 8 | 9 | Usage 10 | ----- 11 | 12 | .. automodule:: broqer.op.map_threaded -------------------------------------------------------------------------------- /docs/operators/merge.rst: -------------------------------------------------------------------------------- 1 | Merge 2 | ===== 3 | 4 | Definition 5 | ---------- 6 | 7 | .. autoclass:: broqer.op.Merge 8 | 9 | Usage 10 | ----- 11 | 12 | .. automodule:: broqer.op.merge -------------------------------------------------------------------------------- /docs/operators/partition.rst: -------------------------------------------------------------------------------- 1 | Partition 2 | ========= 3 | 4 | Definition 5 | ---------- 6 | 7 | .. autoclass:: broqer.op.Partition 8 | 9 | Usage 10 | ----- 11 | 12 | .. automodule:: broqer.op.partition -------------------------------------------------------------------------------- /docs/operators/reduce.rst: -------------------------------------------------------------------------------- 1 | Reduce 2 | ====== 3 | 4 | Definition 5 | ---------- 6 | 7 | .. autoclass:: broqer.op.Reduce 8 | 9 | Usage 10 | ----- 11 | 12 | .. automodule:: broqer.op.reduce -------------------------------------------------------------------------------- /docs/operators/replace.rst: -------------------------------------------------------------------------------- 1 | Replace 2 | ======= 3 | 4 | Definition 5 | ---------- 6 | 7 | .. autoclass:: broqer.op.Replace 8 | 9 | Usage 10 | ----- 11 | 12 | .. automodule:: broqer.op.replace -------------------------------------------------------------------------------- /docs/operators/sample.rst: -------------------------------------------------------------------------------- 1 | Sample 2 | ====== 3 | 4 | Definition 5 | ---------- 6 | 7 | .. autoclass:: broqer.op.Sample 8 | 9 | Usage 10 | ----- 11 | 12 | .. automodule:: broqer.op.sample -------------------------------------------------------------------------------- /docs/operators/sliding_window.rst: -------------------------------------------------------------------------------- 1 | SlidingWindow 2 | ============= 3 | 4 | Definition 5 | ---------- 6 | 7 | .. autoclass:: broqer.op.SlidingWindow 8 | 9 | Usage 10 | ----- 11 | 12 | .. automodule:: broqer.op.sliding_window -------------------------------------------------------------------------------- /docs/operators/switch.rst: -------------------------------------------------------------------------------- 1 | Switch 2 | ====== 3 | 4 | Definition 5 | ---------- 6 | 7 | .. autoclass:: broqer.op.Switch 8 | 9 | Usage 10 | ----- 11 | 12 | .. automodule:: broqer.op.switch -------------------------------------------------------------------------------- /docs/operators/throttle.rst: -------------------------------------------------------------------------------- 1 | Throttle 2 | ======== 3 | 4 | Definition 5 | ---------- 6 | 7 | .. autoclass:: broqer.op.Throttle 8 | 9 | Usage 10 | ----- 11 | 12 | .. automodule:: broqer.op.throttle -------------------------------------------------------------------------------- /docs/overview.gnumeric: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/semiversus/python-broqer/e4ff454488e89b57aefde1b8933f17c9494a13e3/docs/overview.gnumeric -------------------------------------------------------------------------------- /docs/publishers.rst: -------------------------------------------------------------------------------- 1 | Publishers 2 | ========== 3 | 4 | .. autoclassdoc:: broqer.Publisher 5 | 6 | Publisher 7 | --------- 8 | .. autoclass:: broqer.Publisher 9 | :members: subscribe, unsubscribe, get, notify, subscriptions, wait_for 10 | 11 | StatefulPublisher 12 | ----------------- 13 | .. autoclass:: broqer.StatefulPublisher 14 | 15 | FromPolling 16 | ----------- 17 | .. autoclass:: broqer.op.FromPolling -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx-autodoc-typehints==1.3.0 -------------------------------------------------------------------------------- /docs/subjects.rst: -------------------------------------------------------------------------------- 1 | Subjects 2 | ========== 3 | 4 | Subject 5 | ------- 6 | .. autoclass:: broqer.Subject 7 | 8 | Value 9 | ----- 10 | .. autoclass:: broqer.Value -------------------------------------------------------------------------------- /docs/subscribers.rst: -------------------------------------------------------------------------------- 1 | Subscribers 2 | =========== 3 | 4 | Subscriber 5 | ---------- 6 | .. autoclass:: broqer.Subscriber 7 | 8 | Sink 9 | ---- 10 | .. autoclass:: broqer.op.Sink 11 | 12 | SinkAsync 13 | --------- 14 | .. autoclass:: broqer.op.SinkAsync 15 | 16 | OnEmitFuture 17 | ------------ 18 | .. autoclass:: broqer.op.OnEmitFuture 19 | 20 | Trace 21 | ----- 22 | .. autoclass:: broqer.op.Trace 23 | 24 | TopicMapper 25 | ----------- 26 | .. autoclass:: broqer.hub.utils.TopicMapper -------------------------------------------------------------------------------- /examples/await.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from broqer import op 4 | from broqer.subject import Subject 5 | 6 | value = Subject() 7 | 8 | 9 | async def main(): 10 | print('Value: ', await (value | op.OnEmitFuture())) 11 | print('Value: ', await value) 12 | print('Number of subscribers: %d' % len(value.subscriptions)) 13 | 14 | loop = asyncio.get_running_loop() 15 | 16 | loop.call_later(0.2, value.emit, 1) 17 | loop.call_later(0.4, value.emit, 2) 18 | 19 | loop.run_until_complete(main()) 20 | -------------------------------------------------------------------------------- /examples/from_polling.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import subprocess 3 | import operator 4 | 5 | from broqer import op 6 | 7 | (op.FromPolling(1, subprocess.check_output, 'uptime') 8 | | op.Map(str, encoding='utf - 8') 9 | | op.Map(str.split, sep=', ') 10 | | op.Map(lambda v:v[0]) 11 | | op.Sink(print) 12 | ) 13 | 14 | loop = asyncio.get_running_loop() 15 | loop.run_until_complete(asyncio.sleep(10)) 16 | -------------------------------------------------------------------------------- /examples/pipeline.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import statistics 3 | 4 | from broqer import op 5 | from broqer.subject import Subject 6 | 7 | adc_raw = Subject() 8 | 9 | (adc_raw 10 | | op.Cache(0) 11 | | op.Map(lambda d: d * 5 + 3) 12 | | op.Sample(0.3) 13 | | op.SlidingWindow(5) 14 | | op.Map(statistics.mean) 15 | | op.Cache() 16 | | op.Debounce(0.5) 17 | | op.Sink(print) 18 | ) 19 | 20 | 21 | async def main(): 22 | await asyncio.sleep(2) 23 | adc_raw.emit(50) 24 | await asyncio.sleep(2) 25 | 26 | loop = asyncio.get_running_loop() 27 | loop.run_until_complete(main()) 28 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=45", "setuptools_scm[toml]>=7.0"] 3 | 4 | [tool.setuptools_scm] 5 | write_to = "broqer/_version.py" 6 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | pytest==7.1.3 2 | pytest-asyncio==0.19.0 3 | pytest-cov==4.0.0 4 | tox==2.9.1 5 | Sphinx==1.7.8 6 | sphinx-rtd-theme==0.4.0 7 | sphinx-autodoc-typehints==1.3.0 8 | flake8==3.7.9 9 | mypy==1.14.0 10 | rstcheck==6.1.0 11 | setuptools_scm==3.3.3 12 | wheel==0.38.1 13 | pylama==7.7.1 14 | pylint==2.15.3 15 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [coverage:run] 5 | branch = True 6 | 7 | [coverage:report] 8 | precision = 1 9 | exclude_lines = 10 | if TYPE_CHECKING: 11 | 12 | [tool:pytest] 13 | testpaths = tests broqer README.rst 14 | doctest_optionflags = ELLIPSIS 15 | addopts = --cov-report=html --no-cov-on-fail -q --cov=broqer 16 | 17 | [pylama] 18 | async = 1 19 | format = pycodestyle 20 | paths = broqer 21 | skip = broqer/_version.py 22 | linters = pycodestyle,mccabe,pylint 23 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ The setup script.""" 5 | 6 | from setuptools import find_packages, setup 7 | 8 | 9 | with open('README.rst', 'rb') as readme_file: 10 | readme = readme_file.read().decode('utf-8') 11 | 12 | 13 | setup( 14 | author='Günther Jena', 15 | author_email='guenther@jena.at', 16 | classifiers=[ 17 | 'Development Status :: 5 - Production/Stable', 18 | 'Intended Audience :: Developers', 19 | 'License :: OSI Approved :: MIT License', 20 | 'Natural Language :: English', 21 | 'Programming Language :: Python :: 3', 22 | 'Programming Language :: Python :: 3.7', 23 | 'Programming Language :: Python :: 3.8', 24 | 'Programming Language :: Python :: 3.9', 25 | 'Programming Language :: Python :: 3.10', 26 | 'Programming Language :: Python :: 3.11', 27 | ], 28 | description='Carefully crafted library to operate with continuous ' + 29 | 'streams of data in a reactive style with publish/subscribe ' + 30 | 'and broker functionality.', 31 | license='MIT license', 32 | long_description=readme, 33 | include_package_data=True, 34 | keywords='broker publisher subscriber reactive frp observable', 35 | name='broqer', 36 | packages=find_packages(include=['broqer*']), 37 | url='https://github.com/semiversus/python-broqer', 38 | zip_safe=False, 39 | ) 40 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | pytest.register_assert_rewrite('tests.helper_single') 4 | pytest.register_assert_rewrite('tests.helper_multi') 5 | -------------------------------------------------------------------------------- /tests/eventloop.py: -------------------------------------------------------------------------------- 1 | """ this file is taken from aioreactive project: 2 | https://github.com/dbrattli/aioreactive/blob/master/aioreactive/testing/eventloop.py 3 | 4 | version 2: fixed python3.8 issues 5 | """ 6 | 7 | import inspect 8 | import traceback 9 | import logging 10 | import threading 11 | import collections 12 | import heapq 13 | import asyncio 14 | from asyncio import TimerHandle 15 | 16 | log = logging.getLogger(__name__) 17 | 18 | 19 | def _format_handle(handle): 20 | cb = handle._callback 21 | if inspect.ismethod(cb) and isinstance(cb.__self__, asyncio.Task): 22 | # format the task 23 | return repr(cb.__self__) 24 | else: 25 | return str(handle) 26 | 27 | 28 | def _run_until_complete_cb(fut): 29 | exc = fut._exception 30 | if (isinstance(exc, BaseException) and not isinstance(exc, Exception)): 31 | # Issue #22429: run_forever() already finished, no need to 32 | # stop it. 33 | return 34 | fut._loop.stop() 35 | 36 | 37 | def isfuture(obj): 38 | """Check for a Future. 39 | This returns True when obj is a Future instance or is advertising 40 | itself as duck-type compatible by setting _asyncio_future_blocking. 41 | See comment in Future for more details. 42 | """ 43 | return getattr(obj, '_asyncio_future_blocking', None) is not None 44 | 45 | 46 | class VirtualTimeEventLoop(asyncio.SelectorEventLoop): 47 | def __init__(self): 48 | asyncio.SelectorEventLoop.__init__(self) 49 | self._timer_cancelled_count = 0 50 | self._closed = False 51 | self._stopping = False 52 | self._ready = collections.deque() 53 | self._scheduled = [] 54 | self._thread_id = None 55 | self._exception_handler = None 56 | self._current_handle = None 57 | self._debug = False 58 | self._time = 0 59 | try: 60 | asyncio.events._set_running_loop(self) 61 | except KeyError: 62 | # event._set_running_loop is only available (and needed) for 63 | # python >= 3.7 64 | pass 65 | 66 | def run_forever(self): 67 | """Run until stop() is called.""" 68 | self._check_closed() 69 | 70 | if self.is_running(): 71 | raise RuntimeError('Event loop is running.') 72 | 73 | self._thread_id = threading.get_ident() 74 | try: 75 | while True: 76 | self._run_once() 77 | if self._stopping: 78 | break 79 | finally: 80 | self._stopping = False 81 | self._thread_id = None 82 | 83 | def run_until_complete(self, future): 84 | """Run the event loop until a Future is done. 85 | Return the Future's result, or raise its exception. 86 | """ 87 | self._check_closed() 88 | 89 | new_task = not isfuture(future) 90 | future = asyncio.ensure_future(future, loop=self) 91 | if new_task: 92 | # An exception is raised if the future didn't complete, so there 93 | # is no need to log the "destroy pending task" message 94 | future._log_destroy_pending = False 95 | 96 | future.add_done_callback(_run_until_complete_cb) 97 | try: 98 | self.run_forever() 99 | except: 100 | if new_task and future.done() and not future.cancelled(): 101 | # The coroutine raised a BaseException. Consume the exception 102 | # to not log a warning, the caller doesn't have access to the 103 | # local task. 104 | future.exception() 105 | raise 106 | future.remove_done_callback(_run_until_complete_cb) 107 | if not future.done(): 108 | raise RuntimeError('Event loop stopped before Future completed.') 109 | 110 | return future.result() 111 | 112 | def stop(self): 113 | """Stop running the event loop. 114 | Every callback already scheduled will still run. This simply informs 115 | run_forever to stop looping after a complete iteration. 116 | """ 117 | self._stopping = True 118 | 119 | def close(self): 120 | """Close the event loop. 121 | This clears the queues and shuts down the executor, 122 | but does not wait for the executor to finish. 123 | The event loop must not be running. 124 | """ 125 | if self.is_running(): 126 | raise RuntimeError("Cannot close a running event loop") 127 | if self._closed: 128 | return 129 | 130 | self._closed = True 131 | self._ready.clear() 132 | self._scheduled.clear() 133 | asyncio.events._set_running_loop(None) 134 | 135 | def is_closed(self): 136 | """Returns True if the event loop was closed.""" 137 | return self._closed 138 | 139 | def is_running(self): 140 | """Returns True if the event loop is running.""" 141 | return (self._thread_id is not None) 142 | 143 | def call_later(self, delay, callback, *args): 144 | timer = self.call_at(self.time() + delay, callback, *args) 145 | if timer._source_traceback: 146 | del timer._source_traceback[-1] 147 | return timer 148 | 149 | def call_at(self, when, callback, *args): 150 | timer = TimerHandle(when, callback, args, self) 151 | if timer._source_traceback: 152 | del timer._source_traceback[-1] 153 | heapq.heappush(self._scheduled, timer) 154 | timer._scheduled = True 155 | return timer 156 | 157 | def call_soon(self, callback, *args, context=None): 158 | handle = self._call_soon(callback, args) 159 | if handle._source_traceback: 160 | del handle._source_traceback[-1] 161 | return handle 162 | 163 | def _call_soon(self, callback, args, context=None): 164 | if (asyncio.iscoroutine(callback) or asyncio.iscoroutinefunction(callback)): 165 | raise TypeError("coroutines cannot be used with call_soon()") 166 | self._check_closed() 167 | handle = asyncio.Handle(callback, args, self) 168 | if handle._source_traceback: 169 | del handle._source_traceback[-1] 170 | self._ready.append(handle) 171 | return handle 172 | 173 | def call_soon_threadsafe(self, callback, *args): 174 | raise NotImplementedError('This functionality is not supported') 175 | 176 | def time(self): 177 | return self._time 178 | 179 | def create_future(self): 180 | """Create a Future object attached to the loop.""" 181 | return asyncio.Future(loop=self) 182 | 183 | def create_task(self, coro): 184 | """Schedule a coroutine object. 185 | Return a task object.""" 186 | 187 | self._check_closed() 188 | task = asyncio.Task(coro, loop=self) 189 | if task._source_traceback: 190 | del task._source_traceback[-1] 191 | return task 192 | 193 | def default_exception_handler(self, context): 194 | """Default exception handler. 195 | This is called when an exception occurs and no exception 196 | handler is set, and can be called by a custom exception 197 | handler that wants to defer to the default behavior. 198 | The context parameter has the same meaning as in 199 | `call_exception_handler()`.""" 200 | 201 | message = context.get('message') 202 | if not message: 203 | message = 'Unhandled exception in event loop' 204 | 205 | exception = context.get('exception') 206 | if exception is not None: 207 | exc_info = (type(exception), exception, exception.__traceback__) 208 | else: 209 | exc_info = False 210 | 211 | if ('source_traceback' not in context 212 | and self._current_handle is not None 213 | and self._current_handle._source_traceback): 214 | context['handle_traceback'] = self._current_handle._source_traceback 215 | 216 | log_lines = [message] 217 | for key in sorted(context): 218 | if key in {'message', 'exception'}: 219 | continue 220 | value = context[key] 221 | if key == 'source_traceback': 222 | tb = ''.join(traceback.format_list(value)) 223 | value = 'Object created at (most recent call last):\n' 224 | value += tb.rstrip() 225 | elif key == 'handle_traceback': 226 | tb = ''.join(traceback.format_list(value)) 227 | value = 'Handle created at (most recent call last):\n' 228 | value += tb.rstrip() 229 | else: 230 | value = repr(value) 231 | log_lines.append('{}: {}'.format(key, value)) 232 | 233 | log.error('\n'.join(log_lines), exc_info=exc_info) 234 | 235 | def call_exception_handler(self, context): 236 | """Call the current event loop's exception handler. 237 | The context argument is a dict containing the following keys: 238 | - 'message': Error message; 239 | - 'exception' (optional): Exception object; 240 | - 'future' (optional): Future instance; 241 | - 'handle' (optional): Handle instance; 242 | - 'protocol' (optional): Protocol instance; 243 | - 'transport' (optional): Transport instance; 244 | - 'socket' (optional): Socket instance; 245 | - 'asyncgen' (optional): Asynchronous generator that caused 246 | the exception. 247 | New keys maybe introduced in the future. 248 | Note: do not overload this method in an event loop subclass. 249 | For custom exception handling, use the 250 | `set_exception_handler()` method. 251 | """ 252 | if self._exception_handler is None: 253 | try: 254 | self.default_exception_handler(context) 255 | except Exception: 256 | # Second protection layer for unexpected errors 257 | # in the default implementation, as well as for subclassed 258 | # event loops with overloaded "default_exception_handler". 259 | log.error('Exception in default exception handler', exc_info=True) 260 | else: 261 | try: 262 | self._exception_handler(self, context) 263 | except Exception as exc: 264 | # Exception in the user set custom exception handler. 265 | try: 266 | # Let's try default handler. 267 | self.default_exception_handler({ 268 | 'message': 'Unhandled error in exception handler', 269 | 'exception': exc, 270 | 'context': context, 271 | }) 272 | except Exception: 273 | # Guard 'default_exception_handler' in case it is 274 | # overloaded. 275 | log.error('Exception in default exception handler ' 276 | 'while handling an unexpected error ' 277 | 'in custom exception handler', 278 | exc_info=True) 279 | 280 | def get_debug(self): 281 | return self._debug 282 | 283 | def _run_once(self): 284 | # Handle 'later' callbacks that are ready. 285 | 286 | sched_count = len(self._scheduled) 287 | if (sched_count and self._timer_cancelled_count): 288 | # Remove delayed calls that were cancelled if their number 289 | # is too high 290 | new_scheduled = [] 291 | for handle in self._scheduled: 292 | if handle._cancelled: 293 | handle._scheduled = False 294 | else: 295 | new_scheduled.append(handle) 296 | 297 | heapq.heapify(new_scheduled) 298 | self._scheduled = new_scheduled 299 | self._timer_cancelled_count = 0 300 | else: 301 | # Remove delayed calls that were cancelled from head of queue. 302 | while self._scheduled and self._scheduled[0]._cancelled: 303 | self._timer_cancelled_count -= 1 304 | handle = heapq.heappop(self._scheduled) 305 | handle._scheduled = False 306 | 307 | # Process callbacks one at a time and advance time 308 | if self._scheduled and not self._ready: 309 | handle = self._scheduled[0] 310 | handle = heapq.heappop(self._scheduled) 311 | handle._scheduled = False 312 | self._time = handle._when 313 | self._ready.append(handle) 314 | 315 | ntodo = len(self._ready) 316 | for i in range(ntodo): 317 | handle = self._ready.popleft() 318 | if handle._cancelled: 319 | continue 320 | if self._debug: 321 | try: 322 | self._current_handle = handle 323 | t0 = self.time() 324 | handle._run() 325 | dt = self.time() - t0 326 | if dt >= self.slow_callback_duration: 327 | log.warning('Executing %s took %.3f seconds', _format_handle(handle), dt) 328 | finally: 329 | self._current_handle = None 330 | else: 331 | handle._run() 332 | handle = None # Needed to break cycles when an exception occurs. 333 | 334 | def _check_closed(self): 335 | if self._closed: 336 | raise RuntimeError('Event loop is closed') 337 | 338 | def _timer_handle_cancelled(self, handle): 339 | """Notification that a TimerHandle has been cancelled.""" 340 | if handle._scheduled: 341 | self._timer_cancelled_count += 1 342 | -------------------------------------------------------------------------------- /tests/helper_multi.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | from broqer import Publisher, NONE, Sink 6 | 7 | 8 | def check_get_method(operator, input_vector, output_vector): 9 | input_value, output_value = input_vector[0], output_vector[0] 10 | 11 | publishers = operator.dependencies 12 | 13 | for p, v in zip(publishers, input_value): 14 | p.get = mock.MagicMock(return_value=v) 15 | 16 | with pytest.raises(ValueError): 17 | operator.emit(input_value[0], who=None) 18 | 19 | assert operator.get() == output_value 20 | 21 | disposable = operator.subscribe(Sink()) 22 | 23 | for p, v in zip(publishers, input_value): 24 | if v is not NONE: 25 | operator.emit(v, who=p) # simulate emit on subscribe 26 | 27 | for p, v in zip(publishers, input_value): 28 | p.get = None # .get should not be called when operator has a subscription 29 | 30 | assert operator.get() == output_value # this should retrieve the internal state 31 | 32 | # after unsubscription it should remove the internal state and retrieve it 33 | # directly from orignator publisher via get 34 | disposable.dispose() 35 | 36 | for p, v in zip(publishers, input_value): 37 | p.get = mock.MagicMock(return_value=v) 38 | 39 | assert operator.get() == output_value 40 | 41 | for p in publishers: 42 | p.get.assert_called_once_with() 43 | 44 | 45 | def check_subscription(operator, input_vector, output_vector): 46 | m = mock.Mock() 47 | 48 | publishers = operator.dependencies 49 | 50 | # subscribe operator to publisher 51 | disposable = operator.subscribe(Sink(m)) 52 | 53 | for p in publishers: 54 | assert p.subscriptions == (operator,) # now it should be subscribed 55 | 56 | assert operator.dependencies == publishers 57 | 58 | # test emit on subscription 59 | if output_vector[0] is not NONE: 60 | m.assert_called_once_with(output_vector[0]) 61 | 62 | m.reset_mock() 63 | 64 | # test input_vector 65 | for input_value, output_value in zip(input_vector[1:], output_vector[1:]): 66 | for p, v in zip(publishers, input_value): 67 | if v is not NONE: 68 | p.notify(v) 69 | 70 | if output_value is NONE: 71 | m.assert_not_called() 72 | else: 73 | m.assert_called_with(output_value) 74 | m.reset_mock() 75 | 76 | # test input_vector with unsubscriptions between 77 | disposable.dispose() 78 | 79 | assert operator.dependencies == publishers 80 | 81 | for p, v in zip(publishers, input_vector[0]): 82 | p.notify(v) 83 | 84 | disposable = operator.subscribe(Sink(m)) 85 | 86 | for input_value, output_value in zip(input_vector, output_vector): 87 | m.reset_mock() 88 | 89 | for p, v in zip(publishers, input_value): 90 | if v is not NONE: 91 | p.notify(v) 92 | 93 | if output_value is NONE: 94 | m.assert_not_called() 95 | else: 96 | m.assert_called_with(output_value) 97 | 98 | 99 | def check_dependencies(operator, *_): 100 | publishers = operator.dependencies 101 | 102 | for p in publishers: 103 | assert p.subscriptions == () # operator should not be subscribed yet 104 | 105 | assert operator.subscriptions == () 106 | 107 | # subscribe to operator 108 | disposable1 = operator.subscribe(Sink()) 109 | 110 | assert p.subscriptions == (operator,) # operator should now be subscriped 111 | assert operator.subscriptions == (disposable1.subscriber,) 112 | 113 | # second subscribe to operator 114 | disposable2 = operator.subscribe(Sink(), prepend=True) 115 | 116 | assert p.subscriptions == (operator,) 117 | assert operator.subscriptions == (disposable2.subscriber, disposable1.subscriber) 118 | 119 | # remove first subscriber 120 | disposable1.dispose() 121 | 122 | assert p.subscriptions == (operator,) 123 | assert operator.subscriptions == (disposable2.subscriber,) 124 | 125 | # remove second subscriber 126 | disposable2.dispose() 127 | 128 | assert p.subscriptions == () # operator should now be subscriped 129 | assert operator.subscriptions == () 130 | 131 | -------------------------------------------------------------------------------- /tests/helper_single.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | from broqer import Publisher, NONE, op, Sink 6 | 7 | 8 | def check_get_method(operator, input_vector, output_vector): 9 | input_value, output_value = input_vector[0], output_vector[0] 10 | 11 | p = Publisher() 12 | p.get = mock.MagicMock(return_value=input_value) 13 | o = p | operator 14 | 15 | with pytest.raises(ValueError): 16 | o.emit(input_value, who=None) 17 | 18 | assert o.get() == output_value 19 | 20 | disposable = o.subscribe(Sink()) 21 | 22 | if input_value is not NONE: 23 | o.emit(input_value, who=p) # simulate emit on subscribe 24 | 25 | p.get = None # .get should not be called when operator has a subscription 26 | 27 | assert o.get() == output_value # this should retrieve the internal state 28 | 29 | # after unsubscription it should remove the internal state and retrieve it 30 | # directly from orignator publisher via get 31 | disposable.dispose() 32 | 33 | p.get = mock.MagicMock(return_value=input_value) 34 | 35 | assert o.get() == output_value 36 | 37 | p.get.assert_called_once_with() 38 | 39 | 40 | def check_subscription(operator, input_vector, output_vector): 41 | assert len(input_vector) == len(output_vector) 42 | 43 | m = mock.Mock() 44 | 45 | p = Publisher(input_vector[0]) 46 | o = p | operator 47 | 48 | # subscribe operator to publisher 49 | disposable = o.subscribe(Sink(m)) 50 | 51 | assert p.subscriptions == (o,) # now it should be subscribed 52 | assert o.dependencies == (p,) 53 | 54 | # test emit on subscription 55 | if output_vector[0] is not NONE: 56 | m.assert_called_once_with(output_vector[0]) 57 | 58 | m.reset_mock() 59 | 60 | # test input_vector 61 | for input_value, output_value in zip(input_vector[1:], output_vector[1:]): 62 | if input_value is not NONE: 63 | p.notify(input_value) 64 | 65 | if output_value is NONE: 66 | m.assert_not_called() 67 | else: 68 | m.assert_called_with(output_value) 69 | m.reset_mock() 70 | 71 | # test input_vector with unsubscriptions between 72 | disposable.dispose() 73 | assert o.dependencies == (p,) 74 | disposable = o.subscribe(Sink(m)) 75 | 76 | for input_value, output_value in zip(input_vector, output_vector): 77 | if input_value is not NONE: 78 | m.reset_mock() 79 | p.notify(input_value) 80 | if output_value is NONE: 81 | m.assert_not_called() 82 | else: 83 | m.assert_called_once_with(output_value) 84 | 85 | 86 | def check_dependencies(operator, *_): 87 | p = Publisher(NONE) 88 | 89 | assert len(p.subscriptions) == 0 90 | 91 | o = p | operator 92 | 93 | assert p.subscriptions == () # operator should not be subscribed yet 94 | assert o.dependencies == (p,) 95 | assert o.subscriptions == () 96 | 97 | # subscribe to operator 98 | disposable1 = o.subscribe(Sink()) 99 | 100 | assert p.subscriptions == (o,) # operator should now be subscriped 101 | assert o.dependencies == (p,) 102 | assert o.subscriptions == (disposable1.subscriber,) 103 | 104 | # second subscribe to operator 105 | disposable2 = o.subscribe(Sink(), prepend=True) 106 | 107 | assert p.subscriptions == (o,) 108 | assert o.dependencies == (p,) 109 | assert o.subscriptions == (disposable2.subscriber, disposable1.subscriber) 110 | 111 | # remove first subscriber 112 | disposable1.dispose() 113 | 114 | assert p.subscriptions == (o,) 115 | assert o.dependencies == (p,) 116 | assert o.subscriptions == (disposable2.subscriber,) 117 | 118 | # remove second subscriber 119 | disposable2.dispose() 120 | 121 | assert p.subscriptions == () # operator should now be subscriped 122 | assert o.dependencies == (p,) 123 | assert o.subscriptions == () 124 | -------------------------------------------------------------------------------- /tests/test_core_disposable.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from broqer import Disposable, SubscriptionDisposable, Publisher, Value 4 | 5 | 6 | def test_disposable(): 7 | """ Testing base class Disposable 8 | """ 9 | m = mock.Mock() 10 | 11 | class MyDisposable(Disposable): 12 | def dispose(self): 13 | m('disposed') 14 | 15 | d = MyDisposable() 16 | 17 | # test simple .dispose() call 18 | m.assert_not_called() 19 | d.dispose() 20 | m.assert_called_once_with('disposed') 21 | 22 | m.reset_mock() 23 | 24 | # test context manager 25 | with d: 26 | m.assert_not_called() 27 | 28 | m.assert_called_once_with('disposed') 29 | 30 | m.reset_mock() 31 | 32 | # test context manager creating the disposable 33 | with MyDisposable() as d2: 34 | m.assert_not_called() 35 | 36 | m.assert_called_once_with('disposed') 37 | 38 | 39 | def test_subscription_disposable(): 40 | p = Publisher() 41 | v = Value(0) 42 | 43 | assert len(p.subscriptions) == 0 44 | 45 | disposable = p.subscribe(v) 46 | 47 | assert len(p.subscriptions) == 1 48 | 49 | assert disposable.publisher is p 50 | assert disposable.subscriber is v 51 | 52 | disposable.dispose() 53 | 54 | assert len(p.subscriptions) == 0 55 | 56 | with p.subscribe(v): 57 | assert len(p.subscriptions) == 1 58 | 59 | assert len(p.subscriptions) == 0 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /tests/test_core_publisher.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | from broqer import Publisher, SubscriptionError, Value, NONE, Sink 6 | 7 | 8 | def test_subscribe(): 9 | """ Testing .subscribe() and .unsubscibe() of Publisher. """ 10 | s1 = Value() 11 | s2 = Value() 12 | 13 | publisher = Publisher() 14 | assert len(publisher.subscriptions) == 0 15 | 16 | # subscribe first subscriber 17 | d1 = publisher.subscribe(s1) 18 | assert any(s1 is s for s in publisher.subscriptions) 19 | assert not any(s2 is s for s in publisher.subscriptions) 20 | assert len(publisher.subscriptions) == 1 21 | 22 | # re - subscribe should fail 23 | with pytest.raises(SubscriptionError): 24 | publisher.subscribe(s1) 25 | 26 | # subscribe second subscriber 27 | d2 = publisher.subscribe(s2) 28 | assert len(publisher.subscriptions) == 2 29 | assert any(s1 is s for s in publisher.subscriptions) 30 | assert any(s2 is s for s in publisher.subscriptions) 31 | 32 | # unsubscribe both subscribers 33 | d2.dispose() 34 | assert len(publisher.subscriptions) == 1 35 | publisher.unsubscribe(s1) 36 | assert len(publisher.subscriptions) == 0 37 | 38 | # re - unsubscribing should fail 39 | with pytest.raises(SubscriptionError): 40 | d1.dispose() 41 | 42 | with pytest.raises(SubscriptionError): 43 | publisher.unsubscribe(s1) 44 | 45 | with pytest.raises(SubscriptionError): 46 | d2.dispose() 47 | 48 | 49 | @pytest.mark.asyncio 50 | async def test_await(event_loop): 51 | """ Test .as_future() method """ 52 | publisher = Publisher() 53 | 54 | # test omit_subscription=False when publisher has no state. This should 55 | # wait for the first state change 56 | event_loop.call_soon(publisher.notify, 1) 57 | assert await publisher.as_future(timeout=1, omit_subscription=False) == 1 58 | 59 | # omit_subscription is defaulted to True, so the current state should not 60 | # be returned, instead it should wait for the first change 61 | event_loop.call_soon(publisher.notify, 2) 62 | assert await publisher.as_future(timeout=1) == 2 63 | 64 | # when publisher has a state and omit_subscription is False it should 65 | # directly return publisher's state 66 | event_loop.call_soon(publisher.notify, 3) 67 | assert await publisher.as_future(timeout=1, omit_subscription=False) == 2 68 | 69 | assert (await publisher) == 2 70 | 71 | 72 | @pytest.mark.parametrize('init', [0, 'Test', {'a':1}, None, (1,2,3), [1,2,3]]) 73 | def test_get(init): 74 | """ Testing .get() method """ 75 | # testing .get() after initialisation 76 | p1 = Publisher(init) 77 | p2 = Publisher() 78 | p3 = Publisher(3) 79 | 80 | assert p1.get() is init 81 | assert p2.get() is NONE 82 | assert p3.get() is 3 83 | 84 | # testing .get() after notfiy 85 | p1.notify(1) 86 | p2.notify(init) 87 | p3.notify(init) 88 | 89 | assert p1.get() is 1 90 | assert p2.get() is init 91 | assert p3.get() is init 92 | 93 | 94 | @pytest.mark.parametrize('number_of_subscribers', range(3)) 95 | @pytest.mark.parametrize('init', [NONE, 0, 'Test', {'a':1}, None, (1,2,3), [1,2,3]]) 96 | def test_notify(init, number_of_subscribers): 97 | """ Testing .notify(v) method """ 98 | p = Publisher(init) 99 | m = mock.Mock() 100 | 101 | # subscribing Sinks to the publisher and test .notify on subscribe 102 | subscribers = [Sink(m, i) for i in range(number_of_subscribers)] 103 | 104 | m.assert_not_called() 105 | 106 | for s in subscribers: 107 | p.subscribe(s) 108 | 109 | if init is not NONE: 110 | m.assert_has_calls([mock.call(i, init) for i in range(number_of_subscribers)]) 111 | else: 112 | m.assert_not_called() 113 | 114 | m.reset_mock() 115 | 116 | # test .notify() for listening subscribers 117 | p.notify(1) 118 | 119 | m.assert_has_calls([mock.call(i, 1) for i in range(number_of_subscribers)]) 120 | 121 | m.reset_mock() 122 | 123 | # re- .notify() with the same value 124 | p.notify(1) 125 | 126 | m.assert_has_calls([mock.call(i, 1) for i in range(number_of_subscribers)]) 127 | 128 | 129 | def test_subscription_callback(): 130 | """ testing .register_on_subscription_callback() """ 131 | m = mock.Mock() 132 | 133 | p = Publisher() 134 | p.register_on_subscription_callback(m) 135 | 136 | # callback should be called on first subscription (with True as argument) 137 | d1 = p.subscribe(Sink()) 138 | m.assert_called_once_with(True) 139 | m.reset_mock() 140 | 141 | # it should not be called until the last subscriber is unsubscribing 142 | d2 = p.subscribe(Sink()) 143 | d1.dispose() 144 | 145 | m.assert_not_called() 146 | 147 | # callback should be called with False as argument on last unsubscription 148 | d2.dispose() 149 | m.assert_called_once_with(False) 150 | m.reset_mock() 151 | 152 | # callback should again be called with True as argument on first subscription 153 | d3 = p.subscribe(Sink()) 154 | m.assert_called_once_with(True) 155 | m.reset_mock() 156 | 157 | # after reseting callback it should no be called again 158 | p.register_on_subscription_callback(None) 159 | d3.dispose() 160 | p.subscribe(Sink()) 161 | m.assert_not_called() 162 | 163 | # check if callback is called when subscriber is already available 164 | p.register_on_subscription_callback(m) 165 | m.assert_called_once_with(True) 166 | 167 | 168 | 169 | 170 | def test_prepend(): 171 | """ Testing the prepend argument in .subscribe() """ 172 | m = mock.Mock() 173 | p = Publisher() 174 | 175 | s1 = Sink(m, 1) 176 | s2 = Sink(m, 2) 177 | s3 = Sink(m, 3) 178 | s4 = Sink(m, 4) 179 | 180 | p.subscribe(s1) 181 | p.subscribe(s2, prepend=True) # will be inserted before s1 182 | p.subscribe(s3) # will be appended after s1 183 | p.subscribe(s4, prepend=True) # will be inserted even before s2 184 | 185 | p.notify('test') 186 | 187 | # notification order should now be: s4, s2, s1, s3 188 | m.assert_has_calls([mock.call(4, 'test'), mock.call(2, 'test'), mock.call(1, 'test'), mock.call(3, 'test')]) 189 | 190 | 191 | def test_reset_state(): 192 | """ Test .reset_state() """ 193 | m = mock.Mock() 194 | p = Publisher() 195 | 196 | p.subscribe(Sink(m, 1)) 197 | 198 | m.assert_not_called() 199 | 200 | # test .reset_state() before and after subscribing 201 | p.reset_state() 202 | assert p.get() == NONE 203 | 204 | p.subscribe(Sink(m, 2)) 205 | 206 | m.assert_not_called() 207 | 208 | m.reset_mock() 209 | 210 | # test .reset_state() after notify 211 | p.notify('check') 212 | assert p.get() == 'check' 213 | m.assert_has_calls([mock.call(1, 'check'), mock.call(2, 'check')]) 214 | m.reset_mock() 215 | 216 | # test no subscribers get notified 217 | p.reset_state() 218 | m.assert_not_called() 219 | 220 | assert p.get() == NONE 221 | -------------------------------------------------------------------------------- /tests/test_core_publisher_operators.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | import pytest 3 | 4 | from broqer import Value, Publisher, Subscriber, Sink, op 5 | import operator 6 | 7 | def test_operator_with_publishers(): 8 | v1 = Value(0) 9 | v2 = Value(0) 10 | 11 | o = v1 + v2 12 | 13 | assert isinstance(o, Publisher) 14 | assert isinstance(o, Subscriber) 15 | assert o.get() == 0 16 | 17 | v1.emit(1) 18 | assert o.get() == 1 19 | 20 | assert len(o.subscriptions) == 0 21 | 22 | mock_sink = mock.Mock() 23 | 24 | o.subscribe(Sink(mock_sink)) 25 | assert len(o.subscriptions) == 1 26 | mock_sink.assert_called_once_with(1) 27 | mock_sink.reset_mock() 28 | 29 | v2.emit(3) 30 | mock_sink.assert_called_once_with(4) 31 | 32 | with pytest.raises(ValueError): 33 | o.emit(0, who=Publisher()) 34 | 35 | with pytest.raises(ValueError): 36 | Value(1).subscribe(o) 37 | 38 | def test_operator_with_constant(): 39 | v1 = Value(0) 40 | v2 = 1 41 | 42 | o = v1 + v2 43 | 44 | assert isinstance(o, Publisher) 45 | assert o.get() == 1 46 | 47 | v1.emit(1) 48 | assert o.get() == 2 49 | 50 | assert len(o.subscriptions) == 0 51 | 52 | mock_sink = mock.Mock() 53 | 54 | o.subscribe(Sink(mock_sink)) 55 | assert len(o.subscriptions) == 1 56 | mock_sink.assert_called_once_with(2) 57 | mock_sink.reset_mock() 58 | 59 | v1.emit(3) 60 | mock_sink.assert_called_once_with(4) 61 | 62 | with pytest.raises(TypeError): 63 | Value(1) | o 64 | 65 | with pytest.raises(ValueError): 66 | o.emit(0, who=Publisher()) 67 | 68 | def test_operator_with_constant_r(): 69 | v1 = 1 70 | v2 = Value(0) 71 | 72 | o = v1 - v2 73 | 74 | assert isinstance(o, Publisher) 75 | assert o.get() == 1 76 | 77 | v2.emit(1) 78 | assert o.get() == 0 79 | 80 | assert len(o.subscriptions) == 0 81 | 82 | mock_sink = mock.Mock() 83 | 84 | o.subscribe(Sink(mock_sink)) 85 | assert len(o.subscriptions) == 1 86 | mock_sink.assert_called_once_with(0) 87 | mock_sink.reset_mock() 88 | 89 | v2.emit(3) 90 | mock_sink.assert_called_once_with(-2) 91 | 92 | with pytest.raises(TypeError): 93 | Value(1) | o 94 | 95 | with pytest.raises(ValueError): 96 | o.emit(0, who=Publisher()) 97 | 98 | @pytest.mark.parametrize('operator, l_value, r_value, result', [ 99 | (operator.lt, 0, 1, True), (operator.lt, 1, 0, False), (operator.lt, 1, 1, False), (operator.lt, 1, 'foo', TypeError), 100 | (operator.le, 0, 1, True), (operator.le, 1, 0, False), (operator.le, 1, 1, True), (operator.le, 1, 'foo', TypeError), 101 | (operator.eq, 1, 0, False), (operator.eq, 1, 1, True), (operator.eq, 1, 'foo', False), (operator.eq, 'foo', 'foo', True), 102 | (operator.ne, 1, 0, True), (operator.ne, 1, 1, False), (operator.ne, 1, 'foo', True), (operator.ne, 'foo', 'foo', False), 103 | (operator.ge, 0, 1, False), (operator.ge, 1, 0, True), (operator.ge, 1, 1, True), (operator.ge, 1, 'foo', TypeError), 104 | (operator.gt, 0, 1, False), (operator.gt, 1, 0, True), (operator.gt, 1, 1, False), (operator.gt, 1, 'foo', TypeError), 105 | (operator.add, 0, 1, 1), (operator.add, 0, 1.1, 1.1), (operator.add, 'ab', 'cd', 'abcd'), (operator.add, 'ab', 1, TypeError), 106 | (operator.and_, 0, 0, 0), (operator.and_, 5, 1, 1), (operator.and_, 5, 4, 4), (operator.and_, 'ab', 1, TypeError), 107 | (operator.lshift, 0, 0, 0), (operator.lshift, 5, 1, 10), (operator.lshift, 5, 4, 80), (operator.lshift, 'ab', 1, TypeError), 108 | (operator.mod, 0, 0, ZeroDivisionError), (operator.mod, 5, 1, 0), (operator.mod, 5, 4, 1), (operator.mod, 1, 'ab', TypeError), 109 | # str%tuple is tested seperatly 110 | (operator.mul, 0, 0, 0), (operator.mul, 5, 1, 5), (operator.mul, 'ab', 4, 'abababab'), (operator.mul, 1, 'ab', 'ab'), (operator.mul, (1,2), 'ab', TypeError), 111 | (operator.pow, 0, 0, 1), (operator.pow, 5, 2, 25), (operator.pow, 'ab', 4, TypeError), 112 | (operator.rshift, 0, 0, 0), (operator.rshift, 5, 1, 2), (operator.rshift, 5, 4, 0), (operator.rshift, 'ab', 1, TypeError), 113 | (operator.sub, 0, 0, 0), (operator.sub, 5, 1, 4), (operator.sub, 1, 4, -3), (operator.sub, 'ab', 1, TypeError), 114 | (operator.xor, 0, 0, 0), (operator.xor, 5, 1, 4), (operator.xor, 5, 3, 6), (operator.xor, 'ab', 1, TypeError), 115 | # concat is tested seperatly 116 | # getitem is tested seperatly 117 | (operator.floordiv, 0, 0, ZeroDivisionError), (operator.floordiv, 5, 4, 1), (operator.floordiv, 5, 'ab', TypeError), 118 | (operator.truediv, 0, 0, ZeroDivisionError), (operator.truediv, 5, 4, 1.25), (operator.truediv, 5, 'ab', TypeError), 119 | ]) 120 | def test_with_publisher(operator, l_value, r_value, result): 121 | vl = Value(l_value) 122 | vr = Value(r_value) 123 | cl = l_value 124 | cr = r_value 125 | 126 | o1 = operator(vl, vr) 127 | o2 = operator(vl, cr) 128 | o3 = operator(cl, vr) 129 | try: 130 | o4 = operator(cl, cr) 131 | except Exception as e: 132 | assert isinstance(e, result) 133 | o4 = result # to pass the following test 134 | 135 | mock_sink_o3 = mock.Mock() 136 | 137 | try: 138 | o3.subscribe(Sink(mock_sink_o3)) 139 | except Exception as e: 140 | assert isinstance(e, result) 141 | 142 | for output in (o1, o2, o3): 143 | assert isinstance(output, Publisher) 144 | 145 | assert o4 == result 146 | 147 | for output in (o1, o2, o3): 148 | try: 149 | assert output.get() == result 150 | except Exception as e: 151 | assert isinstance(e, result) 152 | 153 | def test_wrong_comparision(): 154 | p1 = Publisher() 155 | p2 = Publisher() 156 | 157 | with pytest.raises(ValueError): 158 | assert p1 == p2 159 | 160 | with pytest.raises(ValueError): 161 | if p1 == p2: pass 162 | 163 | with pytest.raises(ValueError): 164 | if p1 != p2: pass 165 | 166 | with pytest.raises(ValueError): 167 | assert p2 in (p1, p2) 168 | 169 | with pytest.raises(ValueError): 170 | p1 in (p2, p2) 171 | 172 | with pytest.raises(ValueError): 173 | assert p1 not in (p2, p2) 174 | 175 | l = [p1, p2] 176 | with pytest.raises(ValueError): 177 | l.remove(p2) 178 | 179 | def test_mod_str(): 180 | v1 = Publisher('%.2f %d') 181 | v2 = Value((0,0)) 182 | 183 | o = v1%v2 184 | 185 | assert isinstance(o, Publisher) 186 | assert o.get() == '0.00 0' 187 | 188 | v2.emit((1,3)) 189 | assert o.get() == '1.00 3' 190 | 191 | def test_concat(): 192 | v1 = Publisher((1,2)) 193 | v2 = Value((0,0)) 194 | 195 | o = v1+v2 196 | 197 | assert isinstance(o, Publisher) 198 | assert o.get() == (1,2,0,0) 199 | 200 | v2.emit((1,3)) 201 | assert o.get() == (1,2,1,3) 202 | 203 | def test_getitem(): 204 | v1 = Value(('a','b','c')) 205 | v2 = Value(2) 206 | 207 | o = (v1[v2]) 208 | 209 | assert isinstance(o, Publisher) 210 | assert o.get() == 'c' 211 | 212 | v2.emit(1) 213 | assert o.get() == 'b' 214 | 215 | from collections import Counter 216 | import math 217 | 218 | @pytest.mark.parametrize('operator, value, result', [ 219 | (operator.neg, -5, 5), (operator.neg, 'ab', TypeError), (operator.pos, -5, -5), 220 | (operator.pos, Counter({'a':0, 'b':1}), Counter({'b':1})), (operator.abs, -5, 5), 221 | (operator.invert, 5, -6), (round, 5.2, 5), (round, 5.8, 6), (math.trunc, -5.2, -5), 222 | (math.floor, -5.2, -6), (math.ceil, -5.2, -5), 223 | (op.Not, 5, False), (op.Not, 0, True), (op.Not, True, False), 224 | (op.Not, False, True), 225 | (op.Str, 123, '123'), (op.Str, (1, 2), '(1, 2)'), (op.Str, 1.23, '1.23'), 226 | (op.Bool, 0, False), (op.Bool, 1, True), (op.Bool, 'False', True), (op.Bool, (1,2,3), True), 227 | (op.Bool, {}, False), (op.Bool, None, False), (op.Bool, 513.17, True), (op.Bool, 0.0, False), 228 | (op.Int, 1.99, 1), (op.Int, '123', 123), (op.Int, (1, 2, 3), TypeError), (op.Int, None, TypeError), 229 | (op.Float, 1, 1.0), (op.Float, '12.34', 12.34), (op.Float, 'abc', ValueError), 230 | (op.Repr, 123, '123'), (op.Repr, 'abc', '\'abc\''), (op.Repr, int, ''), 231 | (op.Len, (), 0), (op.Len, [1,2,3], 3), (op.Len, 'abcde', 5), (op.Len, None, TypeError), 232 | ]) 233 | def test_unary_operators(operator, value, result): 234 | v = Publisher(value) 235 | 236 | try: 237 | value_applied = operator(v).get() 238 | except Exception as e: 239 | assert isinstance(e, result) 240 | else: 241 | assert value_applied == result 242 | 243 | cb = mock.Mock() 244 | 245 | try: 246 | operator(v).subscribe(Sink(cb)) 247 | except Exception as e: 248 | assert isinstance(e, result) 249 | else: 250 | assert cb.mock_called_once_with(result) 251 | 252 | with pytest.raises(TypeError): 253 | Value(1) | operator(v) 254 | 255 | with pytest.raises(ValueError): 256 | operator(v).emit(0, who=Publisher()) 257 | 258 | def test_in_operator(): 259 | pi = Value(1) 260 | ci = 1 261 | 262 | pc = Value((1,2,3)) 263 | cc = (1, 2, 3) 264 | 265 | dut1 = op.In(pi, pc) 266 | dut2 = op.In(ci, pc) 267 | dut3 = op.In(pi, cc) 268 | with pytest.raises(TypeError): 269 | op.In(ci, cc) 270 | 271 | assert dut1.get() == True 272 | assert dut2.get() == True 273 | assert dut3.get() == True 274 | 275 | pi.emit('a') 276 | pc.emit((2.3, 'b')) 277 | 278 | assert dut1.get() == False 279 | assert dut2.get() == False 280 | assert dut3.get() == False 281 | 282 | def test_getattr_method(): 283 | p = Publisher('') 284 | p.inherit_type(str) 285 | 286 | dut1 = p.split() 287 | dut2 = p.split(',') 288 | dut3 = p.split(sep = '!') 289 | 290 | mock1 = mock.Mock() 291 | mock2 = mock.Mock() 292 | mock3 = mock.Mock() 293 | 294 | dut1.subscribe(Sink(mock1)) 295 | dut2.subscribe(Sink(mock2)) 296 | dut3.subscribe(Sink(mock3)) 297 | 298 | assert dut1.get() == [] 299 | assert dut2.get() == [''] 300 | assert dut3.get() == [''] 301 | 302 | mock1.assert_called_once_with([]) 303 | mock2.assert_called_once_with(['']) 304 | mock3.assert_called_once_with(['']) 305 | 306 | mock1.reset_mock() 307 | mock2.reset_mock() 308 | mock3.reset_mock() 309 | 310 | p.notify('This is just a test, honestly!') 311 | 312 | assert dut1.get() == ['This', 'is', 'just', 'a', 'test,', 'honestly!'] 313 | assert dut2.get() == ['This is just a test', ' honestly!'] 314 | assert dut3.get() == ['This is just a test, honestly', ''] 315 | 316 | mock1.assert_called_once_with(['This', 'is', 'just', 'a', 'test,', 'honestly!']) 317 | mock2.assert_called_once_with(['This is just a test', ' honestly!']) 318 | mock3.assert_called_once_with(['This is just a test, honestly', '']) 319 | 320 | def test_inherit_getattr(): 321 | p = Publisher('') 322 | p.inherit_type(str) 323 | 324 | dut = p.lower().split(' ') 325 | m = mock.Mock() 326 | dut.subscribe(Sink(m)) 327 | m.assert_called_once_with(['']) 328 | m.reset_mock() 329 | 330 | p.notify('This is a TEST') 331 | m.assert_called_once_with(['this', 'is', 'a', 'test']) 332 | 333 | def test_inherit_with_operators(): 334 | p = Publisher('') 335 | p.inherit_type(str) 336 | 337 | dut = op.Len(('abc' + p + 'ghi').upper()) 338 | m = mock.Mock() 339 | dut.subscribe(Sink(m)) 340 | m.assert_called_once_with(6) 341 | m.reset_mock() 342 | 343 | p.notify('def') 344 | m.assert_called_once_with(9) 345 | 346 | def test_getattr_attribute(): 347 | class Foo: 348 | a = None 349 | 350 | def __init__(self, a=5): 351 | self.a = a 352 | 353 | p = Publisher(Foo(3)) 354 | p.inherit_type(Foo) 355 | 356 | dut = p.a 357 | m = mock.Mock() 358 | dut.subscribe(Sink(m)) 359 | 360 | m.assert_called_once_with(3) 361 | assert dut.get() == 3 362 | m.reset_mock() 363 | 364 | p.notify(Foo(4)) 365 | 366 | assert dut.get() == 4 367 | 368 | m.assert_called_once_with(4) 369 | 370 | with pytest.raises(ValueError): 371 | dut.emit(0, who=Publisher()) 372 | 373 | with pytest.raises(AttributeError): 374 | dut.assnign(5) 375 | 376 | 377 | def test_getattr_without_inherit(): 378 | p = Publisher() 379 | 380 | class Foo: 381 | a = None 382 | 383 | def __init__(self, a=5): 384 | self.a = a 385 | 386 | with pytest.raises(AttributeError): 387 | dut = p.a 388 | 389 | with pytest.raises(AttributeError): 390 | p.assnign(5) 391 | 392 | @pytest.mark.parametrize('operator, values, result', [ 393 | (op.All, (False, False, False), False), 394 | (op.All, (False, True, False), False), 395 | (op.All, (True, True, True), True), 396 | (op.Any, (False, False, False), False), 397 | (op.Any, (False, True, False), True), 398 | (op.Any, (True, True, True), True), 399 | (op.BitwiseAnd, (0, 5, 15), 0), 400 | (op.BitwiseAnd, (7, 14, 255), 6), 401 | (op.BitwiseAnd, (3,), 3), 402 | (op.BitwiseOr, (0, 5, 8), 13), 403 | (op.BitwiseOr, (7, 14, 255), 255), 404 | (op.BitwiseOr, (3,), 3), 405 | ]) 406 | def test_multi_operators(operator, values, result): 407 | sources = [Publisher(v) for v in values] 408 | dut = operator(*sources) 409 | assert dut.get() == result 410 | -------------------------------------------------------------------------------- /tests/test_coro_queue.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | import asyncio 3 | 4 | import pytest 5 | 6 | from broqer.coro_queue import CoroQueue, AsyncMode 7 | from broqer import NONE 8 | 9 | 10 | @pytest.mark.parametrize('mode', AsyncMode) 11 | @pytest.mark.parametrize('args', [(), (None,), (1, 2, 3)]) 12 | @pytest.mark.asyncio 13 | async def test_simple_run(mode, args): 14 | excpected_result = args 15 | 16 | async def _coro(*args): 17 | await asyncio.sleep(0) 18 | return args 19 | 20 | coro_queue = CoroQueue(coro=_coro, mode=mode) 21 | 22 | # test the first call and wait for finish 23 | future = coro_queue.schedule(*args) 24 | assert (await future) == excpected_result 25 | 26 | if mode == AsyncMode.LAST_DISTINCT: 27 | # for LAST_DISTINCT, the following results will be broqer.NONE 28 | excpected_result = NONE 29 | 30 | # test a second time 31 | future = coro_queue.schedule(*args) 32 | assert (await future) == excpected_result 33 | 34 | await asyncio.sleep(0.001) 35 | 36 | # test a third time 37 | future = coro_queue.schedule(*args) 38 | assert (await future) == excpected_result 39 | 40 | 41 | @pytest.mark.parametrize('mode', AsyncMode) 42 | @pytest.mark.asyncio 43 | async def test_exception(mode): 44 | async def _coro(fail): 45 | if fail: 46 | raise ZeroDivisionError() 47 | 48 | coro_queue = CoroQueue(coro=_coro, mode=mode) 49 | 50 | # test the first call and wait for finish 51 | future = coro_queue.schedule(True) 52 | with pytest.raises(ZeroDivisionError): 53 | await future 54 | 55 | # test a second time 56 | future = coro_queue.schedule(False) 57 | assert (await future) == None 58 | 59 | # test a third time 60 | future = coro_queue.schedule(True) 61 | with pytest.raises(ZeroDivisionError): 62 | await future 63 | 64 | 65 | @pytest.mark.asyncio 66 | async def test_concurrent(): 67 | callback = mock.Mock() 68 | event = asyncio.Event() 69 | 70 | async def _coro(index): 71 | callback(f'Start {index}') 72 | await event.wait() 73 | callback(f'End {index}') 74 | return index 75 | 76 | coro_queue = CoroQueue(coro=_coro, mode=AsyncMode.CONCURRENT) 77 | 78 | # make two concurrent calls 79 | result1 = coro_queue.schedule(1) 80 | result2 = coro_queue.schedule(2) 81 | 82 | await asyncio.sleep(0) 83 | 84 | callback.assert_has_calls([mock.call('Start 1'), mock.call('Start 2')]) 85 | callback.reset_mock() 86 | 87 | event.set() 88 | await asyncio.sleep(0.001) 89 | callback.assert_has_calls([mock.call('End 1'), mock.call('End 2')], any_order=True) 90 | 91 | assert result1.result() == 1 92 | assert result2.result() == 2 93 | 94 | # make a non-concurrent call 95 | event.set() 96 | assert (await coro_queue.schedule(3)) == 3 97 | 98 | 99 | @pytest.mark.parametrize('wait', [True, False]) 100 | @pytest.mark.asyncio 101 | async def test_interrupt(wait): 102 | callback = mock.Mock() 103 | event = asyncio.Event() 104 | 105 | async def _coro(index): 106 | callback(f'Start {index}') 107 | await event.wait() 108 | callback(f'End {index}') 109 | return index 110 | 111 | coro_queue = CoroQueue(coro=_coro, mode=AsyncMode.INTERRUPT) 112 | 113 | # make two concurrent calls, the first one will be interrupted (canceld) 114 | result1 = coro_queue.schedule(1) 115 | if wait: 116 | await asyncio.sleep(0.0001) 117 | result2 = coro_queue.schedule(2) 118 | 119 | await asyncio.sleep(0) 120 | 121 | callback.assert_called_with('Start 2') 122 | callback.reset_mock() 123 | 124 | event.set() 125 | await asyncio.sleep(0.001) 126 | callback.assert_called_once_with('End 2') 127 | 128 | assert result1.result() == NONE 129 | assert result2.result() == 2 130 | 131 | # make a non-concurrent call 132 | event.set() 133 | assert (await coro_queue.schedule(3)) == 3 134 | 135 | 136 | @pytest.mark.asyncio 137 | async def test_queue(): 138 | callback = mock.Mock() 139 | event = asyncio.Event() 140 | 141 | async def _coro(index): 142 | callback(f'Start {index}') 143 | await event.wait() 144 | callback(f'End {index}') 145 | return index 146 | 147 | coro_queue = CoroQueue(coro=_coro, mode=AsyncMode.QUEUE) 148 | 149 | # make two concurrent calls 150 | result1 = coro_queue.schedule(1) 151 | result2 = coro_queue.schedule(2) 152 | 153 | await asyncio.sleep(0.001) 154 | event.set() 155 | event.clear() 156 | 157 | await asyncio.sleep(0.001) 158 | 159 | callback.assert_has_calls([mock.call('Start 1'), mock.call('End 1'), mock.call('Start 2')]) 160 | 161 | assert result1.result() == 1 162 | assert not result2.done() 163 | 164 | callback.reset_mock() 165 | 166 | event.set() 167 | event.clear() 168 | await asyncio.sleep(0.001) 169 | 170 | callback.assert_called_once_with('End 2') 171 | 172 | # make a non-concurrent call 173 | event.set() 174 | assert (await coro_queue.schedule(3)) == 3 175 | 176 | 177 | @pytest.mark.parametrize('distinct', [True, False]) 178 | @pytest.mark.asyncio 179 | async def test_last(distinct): 180 | callback = mock.Mock() 181 | event = asyncio.Event() 182 | 183 | async def _coro(index): 184 | callback(f'Start {index}') 185 | await event.wait() 186 | callback(f'End {index}') 187 | return index 188 | 189 | mode = AsyncMode.LAST_DISTINCT if distinct else AsyncMode.LAST 190 | coro_queue = CoroQueue(coro=_coro, mode=mode) 191 | 192 | # make three concurrent calls 193 | result1 = coro_queue.schedule(1) 194 | result2 = coro_queue.schedule(2) 195 | result3 = coro_queue.schedule(1) 196 | 197 | await asyncio.sleep(0.001) 198 | event.set() 199 | await asyncio.sleep(0.001) 200 | 201 | expected_calls = [mock.call('Start 1'), mock.call('End 1')] 202 | 203 | if not distinct: 204 | expected_calls += [mock.call('Start 1'), mock.call('End 1')] 205 | 206 | callback.assert_has_calls(expected_calls) 207 | 208 | assert result1.result() == 1 209 | assert result2.result() == NONE 210 | assert result3.result() == (NONE if distinct else 1) 211 | 212 | # make a non-concurrent call 213 | event.set() 214 | assert (await coro_queue.schedule(3)) == 3 215 | 216 | 217 | @pytest.mark.parametrize('wait', [True, False]) 218 | @pytest.mark.asyncio 219 | async def test_skip(wait): 220 | callback = mock.Mock() 221 | event = asyncio.Event() 222 | 223 | async def _coro(index): 224 | callback(f'Start {index}') 225 | await event.wait() 226 | callback(f'End {index}') 227 | return index 228 | 229 | coro_queue = CoroQueue(coro=_coro, mode=AsyncMode.SKIP) 230 | 231 | # make two concurrent calls, the first one will be interrupted (canceld) 232 | result1 = coro_queue.schedule(1) 233 | if wait: 234 | await asyncio.sleep(0.0001) 235 | result2 = coro_queue.schedule(2) 236 | 237 | await asyncio.sleep(0) 238 | 239 | callback.assert_called_with('Start 1') 240 | callback.reset_mock() 241 | 242 | event.set() 243 | await asyncio.sleep(0.001) 244 | callback.assert_called_once_with('End 1') 245 | 246 | assert result1.result() == 1 247 | assert result2.result() == NONE 248 | 249 | # make a non-concurrent call 250 | event.set() 251 | assert (await coro_queue.schedule(3)) == 3 -------------------------------------------------------------------------------- /tests/test_error_handler.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from unittest.mock import Mock 3 | 4 | import pytest 5 | 6 | from broqer.error_handler import default_error_handler 7 | 8 | 9 | def test_default(capsys): 10 | try: 11 | 0/0 12 | except: 13 | exc = sys.exc_info() 14 | 15 | with pytest.raises(ZeroDivisionError): 16 | default_error_handler(*exc) 17 | 18 | 19 | def test_set_errorhandler(capsys): 20 | mock = Mock() 21 | default_error_handler.set(mock) 22 | 23 | try: 24 | 0/0 25 | except: 26 | exc = sys.exc_info() 27 | 28 | default_error_handler(*exc) 29 | 30 | mock.assert_called_once_with(*exc) 31 | 32 | captured = capsys.readouterr() 33 | 34 | assert captured.err == '' 35 | assert captured.out == '' 36 | 37 | # reset 38 | default_error_handler.reset() 39 | 40 | with pytest.raises(ZeroDivisionError): 41 | default_error_handler(*exc) 42 | -------------------------------------------------------------------------------- /tests/test_op_bitwise.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from unittest.mock import Mock 3 | 4 | import pytest 5 | 6 | from broqer.op.bitwise import BitwiseCombineLatest, map_bit 7 | from broqer import Publisher, NONE, Sink 8 | from tests import helper_multi, helper_single 9 | 10 | test_vector = [ 11 | # init, bit_value_map, input_vector, output_vector 12 | (0, [(0, True), (1, False), (4, True)], 13 | [(NONE, True, NONE)], 14 | [17, 19]), 15 | (0, [(0, True), (1, NONE), (4, True)], 16 | [(NONE, True, NONE)], 17 | [17, 19]), 18 | (~0, [(0, True)], 19 | [(True,), (False,), (True,)], 20 | [~0, ~0, ~1, ~0]), 21 | (0xAA, [(0, False), (1, False), (7, True)], 22 | [(NONE, True, NONE), (True, True, True), (False, NONE, NONE), (False, False, False)], 23 | (0xA8, 0xAA, 0xAB, 0xAA, 0x28)) 24 | ] 25 | @pytest.mark.parametrize('method', [helper_multi.check_get_method, helper_multi.check_subscription, helper_multi.check_dependencies]) 26 | @pytest.mark.parametrize('init,bit_value_map,input_vector,output_vector', test_vector) 27 | def test_bitwise_combine_latest(method, init, bit_value_map, input_vector, output_vector): 28 | publisher_bit_mapping = OrderedDict([(Publisher(v), b) for b, v in bit_value_map]) 29 | i_vector = [tuple(v for k, v in bit_value_map)] + input_vector 30 | 31 | operator = BitwiseCombineLatest(publisher_bit_mapping, init) 32 | 33 | method(operator, i_vector, output_vector) 34 | 35 | 36 | def test_bitwise_uninitialized_publishers(): 37 | m = Mock() 38 | p = Publisher() 39 | b = BitwiseCombineLatest({p: 0}) 40 | b.subscribe(Sink(m)) 41 | m.assert_called_once_with(0) 42 | 43 | test_vector = [ 44 | # bit_index, input_vector, output_vector 45 | (0, [0, 1, 2, 3, ~0], [False, True, False, True, True]), 46 | (128, [~0, 0, 1<<128, (1<<128)-1], [True, False, True, False]) 47 | ] 48 | @pytest.mark.parametrize('method', [helper_single.check_get_method, helper_single.check_subscription, helper_single.check_dependencies]) 49 | @pytest.mark.parametrize('bit_index, input_vector,output_vector', test_vector) 50 | def test_map_bit(method, bit_index, input_vector, output_vector): 51 | operator = map_bit(bit_index) 52 | 53 | method(operator, input_vector, output_vector) 54 | -------------------------------------------------------------------------------- /tests/test_op_combine_latest.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | from itertools import product 3 | 4 | import pytest 5 | 6 | from broqer import Publisher, NONE, op, Sink 7 | from tests.helper_multi import check_get_method, check_subscription, check_dependencies 8 | 9 | 10 | test_vector = [ 11 | # o, args, kwargs, input_vector, output_vector 12 | (op.CombineLatest, (), {}, 13 | ((1, 2), (NONE, 3), (2, NONE), (2, 3)), 14 | ((1, 2), (1, 3), (2, 3), (2, 3), (2, 3))), 15 | (op.CombineLatest, (), {}, 16 | ((1,), (2,), (2,), (3,)), 17 | (((1,), (2,), (2,), (3,)))), 18 | (op.CombineLatest, (), {}, 19 | ((1, NONE, NONE, NONE), (NONE, 2, NONE, NONE), (NONE, 3, NONE, NONE), (NONE, NONE, 4, 5)), 20 | ((NONE, NONE, NONE, (1, 3, 4, 5)))), 21 | (op.CombineLatest, (), {'map_': lambda a, b: a+b}, 22 | ((1, 1), (NONE, 2), (1, NONE), (NONE, -5)), 23 | (2, 3, 3, -4)), 24 | (op.CombineLatest, (), {'map_': lambda a, b: a > b}, 25 | ((1, 1), (NONE, 2), (1, NONE), (NONE, -5)), 26 | (False, False, False, True)), 27 | (op.CombineLatest, (), {'map_': lambda a, b: NONE if a > b else a - b}, 28 | ((0, 0), (NONE, 1), (1, NONE), (NONE, 0), (0, NONE)), 29 | (0, -1, 0, NONE, 0)), 30 | (op.build_combine_latest(lambda a, b: a + b), (), {}, 31 | ((1, 1), (NONE, 2), (1, NONE), (NONE, -5)), 32 | (2, 3, 3, -4)), 33 | (op.build_combine_latest(map_=lambda a, b: a + b), (), {}, 34 | ((1, 1), (NONE, 2), (1, NONE), (NONE, -5)), 35 | (2, 3, 3, -4)), 36 | (lambda p1, p2, f: op.build_combine_latest()(f)(p1, p2), (lambda a, b: a + b,), {}, 37 | ((1, 1), (NONE, 2), (1, NONE), (NONE, -5)), 38 | (2, 3, 3, -4)), 39 | ] 40 | @pytest.mark.parametrize('method', [check_get_method, check_subscription, check_dependencies]) 41 | @pytest.mark.parametrize('o,args,kwargs,input_vector,output_vector', test_vector) 42 | def test_operator(method, o, args, kwargs, input_vector, output_vector): 43 | operator = o(*(Publisher(v) for v in input_vector[0]), *args, **kwargs) 44 | 45 | method(operator, input_vector, output_vector) 46 | 47 | 48 | @pytest.mark.parametrize('factory', [ 49 | lambda p1, p2, p3, emit_on: op.CombineLatest(p1, p2, p3, emit_on=emit_on), 50 | lambda p1, p2, p3, emit_on: op.build_combine_latest(lambda *n: n, emit_on=emit_on)(p1, p2, p3), 51 | ]) 52 | @pytest.mark.parametrize('flags', list(product([False, True], repeat=3))) 53 | def test_emit_on(factory, flags): 54 | m = mock.Mock() 55 | 56 | publishers = [Publisher(None) for i in range(3)] 57 | 58 | emit_on = [p for p, f in zip(publishers, flags) if f] 59 | if len(emit_on) == 0: 60 | emit_on = None 61 | elif len(emit_on) == 1: 62 | emit_on = emit_on[0] 63 | 64 | operator = factory(*publishers, emit_on=emit_on) 65 | operator.subscribe(Sink(m)) 66 | 67 | result = [None] * 3 68 | 69 | if flags.count(True) == 0: 70 | flags = [True] * 3 71 | 72 | for i, f in enumerate(flags): 73 | m.reset_mock() 74 | 75 | result[i] = i 76 | 77 | publishers[i].notify(i) 78 | 79 | if f: 80 | m.assert_called_once_with(tuple(result)) 81 | else: 82 | m.assert_not_called() 83 | 84 | @pytest.mark.parametrize('subscribe', [True, False]) 85 | def test_unsubscibe(subscribe): 86 | m = mock.Mock() 87 | 88 | p1, p2 = Publisher(NONE), Publisher(NONE) 89 | 90 | operator = op.CombineLatest(p1, p2) 91 | 92 | if subscribe: 93 | operator.subscribe(Sink()) 94 | 95 | assert operator.get() == NONE 96 | 97 | p1.notify(1) 98 | assert operator.get() == NONE 99 | 100 | p2.notify(2) 101 | assert operator.get() == (1, 2) 102 | -------------------------------------------------------------------------------- /tests/test_op_filter.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from broqer import op, NONE, Value 4 | from tests.helper_single import check_get_method, check_subscription, \ 5 | check_dependencies 6 | 7 | 8 | test_vector = [ 9 | # o, args, kwargs, input_vector, output_vector 10 | (op.Filter, (lambda v: v == 0,), {}, 11 | (1, 2, 0, 0.0, None), 12 | (NONE, NONE, 0, 0.0, NONE)), 13 | (op.Filter, (lambda v: v == 0,), {}, 14 | (0, ), 15 | (0, )), 16 | (op.Filter, (lambda a, b: a == b, 2), {}, 17 | (0, 2, 2.0, 1), 18 | (NONE, 2, 2.0, NONE)), 19 | (op.Filter, (lambda a, b, c: a + b == c, 2), {'unpack': True}, 20 | ((0, 2), (0, 3), (-1.0, 1.0)), 21 | ((0, 2), NONE, (-1.0, 1.0))), 22 | (op.Filter, (lambda a, b, c: a + b == c, 2), {'unpack': True}, 23 | ((0, 3),), 24 | (NONE,)), 25 | (op.Filter, (lambda a, b, c: a + b == c,), {'unpack': True, 'c': 2}, 26 | ((0, 2), (0, 3), (-1.0, 3.0)), 27 | ((0, 2), NONE, (-1.0, 3.0))), 28 | (op.build_filter_factory(lambda v: v == 0), (), {}, 29 | (1, 2, 0, 0.0, None), 30 | (NONE, NONE, 0, 0.0, NONE)), 31 | (op.build_filter_factory(unpack=False)(lambda v: v == 0), (), {}, 32 | (1, 2, 0, 0.0, None), 33 | (NONE, NONE, 0, 0.0, NONE)), 34 | (op.build_filter_factory(lambda a, b, c: a + b == c, unpack=True), (2,), {}, 35 | ((0, 2), (0, 3), (-1.0, 1.0)), 36 | ((0, 2), NONE, (-1.0, 1.0))), 37 | (lambda: op.build_filter(lambda v: v == 0), (), {}, 38 | (1, 2, 0, 0.0, None), 39 | (NONE, NONE, 0, 0.0, NONE)), 40 | (lambda: op.build_filter(unpack=False)(lambda v: v == 0), (), {}, 41 | (1, 2, 0, 0.0, None), 42 | (NONE, NONE, 0, 0.0, NONE)), 43 | ] 44 | 45 | 46 | @pytest.mark.parametrize('method', [check_get_method, check_subscription, 47 | check_dependencies]) 48 | @pytest.mark.parametrize('o,args,kwargs,input_vector,output_vector', 49 | test_vector) 50 | def test_operator(method, o, args, kwargs, input_vector, output_vector): 51 | operator = o(*args, **kwargs) 52 | 53 | method(operator, input_vector, output_vector) 54 | 55 | 56 | test_vector = [ 57 | # o, input_vector, output_vector 58 | (op.EvalTrue, 59 | (1, 2, 0, 0.0, None, False, [1]), 60 | (1, 2, NONE, NONE, NONE, NONE, [1])), 61 | (op.EvalTrue, 62 | (0, ), 63 | (NONE, )), 64 | (op.EvalFalse, 65 | (1, 2, 0, 0.0, None, False, [1]), 66 | (NONE, NONE, 0, 0.0, None, False, NONE)), 67 | (op.EvalFalse, 68 | (0, ), 69 | (0, )), 70 | ] 71 | 72 | 73 | @pytest.mark.parametrize('method', [check_get_method, check_subscription, 74 | check_dependencies]) 75 | @pytest.mark.parametrize('o,input_vector,output_vector', 76 | test_vector) 77 | def test_true_false(method, o, input_vector, output_vector): 78 | method(o(), input_vector, output_vector) 79 | 80 | 81 | def test_filter_factory_keyword(): 82 | m = op.build_filter_factory(lambda v: v) 83 | v = Value() 84 | 85 | with pytest.raises(TypeError, match='"unpack" has to be defined by decorator'): 86 | o = v | m(unpack=True) 87 | -------------------------------------------------------------------------------- /tests/test_op_map.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from broqer import op, NONE, Value, Publisher 4 | from tests.helper_single import check_get_method, check_subscription, check_dependencies 5 | 6 | 7 | def add(a, b, c, constant=0): 8 | return a + b * 2 + c * 3 + constant * 4 9 | 10 | 11 | test_vector = [ 12 | # o, args, kwargs, input_vector, output_vector 13 | (op.Map, (lambda v: v+1,), {}, [NONE, 1, 2], [NONE, 2, 3]), 14 | (op.Map, (lambda v: NONE,), {}, [1, 2], [NONE, NONE]), 15 | (op.Map, (), {'function': lambda v: v+1}, [NONE, 1, 2], [NONE, 2, 3]), 16 | (op.Map, (lambda a, b: a+b,), {'unpack': True}, [NONE, (1, 1), (2, 2)], [NONE, 2, 4]), 17 | (op.Map, (add, 1), {'unpack': True, 'constant': 2}, [NONE, (1, 1), (2, 2)], [NONE, 14, 19]), 18 | (op.build_map_factory(lambda v, o: v+o,), (1,), {}, [NONE, 1, 2], [NONE, 2, 3]), 19 | (op.build_map_factory(lambda a, b: a+b, unpack=True), (), {}, [(1, 1), (2, 2)], [2, 4]), 20 | (op.build_map_factory(unpack=True)(lambda a, b: a+b), (), {}, [(1, 1), (2, 2)], [2, 4]), 21 | (lambda: op.build_map(lambda v: v+1,), (), {}, [NONE, 1, 2], [NONE, 2, 3]), 22 | (lambda: op.build_map(unpack=False)(lambda v: v+1,), (), {}, [NONE, 1, 2], [NONE, 2, 3]), 23 | ] 24 | 25 | @pytest.mark.parametrize('method', [check_get_method, check_subscription, check_dependencies]) 26 | @pytest.mark.parametrize('o,args,kwargs,input_vector,output_vector', test_vector) 27 | def test_operator(method, o, args, kwargs, input_vector, output_vector): 28 | operator = o(*args, **kwargs) 29 | 30 | method(operator, input_vector, output_vector) 31 | 32 | 33 | def test_map_factory_keyword(): 34 | m = op.build_map_factory(lambda v: v+1) 35 | v = Value() 36 | with pytest.raises(TypeError, match='"unpack" has to be defined by decorator'): 37 | o = v | m(unpack=True) 38 | 39 | def test_two_maps(): 40 | p = Publisher(1) 41 | m1 = op.Map(lambda v: v + 1) 42 | m2 = op.Map(lambda v: v * 2) 43 | p2 = p | m1 | m2 44 | assert p2.get() == 4 45 | -------------------------------------------------------------------------------- /tests/test_op_on_emit_future.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import pytest 3 | 4 | from broqer import Publisher, OnEmitFuture 5 | 6 | from .eventloop import VirtualTimeEventLoop 7 | 8 | @pytest.fixture() 9 | def event_loop(): 10 | loop = VirtualTimeEventLoop() 11 | yield loop 12 | loop.close() 13 | 14 | @pytest.mark.asyncio 15 | async def test_publisher(): 16 | p = Publisher(1) 17 | future = OnEmitFuture(p, timeout=1) 18 | 19 | assert future.result() == 1 20 | 21 | p.notify(2) 22 | assert future.result() == 1 23 | 24 | @pytest.mark.asyncio 25 | async def test_timeout(): 26 | p = Publisher() 27 | future = OnEmitFuture(p, timeout=0.01) 28 | await asyncio.sleep(0.05) 29 | 30 | with pytest.raises(asyncio.TimeoutError): 31 | future.result() 32 | 33 | @pytest.mark.asyncio 34 | async def test_cancel(): 35 | p = Publisher() 36 | future = OnEmitFuture(p, timeout=0.01) 37 | future.cancel() 38 | p.notify(1) 39 | 40 | with pytest.raises(asyncio.CancelledError): 41 | future.result() 42 | 43 | @pytest.mark.asyncio 44 | async def test_wrong_source(): 45 | p = Publisher() 46 | on_emit_future = OnEmitFuture(p) 47 | 48 | with pytest.raises(ValueError): 49 | on_emit_future.emit(0, who=Publisher()) 50 | -------------------------------------------------------------------------------- /tests/test_op_sink.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | import pytest 3 | 4 | from broqer import Disposable, Publisher, Value, Sink, Trace, build_sink, \ 5 | build_sink_factory 6 | 7 | 8 | @pytest.mark.parametrize('operator_cls', [Sink, Trace]) 9 | def test_sink(operator_cls): 10 | cb = mock.Mock() 11 | 12 | s = Value() 13 | sink_instance = s.subscribe(operator_cls(cb)) 14 | assert isinstance(sink_instance, Disposable) 15 | 16 | assert not cb.called 17 | assert len(s.subscriptions) == 1 18 | 19 | # test various emits on source 20 | with pytest.raises(TypeError): 21 | s.emit() 22 | 23 | s.emit(None) 24 | cb.assert_called_with(None) 25 | 26 | s.emit(1) 27 | cb.assert_called_with(1) 28 | 29 | s.emit((1, 2)) 30 | cb.assert_called_with((1, 2)) 31 | 32 | # testing dispose()s 33 | cb.reset_mock() 34 | 35 | sink_instance.dispose() 36 | assert len(s.subscriptions) == 0 37 | 38 | s.emit(1) 39 | assert not cb.called 40 | 41 | 42 | @pytest.mark.parametrize('operator_cls', [Sink, Trace]) 43 | def test_sink2(operator_cls): 44 | cb = mock.Mock() 45 | 46 | s = Value() 47 | sink_instance = s.subscribe(operator_cls(cb, unpack=True)) 48 | assert isinstance(sink_instance, Disposable) 49 | 50 | # test various emits on source 51 | with pytest.raises(TypeError): 52 | s.emit() 53 | 54 | with pytest.raises(TypeError): 55 | s.emit(1) 56 | 57 | cb.assert_not_called() 58 | 59 | s.emit((1, 2)) 60 | cb.assert_called_with(1, 2) 61 | 62 | 63 | @pytest.mark.parametrize('operator_cls', [Sink, Trace]) 64 | def test_sink_without_function(operator_cls): 65 | s = Value() 66 | sink_instance = s.subscribe(operator_cls()) 67 | assert isinstance(sink_instance, Disposable) 68 | assert len(s.subscriptions) == 1 69 | 70 | s.emit(1) 71 | 72 | 73 | @pytest.mark.parametrize('operator', [Sink, Trace]) 74 | def test_sink_on_subscription(operator): 75 | cb = mock.Mock() 76 | 77 | s = Value(0) 78 | sink_instance = s.subscribe(operator(cb)) 79 | assert isinstance(sink_instance, Disposable) 80 | 81 | cb.assert_called_with(0) 82 | assert len(s.subscriptions) == 1 83 | 84 | s.emit(1) 85 | cb.assert_called_with(1) 86 | 87 | # testing dispose() 88 | cb.reset_mock() 89 | 90 | sink_instance.dispose() 91 | assert len(s.subscriptions) == 0 92 | 93 | s.emit(1) 94 | assert not cb.called 95 | 96 | 97 | @pytest.mark.parametrize('operator_cls', [Sink, Trace]) 98 | def test_sink_partial(operator_cls): 99 | cb = mock.Mock() 100 | 101 | s = Value() 102 | sink_instance = s.subscribe(operator_cls(cb, 1, 2, 3, a=1)) 103 | assert isinstance(sink_instance, Disposable) 104 | 105 | assert not cb.called 106 | assert len(s.subscriptions) == 1 107 | 108 | # test various emits on source 109 | s.emit(None) 110 | cb.assert_called_with(1, 2, 3, None, a=1) 111 | 112 | s.emit(1) 113 | cb.assert_called_with(1, 2, 3, 1, a=1) 114 | 115 | s.emit((1, 2)) 116 | cb.assert_called_with(1, 2, 3, (1, 2), a=1) 117 | 118 | # testing dispose() 119 | cb.reset_mock() 120 | 121 | sink_instance.dispose() 122 | assert len(s.subscriptions) == 0 123 | 124 | s.emit(1) 125 | assert not cb.called 126 | 127 | 128 | @pytest.mark.parametrize( 129 | 'build_kwargs, init_args, init_kwargs, ref_args, ref_kwargs, exception', 130 | [(None, (), {}, (), {}, None), 131 | ({'unpack': True}, (), {}, (), {'unpack': True}, None), 132 | ({'unpack': False}, (), {}, (), {'unpack': False}, None), 133 | ({'unpack': False}, (), {'unpack': False}, (), {'unpack': False}, 134 | TypeError), 135 | (None, (1,), {'a': 2}, (1,), {'unpack': False, 'a': 2}, None), 136 | ({'unpack': True}, (1,), {'a': 2}, (1,), {'unpack': True, 'a': 2}, None), 137 | ({'unpack': False}, (1,), {'a': 2}, (1,), {'unpack': False, 'a': 2}, 138 | None), 139 | ({'foo': 1}, (), {}, (), {}, TypeError), 140 | ]) 141 | def test_build(build_kwargs, init_args, init_kwargs, ref_args, ref_kwargs, 142 | exception): 143 | mock_cb = mock.Mock() 144 | ref_mock_cb = mock.Mock() 145 | 146 | reference = Sink(ref_mock_cb, *ref_args, **ref_kwargs) 147 | 148 | try: 149 | if build_kwargs is None: 150 | dut = build_sink_factory(mock_cb)(*init_args, **init_kwargs) 151 | else: 152 | dut = build_sink_factory(**build_kwargs)(mock_cb)(*init_args, 153 | **init_kwargs) 154 | except Exception as e: 155 | assert isinstance(e, exception) 156 | return 157 | else: 158 | assert exception is None 159 | 160 | assert dut._unpack == reference._unpack 161 | 162 | v = Publisher((1, 2)) 163 | v.subscribe(dut) 164 | v.subscribe(reference) 165 | 166 | assert mock_cb.mock_calls == ref_mock_cb.mock_calls 167 | assert len(mock_cb.mock_calls) == 1 168 | -------------------------------------------------------------------------------- /tests/test_op_throttle.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import pytest 3 | from unittest import mock 4 | 5 | from broqer import NONE, Sink, Publisher, op 6 | from broqer.op import Throttle 7 | 8 | from .eventloop import VirtualTimeEventLoop 9 | 10 | 11 | @pytest.fixture() 12 | def event_loop(): 13 | loop = VirtualTimeEventLoop() 14 | yield loop 15 | loop.close() 16 | 17 | 18 | @pytest.mark.asyncio 19 | async def test_throttle_errorhandler(): 20 | from broqer import default_error_handler 21 | 22 | p = Publisher() 23 | mock_sink = mock.Mock() 24 | mock_error_handler = mock.Mock() 25 | 26 | default_error_handler.set(mock_error_handler) 27 | 28 | throttle = p | op.Throttle(0.1) 29 | disposable = throttle.subscribe(Sink(mock_sink)) 30 | 31 | mock_sink.side_effect = (None, ZeroDivisionError('FAIL')) 32 | 33 | # test error_handler 34 | p.notify(1) 35 | await asyncio.sleep(0.05) 36 | mock_sink.assert_called_once_with(1) 37 | p.notify(2) 38 | await asyncio.sleep(0.1) 39 | mock_error_handler.assert_called_once_with(ZeroDivisionError, mock.ANY, mock.ANY) 40 | mock_sink.assert_has_calls((mock.call(1), mock.call(2))) 41 | 42 | mock_sink.reset_mock() 43 | 44 | 45 | @pytest.mark.asyncio 46 | async def test_throttle_unsubscribe(event_loop): 47 | p = Publisher() 48 | mock_sink = mock.Mock() 49 | 50 | throttle = p | op.Throttle(0.1) 51 | disposable = throttle.subscribe(Sink(mock_sink)) 52 | 53 | # test subscription and unsubscribe 54 | p.notify(2) 55 | mock_sink.assert_called_once_with(2) 56 | 57 | await asyncio.sleep(0.05) 58 | mock_sink.reset_mock() 59 | 60 | disposable.dispose() 61 | await asyncio.sleep(0.1) 62 | 63 | # dispose must not emit anything 64 | mock_sink.assert_not_called() 65 | 66 | p.notify(3) 67 | 68 | await asyncio.sleep(0.1) 69 | 70 | # after dispose was called, p.notify must not emit to mock_sink 71 | mock_sink.assert_not_called() 72 | 73 | 74 | @pytest.mark.asyncio 75 | async def test_throttle_reset(event_loop): 76 | p = Publisher() 77 | mock_sink = mock.Mock() 78 | 79 | throttle = p | op.Throttle(0.1) 80 | disposable = throttle.subscribe(Sink(mock_sink)) 81 | 82 | p.notify(1) 83 | await asyncio.sleep(0.05) 84 | throttle.reset() 85 | p.notify(3) 86 | 87 | await asyncio.sleep(0.05) 88 | 89 | # reset is called after "1" was emitted 90 | mock_sink.assert_has_calls((mock.call(1), mock.call(3))) 91 | 92 | ## wait until initial state is set and reset mock 93 | await asyncio.sleep(0.1) 94 | mock_sink.reset_mock() 95 | 96 | p.notify(1) 97 | await asyncio.sleep(0.05) 98 | p.notify(2) 99 | throttle.reset() 100 | p.notify(3) 101 | 102 | await asyncio.sleep(0.05) 103 | 104 | # reset is called after "1" was emitted, and while "2" was hold back, 105 | # therefore "1" and "3" are emitted, but "2" is ignored 106 | mock_sink.assert_has_calls((mock.call(1), mock.call(3))) 107 | 108 | disposable.dispose() 109 | 110 | 111 | @pytest.mark.parametrize('emit_sequence, expected_emits', [ 112 | (((0, 0), (0.05, 1), (0.4, 2), (0.6, 3), (0.2, 4), (0.2, 5)), 113 | (mock.call(0), mock.call(2), mock.call(3), mock.call(5))), 114 | (((0.001, 0), (0.6, 1), (0.5, 2), (0.05, 3), (0.44, 4)), 115 | (mock.call(0), mock.call(1), mock.call(2), mock.call(4))), 116 | ]) 117 | @pytest.mark.asyncio 118 | async def test_throttle(event_loop, emit_sequence, expected_emits): 119 | p = Publisher() 120 | mock_sink = mock.Mock() 121 | 122 | throttle = p | op.Throttle(0.5) 123 | disposable = throttle.subscribe(Sink(mock_sink)) 124 | 125 | mock_sink.assert_not_called() 126 | 127 | for item in emit_sequence: 128 | await asyncio.sleep(item[0]) 129 | p.notify(item[1]) 130 | 131 | await asyncio.sleep(0.5) 132 | 133 | mock_sink.assert_has_calls(expected_emits) 134 | 135 | 136 | def test_argument_check(): 137 | with pytest.raises(ValueError): 138 | Throttle(-1) 139 | -------------------------------------------------------------------------------- /tests/test_publishers_poll.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import pytest 3 | from unittest import mock 4 | 5 | from broqer.publishers import PollPublisher 6 | from broqer import Sink, NONE 7 | 8 | from .eventloop import VirtualTimeEventLoop 9 | 10 | 11 | @pytest.fixture() 12 | def event_loop(): 13 | loop = VirtualTimeEventLoop() 14 | yield loop 15 | loop.close() 16 | 17 | 18 | @pytest.mark.asyncio 19 | async def test_subscribe(event_loop): 20 | poll_mock = mock.Mock(return_value=3) 21 | sink_mock = mock.Mock() 22 | 23 | p = PollPublisher(poll_mock, 1) 24 | 25 | await asyncio.sleep(1) 26 | assert p.get() is NONE 27 | 28 | p.subscribe(Sink(sink_mock)) 29 | sink_mock.assert_called_once_with(3) 30 | poll_mock.assert_called_once() 31 | 32 | await asyncio.sleep(2.5) 33 | sink_mock.assert_called_with(3) 34 | assert sink_mock.call_count == 3 35 | --------------------------------------------------------------------------------