├── .github └── workflows │ ├── ci.yml │ └── pythonpublish.yml ├── .gitignore ├── LICENSE.txt ├── README.rst ├── cyclotron ├── __init__.py ├── asyncio │ ├── __init__.py │ ├── runner.py │ └── stop.py ├── backpressure │ ├── __init__.py │ └── pid.py ├── debug │ ├── __init__.py │ └── trace.py ├── router.py └── rx.py ├── docs ├── Makefile ├── asset │ ├── crossroad.png │ ├── crossroad.svg │ ├── cycle.png │ ├── cycle.svg │ ├── cyclotron_logo.png │ ├── error_router.png │ └── error_router.svg ├── conf.py ├── get_started.rst ├── index.rst ├── rationale.rst └── requirements.txt ├── pyproject.toml ├── requirements.txt ├── requirements_test.txt ├── setup.cfg └── tests ├── __init__.py ├── rx_test_case.py ├── test_component.py ├── test_error_router.py ├── test_rx_runner.py └── test_trace_observer.py /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | package_check: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Set up Python 12 | uses: actions/setup-python@v2 13 | with: 14 | python-version: 3.9 15 | - name: Install dependencies 16 | run: | 17 | python -m pip install --upgrade pip 18 | pip install build twine 19 | pip install -r requirements.txt 20 | - name: Publish on Pypi 21 | if: startsWith(github.ref, 'refs/tags/') 22 | env: 23 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 24 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 25 | run: | 26 | python -m build 27 | twine check dist/* 28 | 29 | coverage: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@v2 33 | - name: Set up Python 3.9 34 | uses: actions/setup-python@v2 35 | with: 36 | python-version: 3.9 37 | - name: Install dependencies 38 | run: | 39 | python -m pip install --upgrade pip 40 | pip install -r requirements.txt 41 | pip install -r requirements_test.txt 42 | pip install pytest 43 | - name: Run tests 44 | run: | 45 | pip install coveralls 46 | coverage run -m pytest 47 | - name: Coveralls 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | COVERALLS_SERVICE_NAME: github 51 | run: | 52 | coveralls 53 | 54 | build: 55 | runs-on: ${{ matrix.os }} 56 | strategy: 57 | matrix: 58 | os: [ubuntu-latest, macos-latest, windows-latest] 59 | python-version: ['3.7', '3.8', '3.9', '3.10', 'pypy-3.7', 'pypy-3.8', 'pypy-3.9'] 60 | steps: 61 | - uses: actions/checkout@v2 62 | - name: Set up Python ${{ matrix.python-version }} 63 | uses: actions/setup-python@v2 64 | with: 65 | python-version: ${{ matrix.python-version }} 66 | - name: Install dependencies 67 | run: | 68 | python -m pip install --upgrade pip 69 | pip install -r requirements.txt 70 | - name: Lint with flake8 71 | run: | 72 | pip install flake8 73 | # stop the build if there are Python syntax errors or undefined names 74 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 75 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 76 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 77 | - name: Run unit tests 78 | run: | 79 | pip install -r requirements_test.txt 80 | pip install pytest 81 | pytest 82 | -------------------------------------------------------------------------------- /.github/workflows/pythonpublish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python package 2 | 3 | on: [push] 4 | 5 | jobs: 6 | publish: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Set up Python 12 | uses: actions/setup-python@v2 13 | with: 14 | python-version: 3.9 15 | - name: Install dependencies 16 | run: | 17 | python -m pip install --upgrade pip 18 | pip install build twine 19 | pip install -r requirements.txt 20 | - name: Publish on Pypi 21 | if: startsWith(github.ref, 'refs/tags/') 22 | env: 23 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 24 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 25 | run: | 26 | python -m build 27 | twine upload dist/* 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .ropeproject 3 | .tox 4 | *.egg-info 5 | __pycache__ 6 | *.pyc 7 | dist 8 | build 9 | _build 10 | .vscode 11 | .eggs 12 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 by Romain Picard 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =========================== 2 | |cyclotron-logo| Cyclotron 3 | =========================== 4 | 5 | .. |cyclotron-logo| image:: https://github.com/mainro/cyclotron-py/raw/master/docs/asset/cyclotron_logo.png 6 | 7 | A functional and reactive framework for `RxPY `_. 8 | 9 | .. image:: https://github.com/MainRo/cyclotron-py/actions/workflows/ci.yml/badge.svg 10 | :target: https://github.com/MainRo/cyclotron-py/actions/workflows/ci.yml 11 | 12 | .. image:: https://badge.fury.io/py/cyclotron.svg 13 | :target: https://badge.fury.io/py/cyclotron 14 | 15 | .. image:: https://readthedocs.org/projects/cyclotron-py/badge/?version=latest 16 | :target: https://cyclotron-py.readthedocs.io/en/latest/?badge=latest 17 | :alt: Documentation Status 18 | 19 | 20 | 21 | ---------------------- 22 | 23 | With Cyclotron, you can structure your RxPY code as many reusable components. 24 | Moreover it naturally encourages to separate pure code and side effects. So a 25 | Cyclotron application is easier to test, maintain, and extend. 26 | 27 | Here is the structure of a cyclotron application: 28 | 29 | .. figure:: https://github.com/mainro/cyclotron-py/raw/master/docs/asset/cycle.png 30 | :width: 60% 31 | :align: center 32 | 33 | How to use it 34 | ============= 35 | 36 | The following example is an http echo server: 37 | 38 | .. code:: python 39 | 40 | from collections import namedtuple 41 | 42 | from cyclotron import Component 43 | from cyclotron.asyncio.runner import run 44 | import cyclotron_aiohttp.httpd as httpd 45 | import reactivex as rx 46 | import reactivex.operators as ops 47 | 48 | EchoSource = namedtuple('EchoSource', ['httpd']) 49 | EchoSink = namedtuple('EchoSink', ['httpd']) 50 | EchoDrivers = namedtuple('EchoDrivers', ['httpd']) 51 | 52 | def echo_server(source): 53 | init = rx.from_([ 54 | httpd.Initialize(), 55 | httpd.AddRoute(methods=['GET'], path='/echo/{what}', id='echo'), 56 | httpd.StartServer(host='localhost', port=8080), 57 | ]) 58 | 59 | echo = source.httpd.route.pipe( 60 | ops.filter(lambda i: i.id == 'echo'), 61 | ops.flat_map(lambda i: i.request), 62 | ops.map(lambda i: httpd.Response( 63 | context=i.context, 64 | data=i.match_info['what'].encode('utf-8')), 65 | ) 66 | ) 67 | 68 | control = rx.merge(init, echo) 69 | return EchoSink(httpd=httpd.Sink(control=control)) 70 | 71 | 72 | def main(): 73 | run(Component(call=echo_server, input=EchoSource), 74 | EchoDrivers(httpd=httpd.make_driver())) 75 | 76 | 77 | if __name__ == '__main__': 78 | main() 79 | 80 | In this application, the echo_server function is a pure function, while the http 81 | server is implemented as a driver. 82 | 83 | .. code:: 84 | 85 | pip install cyclotron-aiohttp 86 | 87 | you can then test it with an http client like curl: 88 | 89 | .. code:: 90 | 91 | $ curl http://localhost:8080/echo/hello 92 | hello 93 | 94 | 95 | Install 96 | ======== 97 | 98 | Cyclotron is available on PyPi and can be installed with pip: 99 | 100 | .. code:: console 101 | 102 | pip install cyclotron 103 | 104 | Cyclotron automatically uses `uvloop `_ 105 | if it is available. 106 | 107 | This project is composed of several python packages. Install also the ones that 108 | you use in your application: 109 | 110 | ==================================================================== ========================= 111 | Package Version 112 | ==================================================================== ========================= 113 | `cyclotron `_ |pypi-cyclotron| 114 | `cyclotron-std `_ |pypi-cyclotron-std| 115 | `cyclotron-aiohttp `_ |pypi-cyclotron-aiohttp| 116 | `cyclotron-aiokafka `_ |pypi-cyclotron-aiokafka| 117 | `cyclotron-consul `_ |pypi-cyclotron-consul| 118 | ==================================================================== ========================= 119 | 120 | .. |pypi-cyclotron| image:: https://badge.fury.io/py/cyclotron.svg 121 | :target: https://badge.fury.io/py/cyclotron 122 | 123 | .. |pypi-cyclotron-aiohttp| image:: https://badge.fury.io/py/cyclotron-aiohttp.svg 124 | :target: https://badge.fury.io/py/cyclotron-aiohttp 125 | 126 | .. |pypi-cyclotron-std| image:: https://badge.fury.io/py/cyclotron-std.svg 127 | :target: https://badge.fury.io/py/cyclotron-std 128 | 129 | .. |pypi-cyclotron-aiokafka| image:: https://badge.fury.io/py/cyclotron-aiokafka.svg 130 | :target: https://badge.fury.io/py/cyclotron-aiokafka 131 | 132 | .. |pypi-cyclotron-consul| image:: https://badge.fury.io/py/cyclotron-consul.svg 133 | :target: https://badge.fury.io/py/cyclotron-consul 134 | 135 | 136 | License 137 | ========= 138 | 139 | This project is licensed under the MIT License - see the `License 140 | `_ file for 141 | details -------------------------------------------------------------------------------- /cyclotron/__init__.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | __author__ = """Romain Picard""" 4 | __email__ = 'romain.picard@oakbits.com' 5 | __version__ = '2.0.1' 6 | 7 | from . import backpressure 8 | 9 | Component = namedtuple('Component', ['call', 'input', 'output']) 10 | Component.__new__.__defaults__ = (None, None) 11 | 12 | Drain = namedtuple('Drain', []) 13 | -------------------------------------------------------------------------------- /cyclotron/asyncio/__init__.py: -------------------------------------------------------------------------------- 1 | from .runner import run -------------------------------------------------------------------------------- /cyclotron/asyncio/runner.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | try: 4 | import uvloop 5 | except ImportError: 6 | pass 7 | else: 8 | uvloop.install() 9 | 10 | from cyclotron.rx import setup 11 | 12 | 13 | def run(entry_point, drivers, loop=None): 14 | ''' This is a runner wrapping the cyclotron "run" implementation. It takes 15 | an additional parameter to provide a custom asyncio mainloop. 16 | ''' 17 | program = setup(entry_point, drivers) 18 | dispose = program.run() 19 | if loop is None: 20 | loop = asyncio.get_event_loop() 21 | 22 | loop.run_forever() 23 | dispose() 24 | -------------------------------------------------------------------------------- /cyclotron/asyncio/stop.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from cyclotron import Component 3 | import asyncio 4 | 5 | Sink = namedtuple('Sink', ['control']) 6 | 7 | 8 | def make_driver(loop=None): 9 | ''' Returns a stop driver. 10 | 11 | The optional loop argument can be provided to use the driver in another 12 | loop than the default one. 13 | 14 | Parameters 15 | ----------- 16 | loop: BaseEventLoop 17 | The event loop to use instead of the default one. 18 | ''' 19 | loop = loop or asyncio.get_event_loop() 20 | 21 | def stop(i=None): 22 | loop.stop() 23 | 24 | def driver(sink): 25 | ''' The stop driver stops the asyncio event loop. 26 | The event loop is stopped as soon as an event is received on the 27 | control observable or when it completes (both in case of success or 28 | error). 29 | 30 | Parameters 31 | ---------- 32 | sink: Sink 33 | ''' 34 | sink.control.subscribe( 35 | on_next=stop, 36 | on_error=stop, 37 | on_completed=stop) 38 | return None 39 | 40 | return Component(call=driver, input=Sink) 41 | -------------------------------------------------------------------------------- /cyclotron/backpressure/__init__.py: -------------------------------------------------------------------------------- 1 | from .pid import pid -------------------------------------------------------------------------------- /cyclotron/backpressure/pid.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | import reactivex as rx 3 | import reactivex.operators as ops 4 | 5 | from cyclotron.debug import trace_observable 6 | 7 | 8 | PidContext = namedtuple('PidContext', ['last_error', 'control_value']) 9 | 10 | 11 | def compute_pid(error, last_error, p, i, d): 12 | i_error = error + last_error 13 | d_error = error - last_error 14 | # print("p: {}, i: {}, d: {}".format(error, i_error, d_error)) 15 | # print("p: {}, i: {}, d: {}".format(p*error, i*i_error, d*d_error)) 16 | # print(p*error + i*i_error + d*d_error) 17 | return p*error + i*i_error + d*d_error 18 | 19 | 20 | def pid(setpoint, p, i, d): 21 | ''' 22 | Args: 23 | setpoint: An observable emiting setpoint values 24 | p: proportional component value 25 | i: integral component value 26 | d: derivative component value 27 | ''' 28 | def _pid_step(acc, ii): 29 | setpoint = ii[1] 30 | process_value = ii[0] 31 | error = setpoint - process_value 32 | last_error = acc.last_error if acc is not None else 0 33 | control_value = compute_pid(error, last_error, p, i, d) 34 | 35 | return PidContext( 36 | last_error=error, 37 | control_value=control_value, 38 | ) 39 | 40 | def _pid(process): 41 | return process.pipe( 42 | # trace_observable("pid"), 43 | ops.with_latest_from(setpoint), 44 | # trace_observable("pid2"), 45 | ops.scan(_pid_step, seed=None), 46 | # trace_observable("pid3"), 47 | ops.map(lambda i: i.control_value), 48 | ) 49 | 50 | return _pid 51 | -------------------------------------------------------------------------------- /cyclotron/debug/__init__.py: -------------------------------------------------------------------------------- 1 | from .trace import trace_observable -------------------------------------------------------------------------------- /cyclotron/debug/trace.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import traceback 3 | import reactivex as rx 4 | from reactivex.disposable import Disposable 5 | 6 | 7 | def trace_observable(prefix, 8 | trace_next=True, trace_next_payload=True, 9 | trace_subscribe=True, 10 | date=None): 11 | def _trace(source): 12 | def on_subscribe(observer, scheduler): 13 | def on_next(value): 14 | if trace_next is True: 15 | if trace_next_payload is True: 16 | print("{}:{} - on_next: {}".format( 17 | date or datetime.datetime.now(), 18 | prefix, value)) 19 | else: 20 | print("{}:{} - on_next".format( 21 | date or datetime.datetime.now(), 22 | prefix)) 23 | observer.on_next(value) 24 | 25 | def on_completed(): 26 | print("{}:{} - on_completed".format( 27 | date or datetime.datetime.now(), 28 | prefix)) 29 | observer.on_completed() 30 | 31 | def on_error(error): 32 | if isinstance(error, Exception): 33 | print("{}:{} - on_error: {}, {}".format( 34 | date or datetime.datetime.now(), 35 | prefix, error, 36 | traceback.print_tb(error.__traceback__))) 37 | else: 38 | print("{}:{} - on_error: {}".format( 39 | date or datetime.datetime.now(), 40 | prefix, error)) 41 | observer.on_error(error) 42 | 43 | def dispose(): 44 | if trace_subscribe is True: 45 | print("{}:{} - dispose".format( 46 | date or datetime.datetime.now(), 47 | prefix)) 48 | 49 | disposable.dispose() 50 | 51 | if trace_subscribe is True: 52 | print("{}:{} - on_subscribe".format( 53 | date or datetime.datetime.now(), 54 | prefix)) 55 | disposable = source.subscribe( 56 | on_next=on_next, 57 | on_error=on_error, 58 | on_completed=on_completed, 59 | ) 60 | 61 | return Disposable(dispose) 62 | 63 | return rx.create(on_subscribe) 64 | 65 | return _trace 66 | -------------------------------------------------------------------------------- /cyclotron/router.py: -------------------------------------------------------------------------------- 1 | import reactivex as rx 2 | import reactivex.operators as ops 3 | from reactivex.scheduler import CurrentThreadScheduler 4 | 5 | 6 | def make_error_router(): 7 | """ Creates an error router 8 | 9 | An error router takes a higher order observable a input and returns two 10 | observables: One containing the flattened items of the input observable 11 | and another one containing the flattened errors of the input observable. 12 | 13 | .. image:: ../docs/asset/error_router.png 14 | :scale: 60% 15 | :align: center 16 | 17 | Returns 18 | ------- 19 | error_observable: observable 20 | An observable emitting errors remapped. 21 | 22 | route_error: function 23 | A lettable function routing errors and taking three parameters: 24 | * source: Observable (higher order). Observable with errors to route. 25 | * error_map: function. Function used to map errors before routing them. 26 | * source_map: function. A function used to select the observable from each item is source. 27 | 28 | Examples 29 | -------- 30 | 31 | >>> sink, route_error = make_error_router() 32 | my_observable.let(route_error, error_map=lambda e: e) 33 | 34 | """ 35 | sink_observer = None 36 | sink_scheduler = None 37 | 38 | def on_subscribe(observer, scheduler): 39 | nonlocal sink_observer 40 | nonlocal sink_scheduler 41 | sink_observer = observer 42 | sink_scheduler = scheduler or CurrentThreadScheduler() 43 | 44 | def dispose(): 45 | nonlocal sink_observer 46 | sink_observer = None 47 | 48 | return dispose 49 | 50 | def route_error(obs, convert): 51 | """ Handles error raised by obs observable 52 | 53 | catches any error raised by obs, maps it to anther object with the 54 | convert function, and emits in on the error observer. 55 | 56 | """ 57 | def catch_error(e, source): 58 | sink_scheduler.schedule(lambda _1, _2: sink_observer.on_next(convert(e))) 59 | return rx.empty() 60 | 61 | return obs.pipe(ops.catch(catch_error)) 62 | 63 | def catch_or_flat_map(error_map, source_map=lambda i: i): 64 | def _catch_or_flat_map(source): 65 | return source.pipe( 66 | ops.flat_map(lambda i: route_error(source_map(i), error_map)) 67 | ) 68 | 69 | return _catch_or_flat_map 70 | 71 | return rx.create(on_subscribe), catch_or_flat_map 72 | -------------------------------------------------------------------------------- /cyclotron/rx.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple, OrderedDict 2 | from reactivex.subject import Subject 3 | 4 | 5 | Program = namedtuple('Program', ['sinks', 'sources', 'run']) 6 | 7 | 8 | def make_sink_proxies(drivers): 9 | ''' Build a list of sink proxies. sink proxies are a two-level ordered 10 | dictionary. The first level contains the lst of drivers, and the second 11 | level contains the list of sink proxies for each driver: 12 | 13 | drv1-->sink1 14 | | |->sink2 15 | | 16 | drv2-->sink1 17 | |->sink2 18 | ''' 19 | sink_proxies = OrderedDict() 20 | if drivers is not None: 21 | for driver_name in drivers._fields: 22 | driver = getattr(drivers, driver_name) 23 | driver_sink = getattr(driver, 'input') 24 | if driver_sink is not None: 25 | driver_sink_proxies = OrderedDict() 26 | for name in driver_sink._fields: 27 | driver_sink_proxies[name] = Subject() 28 | 29 | sink_proxies[driver_name] = driver.input(**driver_sink_proxies) 30 | return sink_proxies 31 | 32 | 33 | def call_drivers(drivers, sink_proxies, source_factory): 34 | sources = OrderedDict() 35 | for name in drivers._fields: 36 | try: 37 | source = None 38 | # Source drivers have no sink 39 | if name in sink_proxies: 40 | source = getattr(drivers, name).call(sink_proxies[name]) 41 | else: 42 | source = getattr(drivers, name).call() 43 | 44 | # sink drivers have no source 45 | if source is not None: 46 | sources[name] = source 47 | 48 | except Exception as e: 49 | raise RuntimeError('Unable to initialize {} driver'.format(name)) from e 50 | 51 | if source_factory is None: 52 | return None 53 | return source_factory(**sources) 54 | 55 | 56 | def subscribe_sinks(sinks, sink_proxies, scheduler): 57 | for driver_name in sinks._fields: 58 | driver = getattr(sinks, driver_name) 59 | for sink_name in driver._fields: 60 | getattr(driver, sink_name).subscribe( 61 | getattr(sink_proxies[driver_name], sink_name), 62 | scheduler=scheduler) 63 | 64 | 65 | def setup(entry_point, drivers): 66 | sink_proxies = make_sink_proxies(drivers) 67 | sources = call_drivers(drivers, sink_proxies, entry_point.input) 68 | sinks = entry_point.call(sources) 69 | 70 | def _run(scheduler=None): 71 | subscribe_sinks(sinks, sink_proxies, scheduler) 72 | 73 | def dispose(): 74 | return 75 | return dispose 76 | 77 | return Program(sinks=sinks, sources=sources, run=_run) 78 | 79 | 80 | def run(entry_point, drivers): 81 | ''' 82 | Takes a function and circularly connects it to the given collection of 83 | driver functions. 84 | 85 | parameters: 86 | - entry_point (Component): the function to call once the streams are configured. 87 | - drivers: a list of Component namedtuple where each Component is a driver. 88 | ''' 89 | program = setup(entry_point, drivers) 90 | return program.run() 91 | -------------------------------------------------------------------------------- /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 = Cyclotron 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) -------------------------------------------------------------------------------- /docs/asset/crossroad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MainRo/cyclotron-py/da9baefae9440b9399d26dc97fc680ac5d75cfa5/docs/asset/crossroad.png -------------------------------------------------------------------------------- /docs/asset/crossroad.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 24 | 32 | 37 | 38 | 46 | 51 | 52 | 60 | 65 | 66 | 74 | 79 | 80 | 87 | 93 | 94 | 95 | 116 | 119 | 120 | 122 | 123 | 125 | image/svg+xml 126 | 128 | 129 | 130 | 131 | 132 | 136 | 141 | driver 152 | source 163 | 172 | 179 | 184 | request 195 | 200 | response 211 | 216 | crossroad 227 | 236 | 242 | sink 253 | 260 | 267 | 268 | 269 | -------------------------------------------------------------------------------- /docs/asset/cycle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MainRo/cyclotron-py/da9baefae9440b9399d26dc97fc680ac5d75cfa5/docs/asset/cycle.png -------------------------------------------------------------------------------- /docs/asset/cycle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 24 | 32 | 37 | 38 | 46 | 51 | 52 | 60 | 65 | 66 | 74 | 79 | 80 | 87 | 93 | 94 | 95 | 115 | 118 | 119 | 121 | 122 | 124 | image/svg+xml 125 | 127 | 128 | 129 | 130 | 131 | 135 | 140 | 145 | Sinks 156 | Sources 167 | 176 | entry point 187 | 193 | 199 | 205 | 214 | drivers (side effects) 225 | 230 | 235 | argv 246 | http server 257 | console output 268 | pure dataflow 279 | 288 | 295 | 301 | 310 | 319 | 328 | 337 | 346 | 352 | 358 | 365 | 366 | 367 | -------------------------------------------------------------------------------- /docs/asset/cyclotron_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MainRo/cyclotron-py/da9baefae9440b9399d26dc97fc680ac5d75cfa5/docs/asset/cyclotron_logo.png -------------------------------------------------------------------------------- /docs/asset/error_router.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MainRo/cyclotron-py/da9baefae9440b9399d26dc97fc680ac5d75cfa5/docs/asset/error_router.png -------------------------------------------------------------------------------- /docs/asset/error_router.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 24 | 32 | 37 | 38 | 46 | 51 | 52 | 60 | 65 | 66 | 74 | 79 | 80 | 87 | 93 | 94 | 95 | 116 | 119 | 120 | 122 | 123 | 125 | image/svg+xml 126 | 128 | 129 | 130 | 131 | 132 | 136 | 141 | request 152 | 157 | response 168 | 173 | error router 184 | 193 | 198 | error 209 | 215 | 221 | 222 | 223 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import os 16 | import sys 17 | sys.path.insert(0, os.path.abspath('../')) 18 | 19 | 20 | # -- Project information ----------------------------------------------------- 21 | 22 | project = 'Cyclotron' 23 | copyright = '2019, R. Picard' 24 | author = 'R. Picard' 25 | 26 | # The short X.Y version 27 | version = '' 28 | # The full version, including alpha/beta/rc tags 29 | release = '2.0.1' 30 | 31 | 32 | # -- General configuration --------------------------------------------------- 33 | 34 | # If your documentation needs a minimal Sphinx version, state it here. 35 | # 36 | # needs_sphinx = '1.0' 37 | 38 | # Add any Sphinx extension module names here, as strings. They can be 39 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 40 | # ones. 41 | extensions = [ 42 | 'sphinx.ext.autodoc', 43 | 'numpydoc', 44 | ] 45 | 46 | # Add any paths that contain templates here, relative to this directory. 47 | templates_path = ['_templates'] 48 | 49 | # The suffix(es) of source filenames. 50 | # You can specify multiple suffix as a list of string: 51 | # 52 | # source_suffix = ['.rst', '.md'] 53 | source_suffix = '.rst' 54 | 55 | # The master toctree document. 56 | master_doc = 'index' 57 | 58 | # The language for content autogenerated by Sphinx. Refer to documentation 59 | # for a list of supported languages. 60 | # 61 | # This is also used if you do content translation via gettext catalogs. 62 | # Usually you set "language" from the command line for these cases. 63 | language = None 64 | 65 | # List of patterns, relative to source directory, that match files and 66 | # directories to ignore when looking for source files. 67 | # This pattern also affects html_static_path and html_extra_path . 68 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 69 | 70 | # The name of the Pygments (syntax highlighting) style to use. 71 | pygments_style = 'sphinx' 72 | 73 | 74 | # -- Options for HTML output ------------------------------------------------- 75 | 76 | # The theme to use for HTML and HTML Help pages. See the documentation for 77 | # a list of builtin themes. 78 | # 79 | html_theme = 'sphinx_rtd_theme' 80 | html_theme_path = ["_themes", ] 81 | 82 | # Theme options are theme-specific and customize the look and feel of a theme 83 | # further. For a list of options available for each theme, see the 84 | # documentation. 85 | # 86 | # html_theme_options = {} 87 | 88 | # Add any paths that contain custom static files (such as style sheets) here, 89 | # relative to this directory. They are copied after the builtin static files, 90 | # so a file named "default.css" will overwrite the builtin "default.css". 91 | html_static_path = ['_static'] 92 | 93 | # Custom sidebar templates, must be a dictionary that maps document names 94 | # to template names. 95 | # 96 | # The default sidebars (for documents that don't match any pattern) are 97 | # defined by theme itself. Builtin themes are using these templates by 98 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 99 | # 'searchbox.html']``. 100 | # 101 | # html_sidebars = {} 102 | 103 | 104 | # -- Options for HTMLHelp output --------------------------------------------- 105 | 106 | # Output file base name for HTML help builder. 107 | htmlhelp_basename = 'Cyclotrondoc' 108 | 109 | 110 | # -- Options for LaTeX output ------------------------------------------------ 111 | 112 | latex_elements = { 113 | # The paper size ('letterpaper' or 'a4paper'). 114 | # 115 | # 'papersize': 'letterpaper', 116 | 117 | # The font size ('10pt', '11pt' or '12pt'). 118 | # 119 | # 'pointsize': '10pt', 120 | 121 | # Additional stuff for the LaTeX preamble. 122 | # 123 | # 'preamble': '', 124 | 125 | # Latex figure (float) alignment 126 | # 127 | # 'figure_align': 'htbp', 128 | } 129 | 130 | # Grouping the document tree into LaTeX files. List of tuples 131 | # (source start file, target name, title, 132 | # author, documentclass [howto, manual, or own class]). 133 | latex_documents = [ 134 | (master_doc, 'Cyclotron.tex', 'Cyclotron Documentation', 135 | 'R. Picard', 'manual'), 136 | ] 137 | 138 | 139 | # -- Options for manual page output ------------------------------------------ 140 | 141 | # One entry per manual page. List of tuples 142 | # (source start file, name, description, authors, manual section). 143 | man_pages = [ 144 | (master_doc, 'cyclotron', 'Cyclotron Documentation', 145 | [author], 1) 146 | ] 147 | 148 | 149 | # -- Options for Texinfo output ---------------------------------------------- 150 | 151 | # Grouping the document tree into Texinfo files. List of tuples 152 | # (source start file, target name, title, author, 153 | # dir menu entry, description, category) 154 | texinfo_documents = [ 155 | (master_doc, 'Cyclotron', 'Cyclotron Documentation', 156 | author, 'Cyclotron', 'One line description of project.', 157 | 'Miscellaneous'), 158 | ] 159 | 160 | 161 | # -- Extension configuration ------------------------------------------------- 162 | -------------------------------------------------------------------------------- /docs/get_started.rst: -------------------------------------------------------------------------------- 1 | Get Started 2 | ============ 3 | 4 | install cyclotron http package: 5 | 6 | .. code-block:: console 7 | 8 | $ pip3 install cyclotron-aiohttp 9 | 10 | 11 | .. code-block:: python 12 | 13 | from collections import namedtuple 14 | 15 | from cyclotron import Component 16 | from cyclotron.asyncio.runner import run 17 | import cyclotron_aiohttp.httpd as httpd 18 | import reactivex as rx 19 | import reactivex.operators as ops 20 | 21 | EchoSource = namedtuple('EchoSource', ['httpd']) 22 | EchoSink = namedtuple('EchoSink', ['httpd']) 23 | EchoDrivers = namedtuple('EchoDrivers', ['httpd']) 24 | 25 | def echo_server(source): 26 | init = rx.from_([ 27 | httpd.Initialize(), 28 | httpd.AddRoute(methods=['GET'], path='/echo/{what}', id='echo'), 29 | httpd.StartServer(host='localhost', port=8080), 30 | ]) 31 | 32 | echo = source.httpd.route.pipe( 33 | ops.filter(lambda i: i.id == 'echo'), 34 | ops.flat_map(lambda i: i.request), 35 | ops.map(lambda i: httpd.Response( 36 | context=i.context, 37 | data=i.match_info['what'].encode('utf-8')), 38 | ) 39 | ) 40 | 41 | control = rx.merge(init, echo) 42 | return EchoSink(httpd=httpd.Sink(control=control)) 43 | 44 | 45 | def main(): 46 | run(Component(call=echo_server, input=EchoSource), 47 | EchoDrivers(httpd=httpd.make_driver())) 48 | 49 | 50 | if __name__ == '__main__': 51 | main() 52 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Cyclotron documentation master file, created by 2 | sphinx-quickstart on Fri Aug 10 21:16:05 2018. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Cyclotron's documentation! 7 | ===================================== 8 | 9 | Cyclotron is a functional and reactive framework for python, asyncio, and 10 | `RxPY `_. It is inspired from the 11 | `CycleJs `_ javascript framework. Cyclotron makes it 12 | easy to write asynchronous reactive applications in a functional way. 13 | 14 | .. toctree:: 15 | :maxdepth: 2 16 | :caption: Contents: 17 | 18 | rationale 19 | get_started 20 | 21 | 22 | 23 | Indices and tables 24 | ================== 25 | 26 | * :ref:`genindex` 27 | * :ref:`modindex` 28 | * :ref:`search` 29 | -------------------------------------------------------------------------------- /docs/rationale.rst: -------------------------------------------------------------------------------- 1 | Rationale 2 | ========== 3 | 4 | A Cyclotron application is composed of two parts: 5 | 6 | * Components, that are composed of pure code. 7 | * Drivers, that implement side effects. 8 | 9 | Components and drivers comunicate via Observables. Communication between 10 | components and drivers is done via sink Observables. Communication between 11 | drivers and components is done via source Observables. 12 | 13 | 14 | .. image:: asset/cycle.png 15 | :scale: 60% 16 | :align: center 17 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | -r ../requirements.txt 2 | numpydoc>=0.8 3 | sphinx_rtd_theme>=0.4 4 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "cyclotron" 3 | version = "2.0.1" 4 | description = "A reactive stream cycle implementation in python" 5 | readme = "README.rst" 6 | requires-python = ">=3.7" 7 | license = {file = "LICENSE.txt"} 8 | keywords = ["reactivex", "cyclejs"] 9 | authors = [ 10 | {email = "romain.picard@oakbits.com"}, 11 | {name = "Romain Picard"} 12 | ] 13 | classifiers = [ 14 | 'Development Status :: 5 - Production/Stable', 15 | 'Intended Audience :: Developers', 16 | 'Programming Language :: Python :: 3', 17 | ] 18 | 19 | dependencies = [ 20 | 'reactivex~=4.0', 21 | ] 22 | 23 | [project.optional-dependencies] 24 | test = [ 25 | "pytest", 26 | ] 27 | 28 | [project.urls] 29 | repository = "https://github.com/MainRo/cyclotron-py.git" 30 | documentation = 'https://cyclotron-py.readthedocs.io' 31 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | reactivex~=4.0 2 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MainRo/cyclotron-py/da9baefae9440b9399d26dc97fc680ac5d75cfa5/requirements_test.txt -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 2.0.1 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:pyproject.toml] 7 | search = version = "{current_version}" 8 | replace = version = "{new_version}" 9 | 10 | [bumpversion:file:cyclotron/__init__.py] 11 | search = __version__ = '{current_version}' 12 | replace = __version__ = '{new_version}' 13 | 14 | [bumpversion:file:docs/conf.py] 15 | search = release = '{current_version}' 16 | replace = release = '{new_version}' 17 | 18 | [bdist_wheel] 19 | universal = 1 20 | 21 | [flake8] 22 | exclude = docs 23 | 24 | [aliases] 25 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MainRo/cyclotron-py/da9baefae9440b9399d26dc97fc680ac5d75cfa5/tests/__init__.py -------------------------------------------------------------------------------- /tests/rx_test_case.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | 4 | class RxTestCase(TestCase): 5 | def setUp(self): 6 | self.actual = {} 7 | 8 | def create_actual(self): 9 | return { 10 | 'next': [], 11 | 'error': None, 12 | 'completed': False 13 | } 14 | 15 | def on_next(self, key, i): 16 | if key not in self.actual: 17 | self.actual[key] = self.create_actual() 18 | self.actual[key]['next'].append(i) 19 | 20 | def on_error(self, key, e): 21 | if key not in self.actual: 22 | self.actual[key] = self.create_actual() 23 | self.actual[key]['error'] = e 24 | 25 | def on_completed(self, key): 26 | if key not in self.actual: 27 | self.actual[key] = self.create_actual() 28 | self.actual[key]['completed'] = True 29 | -------------------------------------------------------------------------------- /tests/test_component.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from collections import namedtuple 4 | from cyclotron import Component 5 | 6 | 7 | class ComponentTestCase(TestCase): 8 | 9 | def test_constructor(self): 10 | def test_main(): 11 | pass 12 | 13 | component = Component(call=test_main, input=int) 14 | self.assertEqual(test_main, component.call) 15 | self.assertEqual(int, component.input) 16 | self.assertEqual(None, component.output) 17 | 18 | component = Component(call=test_main, output=float) 19 | self.assertEqual(test_main, component.call) 20 | self.assertEqual(None, component.input) 21 | self.assertEqual(float, component.output) 22 | 23 | self.assertRaises(TypeError , Component) 24 | -------------------------------------------------------------------------------- /tests/test_error_router.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | import reactivex as rx 3 | import reactivex.operators as ops 4 | from reactivex.scheduler import CurrentThreadScheduler 5 | 6 | 7 | from cyclotron.router import make_error_router 8 | 9 | 10 | class ErrorRouterTestCase(TestCase): 11 | 12 | def test_route_error(self): 13 | actual_sequence = [] 14 | 15 | def on_chain_item(i): 16 | nonlocal actual_sequence 17 | actual_sequence.append(i) 18 | 19 | sink, route_error = make_error_router() 20 | 21 | origin = rx.from_([ 22 | rx.just(1), 23 | rx.throw(-1) 24 | ]).pipe( 25 | route_error(error_map=lambda e: e.args[0] * 100), 26 | ) 27 | 28 | result = rx.merge(origin, sink) 29 | disposable = result.subscribe( 30 | on_chain_item, 31 | scheduler=CurrentThreadScheduler()) 32 | disposable.dispose() 33 | 34 | expected_sequence = [1, -100] 35 | self.assertEqual(actual_sequence, expected_sequence) 36 | -------------------------------------------------------------------------------- /tests/test_rx_runner.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from collections import namedtuple 4 | import reactivex as rx 5 | 6 | from cyclotron import Component 7 | from cyclotron.rx import run 8 | 9 | 10 | class RunnerTestCase(TestCase): 11 | 12 | def test_run_1snk(self): 13 | ''' Creates a cycle with one sink driver. 14 | ''' 15 | MainDrivers = namedtuple('MainDrivers', ['drv1']) 16 | MainSource = namedtuple('MainSource', []) 17 | MainSink = namedtuple('MainSink', ['drv1']) 18 | test_values = [] 19 | 20 | def drv1(sink): 21 | sink.values.subscribe(lambda i: test_values.append(i)) 22 | return None 23 | Drv1Sink = namedtuple('Drv1Sink', ['values']) 24 | Drv1Driver = Component(call=drv1, input=Drv1Sink) 25 | 26 | def main(sources): 27 | val = rx.from_([1, 2, 3]) 28 | return MainSink(drv1=Drv1Sink(values=val)) 29 | 30 | drivers = MainDrivers(drv1=Drv1Driver) 31 | dispose = run(Component(call=main, input=MainSource), drivers) 32 | dispose() 33 | 34 | self.assertEqual(3, len(test_values)) 35 | self.assertEqual(1, test_values[0]) 36 | self.assertEqual(2, test_values[1]) 37 | self.assertEqual(3, test_values[2]) 38 | 39 | def test_run_1srcsnk(self): 40 | ''' Creates a cycle with one sink/source driver. 41 | ''' 42 | MainDrivers = namedtuple('MainDrivers', ['drv1']) 43 | MainSource = namedtuple('MainSource', ['drv1']) 44 | MainSink = namedtuple('MainSink', ['drv1']) 45 | test_values = [] 46 | 47 | Drv1Source = namedtuple('Drv1Source', ['counter']) 48 | Drv1Sink = namedtuple('Drv1Sink', ['values']) 49 | 50 | def drv1(sink): 51 | sink.values.subscribe(lambda i: test_values.append(i)) 52 | counter_stream = rx.from_([1, 2, 3]) 53 | return Drv1Source(counter=counter_stream) 54 | Drv1Driver = Component(call=drv1, input=Drv1Sink) 55 | 56 | def main(sources): 57 | val = sources.drv1.counter 58 | return MainSink(drv1=Drv1Sink(values=val)) 59 | 60 | drivers = MainDrivers(drv1=Drv1Driver) 61 | dispose = run(Component(call=main, input=MainSource), drivers) 62 | dispose() 63 | 64 | self.assertEqual(3, len(test_values)) 65 | self.assertEqual(1, test_values[0]) 66 | self.assertEqual(2, test_values[1]) 67 | self.assertEqual(3, test_values[2]) 68 | 69 | def test_run_1src_1snk(self): 70 | ''' Creates a cycle with one sink driver and one source driver. 71 | ''' 72 | test_values = [] 73 | Drv1Sink = namedtuple('Drv1Sink', ['values']) 74 | 75 | def drv1(sink): 76 | sink.values.subscribe(lambda i: test_values.append(i)) 77 | return None 78 | Drv1Driver = Component(call=drv1, input=Drv1Sink) 79 | 80 | Drv2Source = namedtuple('Drv2Source', ['counter']) 81 | 82 | def drv2(): 83 | counter_stream = rx.from_([1, 2, 3]) 84 | return Drv2Source(counter=counter_stream) 85 | Drv2Driver = Component(call=drv2, input=None) 86 | 87 | MainDrivers = namedtuple('MainDrivers', ['drv1', 'drv2']) 88 | MainSource = namedtuple('MainSource', ['drv2']) 89 | MainSink = namedtuple('MainSink', ['drv1']) 90 | 91 | def main(sources): 92 | val = sources.drv2.counter 93 | return MainSink(drv1=Drv1Sink(values=val)) 94 | 95 | drivers = MainDrivers(drv1=Drv1Driver, drv2=Drv2Driver) 96 | dispose = run(Component(call=main, input=MainSource), drivers) 97 | dispose() 98 | 99 | self.assertEqual(3, len(test_values)) 100 | self.assertEqual(1, test_values[0]) 101 | self.assertEqual(2, test_values[1]) 102 | self.assertEqual(3, test_values[2]) 103 | -------------------------------------------------------------------------------- /tests/test_trace_observer.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | import sys 3 | import datetime 4 | from io import StringIO 5 | import reactivex as rx 6 | from reactivex.subject import Subject 7 | 8 | from cyclotron.debug import trace_observable 9 | 10 | 11 | class TraceObserverTestCase(TestCase): 12 | def setUp(self): 13 | self.saved_stdout = sys.stdout 14 | self.out = StringIO() 15 | sys.stdout = self.out 16 | 17 | def tearDown(self): 18 | sys.stdout = self.saved_stdout 19 | 20 | def test_base_on_next(self): 21 | source = Subject() 22 | source.pipe(trace_observable( 23 | prefix='foo', 24 | date=datetime.datetime(year=2018, month=8, day=3)) 25 | ).subscribe() 26 | source.on_next('bar') 27 | self.assertEqual( 28 | '2018-08-03 00:00:00:foo - on_subscribe\n' 29 | '2018-08-03 00:00:00:foo - on_next: bar', 30 | self.out.getvalue().strip()) 31 | 32 | def test_base_on_completed(self): 33 | source = Subject() 34 | source.pipe(trace_observable( 35 | prefix='foo', 36 | date=datetime.datetime(year=2018, month=8, day=3)) 37 | ).subscribe() 38 | source.on_completed() 39 | self.assertEqual( 40 | '2018-08-03 00:00:00:foo - on_subscribe\n' 41 | '2018-08-03 00:00:00:foo - on_completed\n' 42 | '2018-08-03 00:00:00:foo - dispose', 43 | self.out.getvalue().strip()) 44 | 45 | def test_base_on_error(self): 46 | source = Subject() 47 | source.pipe(trace_observable( 48 | prefix='foo', 49 | date=datetime.datetime(year=2018, month=8, day=3)) 50 | ).subscribe(on_error=lambda _: None) 51 | source.on_error('error') 52 | self.assertEqual( 53 | '2018-08-03 00:00:00:foo - on_subscribe\n' 54 | '2018-08-03 00:00:00:foo - on_error: error\n' 55 | '2018-08-03 00:00:00:foo - dispose', 56 | self.out.getvalue().strip()) 57 | 58 | def test_no_trace_next(self): 59 | source = Subject() 60 | source.pipe(trace_observable( 61 | prefix='foo', trace_next=False, 62 | date=datetime.datetime(year=2018, month=8, day=3)) 63 | ).subscribe() 64 | source.on_next('bar') 65 | self.assertEqual( 66 | '2018-08-03 00:00:00:foo - on_subscribe', 67 | self.out.getvalue().strip()) 68 | 69 | def test_no_payload_next(self): 70 | source = Subject() 71 | source.pipe(trace_observable( 72 | prefix='foo', trace_next_payload=False, 73 | date=datetime.datetime(year=2018, month=8, day=3)) 74 | ).subscribe() 75 | source.on_next('bar') 76 | self.assertEqual( 77 | '2018-08-03 00:00:00:foo - on_subscribe\n' 78 | '2018-08-03 00:00:00:foo - on_next', 79 | self.out.getvalue().strip()) 80 | 81 | def test_no_subscribe(self): 82 | source = Subject() 83 | source.pipe(trace_observable( 84 | prefix='foo', trace_subscribe=False, 85 | date=datetime.datetime(year=2018, month=8, day=3)) 86 | ).subscribe() 87 | source.on_next('bar') 88 | self.assertEqual( 89 | '2018-08-03 00:00:00:foo - on_next: bar', 90 | self.out.getvalue().strip()) 91 | --------------------------------------------------------------------------------