├── .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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------