├── tests ├── __init__.py ├── storage │ ├── __init__.py │ └── test_dict.py ├── fixtures │ ├── constants.py │ ├── __init__.py │ ├── client.py │ └── harness.py ├── conftest.py └── examples │ ├── test_echo.py │ └── test_chat.py ├── mara ├── storage │ ├── __init__.py │ ├── base.py │ └── dict.py ├── app │ ├── __init__.py │ ├── logging.py │ ├── event_manager.py │ └── app.py ├── constants.py ├── servers │ ├── __init__.py │ ├── connections │ │ ├── __init__.py │ │ ├── http.py │ │ ├── telnet.py │ │ ├── socket.py │ │ └── base.py │ ├── telnet.py │ ├── socket.py │ ├── http.py │ └── base.py ├── timers │ ├── __init__.py │ ├── periodic.py │ └── base.py ├── __init__.py ├── status.py └── events │ ├── __init__.py │ ├── app.py │ ├── server.py │ ├── base.py │ └── connection.py ├── .gitignore ├── docs ├── requirements.in ├── app.rst ├── logging.rst ├── Makefile ├── index.rst ├── timers.rst ├── install.rst ├── contributing.rst ├── cookbook.rst ├── upgrading.rst ├── servers.rst ├── requirements.txt ├── events.rst ├── quickstart.rst ├── conf.py └── changelog.rst ├── requirements-dev.in ├── examples ├── echo.py ├── chat_telnet.py ├── http_web.py ├── chat_socket_ssl.py ├── chat_socket.py ├── http_websocket.py └── http_websocket_telnet.py ├── setup.py ├── .github └── workflows │ ├── pypi.yml │ └── ci.yml ├── .readthedocs.yaml ├── LICENSE ├── requirements-dev.txt ├── setup.cfg └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mara/storage/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/storage/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mara/app/__init__.py: -------------------------------------------------------------------------------- 1 | from .app import App # noqa 2 | -------------------------------------------------------------------------------- /mara/constants.py: -------------------------------------------------------------------------------- 1 | DEFAULT_HOST = "127.0.0.1" 2 | DEFAULT_PORT = 9000 3 | -------------------------------------------------------------------------------- /mara/servers/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import AbstractServer # noqa 2 | -------------------------------------------------------------------------------- /tests/fixtures/constants.py: -------------------------------------------------------------------------------- 1 | TEST_HOST = "127.0.0.1" 2 | TEST_PORT = 5999 3 | -------------------------------------------------------------------------------- /mara/servers/connections/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import AbstractConnection # noqa 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from .fixtures import app_harness, socket_client_factory # noqa 2 | -------------------------------------------------------------------------------- /mara/timers/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import AbstractTimer # noqa 2 | from .periodic import PeriodicTimer # noqa 3 | -------------------------------------------------------------------------------- /mara/__init__.py: -------------------------------------------------------------------------------- 1 | from . import events, servers # noqa 2 | from .app import App # noqa 3 | 4 | 5 | __version__ = "2.1.1" 6 | -------------------------------------------------------------------------------- /tests/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import socket_client_factory # noqa 2 | from .harness import app_harness # noqa 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | __pycache__ 3 | mara.egg-info 4 | .coverage* 5 | .tox 6 | .*_cache 7 | htmlcov 8 | docs/_* 9 | examples/ssl.* 10 | -------------------------------------------------------------------------------- /docs/requirements.in: -------------------------------------------------------------------------------- 1 | # Core build requirements 2 | sphinx 3 | sphinx-gitref 4 | -e git+https://github.com/radiac/sphinx_radiac_theme.git#egg=sphinx_radiac_theme 5 | 6 | # Optional 7 | sphinx-autobuild 8 | -------------------------------------------------------------------------------- /mara/status.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum, auto 2 | 3 | 4 | class Status(IntEnum): 5 | IDLE = auto() 6 | STARTING = auto() 7 | RUNNING = auto() 8 | STOPPING = auto() 9 | STOPPED = auto() 10 | FAILED = auto() 11 | -------------------------------------------------------------------------------- /requirements-dev.in: -------------------------------------------------------------------------------- 1 | ########################## 2 | # Developer requirements # 3 | ########################## 4 | 5 | pip-tools 6 | 7 | # 8 | # Testing 9 | # 10 | 11 | pytest 12 | pytest-asyncio 13 | pytest-cov 14 | pytest-mypy 15 | 16 | 17 | # 18 | # Extra requires 19 | # 20 | 21 | telnetlib3 22 | aiohttp 23 | -------------------------------------------------------------------------------- /docs/app.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | The App class 3 | ============= 4 | 5 | The main ``App`` class manages the asyncio loop, the servers and events. 6 | 7 | It can be imported using:: 8 | 9 | from mara import App 10 | 11 | 12 | API reference 13 | ============= 14 | 15 | .. autoclass:: mara.app.app.App 16 | :members: 17 | -------------------------------------------------------------------------------- /docs/logging.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Logging 3 | ======= 4 | 5 | Mara uses Python's standard ``logging`` framework. 6 | 7 | All Mara's loggers are children of ``mara``, and by default the app will filter to show 8 | all ``mara`` log INFO messages upwards. 9 | 10 | The log level can be changed by setting the environment variable ``LOGLEVEL``, eg:: 11 | 12 | $ LOGLEVEL=DEBUG python echo.py 13 | -------------------------------------------------------------------------------- /examples/echo.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from mara import App, events 4 | from mara.servers.socket import SocketServer 5 | 6 | app = App() 7 | app.add_server(SocketServer(host="0", port=9000)) 8 | 9 | 10 | @app.on(events.Receive) 11 | async def echo(event: events.Receive): 12 | event.connection.write(event.data) 13 | 14 | 15 | if __name__ == "__main__": 16 | app.run() 17 | -------------------------------------------------------------------------------- /examples/chat_telnet.py: -------------------------------------------------------------------------------- 1 | """ 2 | An extension of the chat server that runs with telnet support 3 | 4 | Requires additional libraries:: 5 | 6 | pip install telnetlib3 7 | """ 8 | from chat_socket import app, server 9 | from mara.servers.telnet import TelnetServer 10 | 11 | app.remove_server(server) 12 | app.add_server(TelnetServer(host="0", port=9000)) 13 | 14 | if __name__ == "__main__": 15 | app.run() 16 | -------------------------------------------------------------------------------- /mara/events/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .app import ( # noqa 4 | App, 5 | PostRestart, 6 | PostStart, 7 | PostStop, 8 | PreRestart, 9 | PreStart, 10 | PreStop, 11 | ) 12 | from .base import Event # noqa 13 | from .connection import Connect, Connection, Disconnect, Receive # noqa 14 | from .server import ListenStart, ListenStop, Server, Suspend # noqa 15 | -------------------------------------------------------------------------------- /examples/http_web.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from mara import App, events 4 | from mara.servers.http import HttpServer 5 | 6 | from aiohttp.web import Response 7 | 8 | app = App() 9 | http = HttpServer(host="0", port=8000) 10 | app.add_server(http) 11 | 12 | 13 | @http.add_route("GET", '/') 14 | def home(request): 15 | return Response(text="Hello world") 16 | 17 | 18 | if __name__ == "__main__": 19 | app.run() 20 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | from pathlib import Path 4 | 5 | from setuptools import setup 6 | 7 | 8 | def find_version(*paths: str): 9 | path = Path(*paths) 10 | content = path.read_text() 11 | match = re.search(r"^__version__\s*=\s*['\"]([^'\"]*)['\"]", content, re.M) 12 | if match: 13 | return match.group(1) 14 | raise RuntimeError("Unable to find version string.") 15 | 16 | 17 | # Setup unless this is being imported by Sphinx, which just wants find_version 18 | if "sphinx" not in sys.modules: 19 | setup(version=find_version("mara", "__init__.py")) 20 | -------------------------------------------------------------------------------- /tests/examples/test_echo.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from examples.echo import app as echo_app 4 | 5 | 6 | @pytest.fixture(autouse=True) 7 | def echo_app_harness(app_harness): 8 | return app_harness(echo_app) 9 | 10 | 11 | def test_single(socket_client_factory): 12 | client = socket_client_factory() 13 | client.write(b"hello") 14 | response = client.read() 15 | assert response == b"hello" 16 | 17 | 18 | def test_multiple(socket_client_factory): 19 | client1 = socket_client_factory() 20 | client2 = socket_client_factory() 21 | client1.write(b"client1") 22 | client2.write(b"client2") 23 | assert client1.read() == b"client1" 24 | assert client2.read() == b"client2" 25 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /mara/events/app.py: -------------------------------------------------------------------------------- 1 | """ 2 | Service events 3 | """ 4 | from __future__ import annotations 5 | 6 | from .base import Event 7 | 8 | 9 | __all__ = [ 10 | "App", 11 | "PreStart", 12 | "PostStart", 13 | "PreStop", 14 | "PostStop", 15 | "PreRestart", 16 | "PostRestart", 17 | ] 18 | 19 | 20 | class App(Event): 21 | "Service event" 22 | 23 | 24 | class PreStart(App): 25 | "Service starting" 26 | 27 | 28 | class PostStart(App): 29 | "Service started" 30 | 31 | 32 | class PreStop(App): 33 | "Service stopping" 34 | 35 | 36 | class PostStop(App): 37 | "Service stopped" 38 | 39 | 40 | class PreRestart(App): 41 | "Service restarting" 42 | 43 | 44 | class PostRestart(App): 45 | "Service restarted" 46 | -------------------------------------------------------------------------------- /mara/timers/periodic.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from .base import AbstractTimer 4 | 5 | 6 | class PeriodicTimer(AbstractTimer): 7 | every: int | float 8 | strict: bool 9 | 10 | def __init__(self, every: int | float, strict=False): 11 | super().__init__() 12 | self.every = every 13 | self.strict = strict 14 | 15 | def next_due(self, last_due: float, now: float) -> int | float: 16 | """ 17 | Return unix time for the next time the trigger is due 18 | """ 19 | if self.strict: 20 | next = last_due + self.every 21 | 22 | else: 23 | next = last_due 24 | 25 | if next <= now: 26 | next = now + self.every 27 | 28 | return next 29 | -------------------------------------------------------------------------------- /mara/events/server.py: -------------------------------------------------------------------------------- 1 | """ 2 | Server events 3 | """ 4 | from __future__ import annotations 5 | 6 | from typing import TYPE_CHECKING 7 | 8 | from .base import Event 9 | 10 | 11 | __all__ = ["Server", "ListenStart", "Suspend", "ListenStop"] 12 | 13 | 14 | if TYPE_CHECKING: 15 | from ..servers import AbstractServer 16 | 17 | 18 | class Server(Event): 19 | "Server event" 20 | server: AbstractServer 21 | 22 | def __init__(self, server: AbstractServer): 23 | self.server = server 24 | 25 | def __str__(self) -> str: 26 | return f"{super().__str__()}: {self.server}" 27 | 28 | 29 | class ListenStart(Server): 30 | "Server listening" 31 | 32 | 33 | class Suspend(Server): 34 | "Server has been suspended" 35 | 36 | 37 | class ListenStop(Server): 38 | "Server is no longer listening" 39 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ======================================= 2 | Mara - Python network service framework 3 | ======================================= 4 | 5 | Mara is an asynchronous event-based python framework designed for building TCP/IP 6 | services, such as telnet, HTTP and websocket servers. 7 | 8 | * Project site: https://radiac.net/projects/mara/ 9 | * Source code: https://github.com/radiac/mara 10 | 11 | 12 | 13 | Contents 14 | ======== 15 | 16 | .. toctree:: 17 | :maxdepth: 2 18 | :titlesonly: 19 | 20 | quickstart 21 | install 22 | cookbook 23 | app 24 | logging 25 | servers 26 | events 27 | timers 28 | upgrading 29 | changelog 30 | contributing 31 | 32 | 33 | Indices and tables 34 | ================== 35 | 36 | * :ref:`genindex` 37 | * :ref:`modindex` 38 | * :ref:`search` 39 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | publish: 10 | name: Build and publish to PyPI 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Python 3.9 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: 3.9 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | pip install setuptools wheel 22 | - name: Build a binary wheel and a source tarball 23 | run: | 24 | python setup.py sdist bdist_wheel 25 | - name: Publish to PyPI 26 | if: startsWith(github.ref, 'refs/tags') 27 | uses: pypa/gh-action-pypi-publish@release/v1 28 | with: 29 | password: ${{ secrets.pypi_password }} 30 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.11" 13 | # You can also specify other tool versions: 14 | # nodejs: "16" 15 | # rust: "1.55" 16 | # golang: "1.17" 17 | 18 | # Build documentation in the docs/ directory with Sphinx 19 | sphinx: 20 | configuration: docs/conf.py 21 | 22 | # If using Sphinx, optionally build your docs in additional formats such as PDF 23 | # formats: 24 | # - pdf 25 | 26 | # Optionally declare the Python requirements required to build your docs 27 | python: 28 | install: 29 | - requirements: docs/requirements.txt 30 | -------------------------------------------------------------------------------- /docs/timers.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | Timers 3 | ====== 4 | 5 | Mara has built-in support for timers - for example:: 6 | 7 | from mara.timers import PeriodicTimer 8 | 9 | @app.add_timer(PeriodicTimer(every=60)) 10 | async def tick(timer): 11 | ... 12 | 13 | 14 | A timer is an instance of an :gitref:`mara/timers/base.py::AbstractTimer` subclass, and 15 | should be attached to the app and a corresponding asynchronous callback function. 16 | 17 | Timers run independently of each other within the asyncio loop, so order is not 18 | guaranteed. 19 | 20 | Timer instances are actually callable as decorators, so the above is shorthand for:: 21 | 22 | async def tick(timer): 23 | ... 24 | 25 | timer = PeriodicTimer(every=60) 26 | app.add_timer(timer) 27 | timer(tick) 28 | 29 | 30 | API reference 31 | ============= 32 | 33 | .. autoclass:: mara.timers.periodic.PeriodicTimer 34 | :members: 35 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | test: 9 | name: py-${{ matrix.python }} 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | include: 14 | - python: "3.11" 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Set up Python ${{ matrix.python }} 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: ${{ matrix.python }} 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install -r requirements-dev.txt 26 | - name: Set Python path 27 | run: | 28 | echo "PYTHONPATH=." >> $GITHUB_ENV 29 | - name: Test 30 | run: | 31 | pytest 32 | - name: Upload coverage to Codecov 33 | uses: codecov/codecov-action@v1 34 | with: 35 | name: ${{ matrix.python }} 36 | -------------------------------------------------------------------------------- /tests/storage/test_dict.py: -------------------------------------------------------------------------------- 1 | from mara.storage.dict import DictStore 2 | 3 | 4 | async def test_flat_store(): 5 | store = DictStore(a=1, foo="bar") 6 | data = await store.store() 7 | assert data == '{"a": 1, "foo": "bar"}' 8 | 9 | 10 | async def test_flat_restore(): 11 | store = await DictStore.restore('{"a": 1, "foo": "bar"}') 12 | assert store.a == 1 13 | assert store.foo == "bar" 14 | 15 | 16 | async def test_nested_store(): 17 | store = DictStore(a=1, child=DictStore(b=2)) 18 | data = await store.store() 19 | assert ( 20 | data 21 | == '{"a": 1, "child": {"_store_class": "DictStore", "data": "{\\"b\\": 2}"}}' 22 | ) 23 | 24 | 25 | async def test_nested_restore(): 26 | store = await DictStore.restore( 27 | '{"a": 1, "child": {"_store_class": "DictStore", "data": "{\\"b\\": 2}"}}' 28 | ) 29 | assert store.a == 1 30 | assert isinstance(store.child, DictStore) 31 | assert store.child.b == 2 32 | -------------------------------------------------------------------------------- /mara/events/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Mara events 3 | """ 4 | from __future__ import annotations 5 | 6 | from typing import TYPE_CHECKING 7 | 8 | 9 | if TYPE_CHECKING: 10 | from ..app import App 11 | 12 | 13 | class Event(object): 14 | """ 15 | Non-specific event 16 | 17 | All events are derived from this class. 18 | """ 19 | 20 | app: App | None 21 | 22 | def __init__(self): 23 | self.stopped = False 24 | 25 | # App will be added by event handler 26 | self.app = None 27 | 28 | def stop(self): 29 | """ 30 | Stop the event from being passed to any more handlers 31 | """ 32 | self.stopped = True 33 | 34 | def __str__(self): 35 | """ 36 | Return this event as a string 37 | """ 38 | label = getattr(self.__class__, "hint", self.__class__.__doc__ or "") 39 | label = (label.strip().splitlines()[0],) 40 | return f"[{self.__class__.__name__}]: {label}" 41 | -------------------------------------------------------------------------------- /mara/servers/connections/http.py: -------------------------------------------------------------------------------- 1 | from aiohttp import WSMsgType 2 | from aiohttp.web import Request, WebSocketResponse 3 | 4 | from .base import AbstractConnection 5 | 6 | 7 | class WebSocketConnection(AbstractConnection[str]): 8 | request: Request 9 | ws: WebSocketResponse 10 | 11 | def __str__(self): 12 | return str(self.request.remote) 13 | 14 | async def prepare(self, request: Request): 15 | self.request = request 16 | self.ws = WebSocketResponse() 17 | await self.ws.prepare(request) 18 | 19 | async def read(self) -> str: 20 | msg = await self.ws.receive() 21 | if msg.type == WSMsgType.TEXT: 22 | return msg.data 23 | elif msg.type == WSMsgType.ERROR: 24 | self.connected = False 25 | return "" 26 | # if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.CLOSED): 27 | # raise StopAsyncIteration 28 | return "" 29 | 30 | async def _write(self, data: str): 31 | await self.ws.send_str(data) 32 | -------------------------------------------------------------------------------- /examples/chat_socket_ssl.py: -------------------------------------------------------------------------------- 1 | """ 2 | A basic chat server with TLS support 3 | 4 | Create the TLS certificates with:: 5 | 6 | openssl genrsa -out ssl.key 2048 7 | openssl req -new -key ssl.key -out ssl.csr 8 | openssl x509 -req -days 365 -in ssl.csr -signkey ssl.key -out ssl.crt 9 | 10 | * Set the common name to the hostname when asked 11 | 12 | Connect to the TLS server with:: 13 | 14 | openssl s_client -connect localhost:9001 -crlf -quiet 15 | """ 16 | 17 | from pathlib import Path 18 | 19 | from mara import App 20 | from mara.servers.socket import TextServer 21 | 22 | ssl_cert = Path("ssl.crt") 23 | ssl_key = Path("ssl.key") 24 | 25 | if not ssl_cert.exists() or not ssl_key.exists(): 26 | raise FileNotFoundError("SSL cert and key not found - see instructions in comments") 27 | 28 | 29 | app = App() 30 | server = TextServer(host="0", port=9000) 31 | server_ssl = TextServer(host="0", port=9001, ssl_cert=ssl_cert, ssl_key=ssl_key) 32 | app.add_server(server) 33 | app.add_server(server_ssl) 34 | 35 | 36 | if __name__ == "__main__": 37 | app.run() 38 | -------------------------------------------------------------------------------- /mara/events/connection.py: -------------------------------------------------------------------------------- 1 | """ 2 | Connection events 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | from .base import Event 8 | 9 | __all__ = ["Connection", "Connect", "Receive", "Disconnect"] 10 | 11 | 12 | class Connection(Event): 13 | "Connection event" 14 | 15 | def __init__(self, connection): 16 | super(Connection, self).__init__() 17 | self.connection = connection 18 | self.server = getattr(connection, "server", None) 19 | 20 | def __str__(self): 21 | msg = super(Connection, self).__str__().strip() 22 | return f"{msg} ({self.connection})" 23 | 24 | 25 | class Connect(Connection): 26 | "Connection connected" 27 | 28 | 29 | class Disconnect(Connection): 30 | "Connection disconnected" 31 | 32 | 33 | class Receive(Connection): 34 | "Data received" 35 | 36 | def __init__(self, connection, data): 37 | super(Receive, self).__init__(connection) 38 | self.data = data 39 | 40 | def __str__(self): 41 | msg = super(Receive, self).__str__().strip() 42 | return f"{msg}: {self.data}" 43 | -------------------------------------------------------------------------------- /docs/install.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | 5 | Installing Mara 6 | =============== 7 | 8 | Mara requires Python 3.10 or later. 9 | 10 | Install to your Python environment with: 11 | 12 | .. code-block:: bash 13 | 14 | pip install mara 15 | 16 | If you are planning to contribute to Mara, see :doc:`contributing` for more details. 17 | 18 | 19 | Running the examples 20 | -------------------- 21 | 22 | The examples are not installed by pip. To try them out, grab them from github: 23 | 24 | .. code-block:: bash 25 | 26 | git checkout https://github.com/radiac/mara.git 27 | cd mara/examples 28 | 29 | You can then run the examples using ``python``: 30 | 31 | .. code-block:: bash 32 | 33 | python echo.py 34 | 35 | All examples listen to 127.0.0.1 on port 9000. 36 | 37 | 38 | Deploying Mara 39 | ============== 40 | 41 | If deploying your Mara project to a Linux environment, we recommend using your 42 | distribution's init daemon (eg init.d or systemd) to run your project on boot and 43 | restart it if necessary. 44 | 45 | .. TODO: Add instructions for systemd and containerised deployment 46 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | Contributions are welcome, preferably via pull request. Check the github issues and to 6 | see what needs work. 7 | 8 | If you have an idea for a new feature, it's worth opening a new ticket to discuss 9 | whether it's suitable for the project, or would be better as a separate pacakge. 10 | 11 | 12 | Installing 13 | ========== 14 | 15 | The easiest way to work on Mara is to fork the project on github, then install it to a 16 | virtualenv:: 17 | 18 | virtualenv mara 19 | cd mara 20 | source bin/activate 21 | pip install -e git+git@github.com:USERNAME/mara.git#egg=mara 22 | 23 | (replacing ``USERNAME`` with your username). 24 | 25 | This will install the testing dependencies too, and you'll find the Mara source ready 26 | for you to work on in the ``src`` folder of your virtualenv. 27 | 28 | 29 | Testing 30 | ======= 31 | 32 | It is greatly appreciated when contributions come with tests, and they will lead to a 33 | faster merge and release of your work. 34 | 35 | Use ``pytest`` to run the tests:: 36 | 37 | cd path/to/mara 38 | pytest 39 | 40 | These will also generate a ``coverage`` HTML report. 41 | -------------------------------------------------------------------------------- /docs/cookbook.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Cookbook 3 | ======== 4 | 5 | Tips and tricks for working with Mara 6 | 7 | 8 | Capture user input 9 | ================== 10 | 11 | Each connection instance reads from its connection in its own async read loop, and 12 | raises a ``Receive`` event when data is received. This event is then handled within that 13 | connection's read loop, blocking any data from that connection until the event is 14 | completely handled. 15 | 16 | This means that an event can effectively pause the read loop for that connection and 17 | capture inbound content ``connection.read()``:: 18 | 19 | @app.on(events.Connect) 20 | async def login(event: events.Connect): 21 | event.connection.write("Username: ", end="") 22 | username: str = event.connection.read() 23 | event.connection.write(f"Welcome, {username}") 24 | 25 | Here the ``connection.read()`` will capture the first input from a user, and will not 26 | trigger a ``Receive`` event. 27 | 28 | Note that connections can write during an event, as outgoing data is sent using a 29 | separate async write loop. You may want to call ``await event.connection.flush()`` to 30 | ensure the data has been sent before continuing. 31 | -------------------------------------------------------------------------------- /mara/app/logging.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import sys 4 | from os import getenv 5 | 6 | 7 | class Whitelist(logging.Filter): 8 | def __init__(self, *whitelist): 9 | self.whitelist = [logging.Filter(name) for name in whitelist] 10 | 11 | def filter(self, record): 12 | return any(f.filter(record) for f in self.whitelist) 13 | 14 | 15 | def configure(): 16 | """ 17 | Configure logging 18 | 19 | Pick up the log level from the env var LOGLEVEL, otherwise default to INFO 20 | """ 21 | # TODO: Simple configuration of what to log and where to log it to 22 | level_name = getenv("LOGLEVEL", "INFO") 23 | level = getattr(logging, level_name) 24 | logging.basicConfig(stream=sys.stdout, filemode="w", level=level) 25 | 26 | for handler in logging.root.handlers: 27 | handler.addFilter(Whitelist("mara", "tests")) 28 | 29 | 30 | def get_tasks(loop): 31 | """ 32 | List the current tasks in the loop 33 | 34 | Usage:: 35 | 36 | logger.debug(get_tasks(app.loop)) 37 | """ 38 | tasks = asyncio.all_tasks(loop) 39 | return "Tasks: " + ", ".join( 40 | [f"{task.get_name()}: {task.get_coro().__name__}" for task in tasks] 41 | ) 42 | -------------------------------------------------------------------------------- /mara/storage/base.py: -------------------------------------------------------------------------------- 1 | store_classes = {} 2 | 3 | 4 | class Store: 5 | """ 6 | A class which can store and restore its data 7 | 8 | Each Store subclass is responsible for how it stores and restores itself using a 9 | single string value; for example, the DictStore serialises to json which is 10 | returned raw, while the SqliteStore saves data to an sqlite database and returns the 11 | database ID of the object. 12 | 13 | Each Store subclass must have a unique name, as it uses that to register itself with 14 | the global store class registry, so that store class names mentioned in serialised 15 | data can be restored. 16 | 17 | A subclass must implement store() and restore() 18 | 19 | Abstract subclasses should set abstract=True in their definition, eg:: 20 | 21 | class MyAbstractStore(Store, abstract=True): 22 | ... 23 | """ 24 | 25 | def __init_subclass__(cls, *, abstract=False, **kwargs): 26 | super().__init_subclass__(**kwargs) 27 | if not abstract: 28 | store_classes[cls.__name__] = cls 29 | 30 | async def store(self) -> str: 31 | raise NotImplementedError() 32 | 33 | @classmethod 34 | async def restore(cls, data: str) -> None: 35 | raise NotImplementedError() 36 | -------------------------------------------------------------------------------- /docs/upgrading.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Upgrading 3 | ========= 4 | 5 | For an overview of what has changed between versions, see the :doc:`changelog`. 6 | 7 | 8 | Instructions 9 | ============ 10 | 11 | 1. Check which version of Mara you are upgrading from:: 12 | 13 | python 14 | >>> import mara 15 | >>> mara.__version__ 16 | 17 | 2. Upgrade the Mara package:: 18 | 19 | pip install --upgrade mara 20 | 21 | 3. Find your version below and work your way up this document until you've upgraded to 22 | the latest version 23 | 24 | 25 | .. _upgrade_0-6-3: 26 | 27 | Upgrading from 0.6.3 28 | ==================== 29 | 30 | Mara 0.6.3 was the last release of version 1 of Mara. Although Version 2 has a similar 31 | API, it is a complete rewrite and most user code will need to be updated and refactored. 32 | 33 | The key differences are: 34 | 35 | * ``mara.Service`` is now ``mara.App``, and now uses asyncio. 36 | * Servers must be defined and added using ``add_server`` 37 | * Event handlers need to be ``async``. Handler classes have been removed. To capture 38 | user data a handler must ``await event.client.read()`` instead of calling ``yield``. 39 | * Timers are now defined as instances. 40 | * All contrib modules have been removed in version 2. These will be brought back as a 41 | separate package. 42 | * Version 2.0.0 is missing the angel, ``mara`` command, and telnet support. 43 | These are planned for a future version. 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mara is licensed under the BSD License 2 | ====================================== 3 | 4 | Copyright (c) 2022, Richard Terry, https://radiac.net/ 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 8 | 9 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 10 | Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 11 | Neither the name of the software nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 14 | -------------------------------------------------------------------------------- /docs/servers.rst: -------------------------------------------------------------------------------- 1 | ============== 2 | Server classes 3 | ============== 4 | 5 | Each server has a corresponding connection, which is created automatically when a new 6 | connection is made. 7 | 8 | 9 | SocketServer 10 | ============ 11 | 12 | This is a low-level socket server which reads and writes bytes. 13 | 14 | .. autoclass:: mara.servers.socket.SocketServer 15 | :members: 16 | :show-inheritance: 17 | 18 | .. autoclass:: mara.servers.socket.SocketConnection 19 | :members: 20 | :show-inheritance: 21 | 22 | 23 | TextServer 24 | ========== 25 | 26 | This wraps the ``SocketServer`` to read and write text ``str``. 27 | 28 | .. autoclass:: mara.servers.socket.TextServer 29 | :members: 30 | :show-inheritance: 31 | 32 | .. autoclass:: mara.servers.socket.TextConnection 33 | :members: 34 | :show-inheritance: 35 | 36 | 37 | TelnetServer 38 | ============ 39 | 40 | This wraps ``telnetlib3`` to provide a socket with telnet protocol support. 41 | 42 | .. autoclass:: mara.servers.telnet.TelnetServer 43 | :members: 44 | :show-inheritance: 45 | 46 | .. autoclass:: mara.servers.telnet.TelnetConnection 47 | :members: 48 | :show-inheritance: 49 | 50 | 51 | HttpServer 52 | ========== 53 | 54 | This wraps ``aiohttp`` to serve HTTP requests 55 | 56 | .. autoclass:: mara.servers.http.HttpServer 57 | :members: 58 | :show-inheritance: 59 | 60 | 61 | WebSocketServer 62 | --------------- 63 | 64 | Create a websocket server using ``HttpServer.create_websocket(..)`` 65 | 66 | 67 | .. autoclass:: mara.servers.http.WebSocketServer 68 | :members: 69 | :show-inheritance: 70 | 71 | .. autoclass:: mara.servers.http.WebSocketConnection 72 | :members: 73 | :show-inheritance: 74 | -------------------------------------------------------------------------------- /examples/chat_socket.py: -------------------------------------------------------------------------------- 1 | """ 2 | A basic chat server 3 | """ 4 | 5 | from mara import App, events 6 | from mara.servers.socket import TextServer 7 | from mara.timers import PeriodicTimer 8 | 9 | app = App() 10 | server = TextServer(host="0", port=9000) 11 | app.add_server(server) 12 | 13 | 14 | def broadcast(msg: str): 15 | "Send a message out to all connected users on all servers" 16 | for server in app.servers: 17 | for conn in server.connections: 18 | if "username" not in conn.session: 19 | continue 20 | conn.write(msg) 21 | 22 | 23 | @app.on(events.Connect) 24 | async def login(event: events.Connect): 25 | "Prompt for a username and announce to users" 26 | event.connection.write("Username: ", end="") 27 | username = await event.connection.read() 28 | event.connection.write("") 29 | 30 | event.connection.session.username = username 31 | broadcast(f"* {username} has joined") 32 | 33 | 34 | @app.on(events.Receive) 35 | async def input(event: events.Receive): 36 | "Broadcast a chat message" 37 | broadcast(f"{event.connection.session.username} says: {event.data}") 38 | 39 | 40 | @app.on(events.Disconnect) 41 | async def leave(event: events.Disconnect): 42 | "Announce departure to users" 43 | broadcast(f"* {event.connection.session.username} has left") 44 | 45 | 46 | @app.add_timer(PeriodicTimer(every=60)) 47 | async def poll(timer): 48 | for server in timer.app.servers: 49 | for conn in server.connections: 50 | if "username" not in conn.session: 51 | continue 52 | conn.write("Beep!") 53 | 54 | 55 | if __name__ == "__main__": 56 | app.run() 57 | -------------------------------------------------------------------------------- /tests/examples/test_chat.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from examples.chat_socket import app as chat_app 3 | 4 | 5 | @pytest.fixture(autouse=True) 6 | def echo_app_harness(app_harness): 7 | return app_harness(chat_app) 8 | 9 | 10 | def test_single(socket_client_factory): 11 | alice = socket_client_factory() 12 | assert alice.read() == b"Username: " 13 | alice.write(b"alice\r\n") 14 | assert alice.read_line() == b"" 15 | assert alice.read_line() == b"* alice has joined" 16 | alice.write(b"hello\r\n") 17 | assert alice.read_line() == b"alice says: hello" 18 | 19 | 20 | def test_multiple__login_private__chat_public(socket_client_factory): 21 | alice = socket_client_factory(name="alice") 22 | bob = socket_client_factory(name="bob") 23 | 24 | # Alice logs in 25 | assert alice.read() == b"Username: " 26 | alice.write(b"alice\r\n") 27 | assert alice.read_line() == b"" 28 | assert alice.read_line() == b"* alice has joined" 29 | 30 | # Alice cannot talk to bob until he is logged in 31 | alice.write(b"alone\r\n") 32 | assert alice.read_line() == b"alice says: alone" 33 | 34 | # Bob logs in 35 | assert bob.read() == b"Username: " 36 | bob.write(b"bob\r\n") 37 | assert bob.read_line() == b"" 38 | assert bob.read_line() == b"* bob has joined" 39 | 40 | # Alice sees this 41 | assert alice.read_line() == b"* bob has joined" 42 | 43 | # They can now talk 44 | alice.write(b"hello\r\n") 45 | assert alice.read_line() == b"alice says: hello" 46 | assert bob.read_line() == b"alice says: hello" 47 | bob.write(b"goodbye\r\n") 48 | assert alice.read_line() == b"bob says: goodbye" 49 | assert bob.read_line() == b"bob says: goodbye" 50 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile requirements-dev.in 6 | # 7 | aiohttp==3.9.5 8 | # via -r requirements-dev.in 9 | aiosignal==1.3.1 10 | # via aiohttp 11 | attrs==23.2.0 12 | # via 13 | # aiohttp 14 | # pytest-mypy 15 | build==1.2.1 16 | # via pip-tools 17 | click==8.1.7 18 | # via pip-tools 19 | coverage[toml]==7.5.1 20 | # via pytest-cov 21 | filelock==3.14.0 22 | # via pytest-mypy 23 | frozenlist==1.4.1 24 | # via 25 | # aiohttp 26 | # aiosignal 27 | idna==3.7 28 | # via yarl 29 | iniconfig==2.0.0 30 | # via pytest 31 | multidict==6.0.5 32 | # via 33 | # aiohttp 34 | # yarl 35 | mypy==1.10.0 36 | # via pytest-mypy 37 | mypy-extensions==1.0.0 38 | # via mypy 39 | packaging==24.0 40 | # via 41 | # build 42 | # pytest 43 | pip-tools==7.4.1 44 | # via -r requirements-dev.in 45 | pluggy==1.5.0 46 | # via pytest 47 | pyproject-hooks==1.1.0 48 | # via 49 | # build 50 | # pip-tools 51 | pytest==8.2.0 52 | # via 53 | # -r requirements-dev.in 54 | # pytest-asyncio 55 | # pytest-cov 56 | # pytest-mypy 57 | pytest-asyncio==0.23.6 58 | # via -r requirements-dev.in 59 | pytest-cov==5.0.0 60 | # via -r requirements-dev.in 61 | pytest-mypy==0.10.3 62 | # via -r requirements-dev.in 63 | telnetlib3==2.0.4 64 | # via -r requirements-dev.in 65 | typing-extensions==4.11.0 66 | # via mypy 67 | wheel==0.43.0 68 | # via pip-tools 69 | yarl==1.9.4 70 | # via aiohttp 71 | 72 | # The following packages are considered to be unsafe in a requirements file: 73 | # pip 74 | # setuptools 75 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = mara 3 | description = A framework for network services 4 | long_description = file: README.rst 5 | keywords = socket telnet http websocket 6 | author = Richard Terry 7 | author_email = code@radiac.net 8 | license = BSD 9 | classifiers = 10 | Development Status :: 3 - Alpha 11 | Environment :: Web Environment 12 | Intended Audience :: Developers 13 | License :: OSI Approved :: BSD License 14 | Operating System :: OS Independent 15 | Topic :: Internet 16 | Programming Language :: Python 17 | Programming Language :: Python :: 3 18 | Programming Language :: Python :: 3 :: Only 19 | Programming Language :: Python :: 3.10 20 | url = https://radiac.net/projects/mara/ 21 | project_urls = 22 | Documentation = https://python-mara.readthedocs.io/ 23 | Source = https://github.com/radiac/mara 24 | Tracker = https://github.com/radiac/mara/issues 25 | 26 | [options] 27 | python_requires = >=3.10 28 | packages = find: 29 | include_package_data = true 30 | zip_safe = false 31 | install_requires = 32 | telnetlib3 33 | aiohttp 34 | 35 | [options.packages.find] 36 | exclude = tests* 37 | 38 | [tool:pytest] 39 | addopts = --cov=mara --cov-report=term --cov-report=html 40 | pythonpath = . 41 | asyncio_mode = auto 42 | 43 | [coverage:run] 44 | parallel=True 45 | 46 | [flake8] 47 | ignore = E123,E128,E203,E501,W503 48 | max-line-length = 88 49 | exclude = .tox,.git 50 | 51 | [isort] 52 | multi_line_output = 3 53 | line_length = 88 54 | known_first_party = mara 55 | sections = FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 56 | include_trailing_comma = True 57 | lines_after_imports = 2 58 | skip = .tox,.git 59 | 60 | [mypy] 61 | ignore_missing_imports = True 62 | 63 | [doc8] 64 | max-line-length = 88 65 | ignore-path = *.txt,.tox 66 | -------------------------------------------------------------------------------- /mara/servers/telnet.py: -------------------------------------------------------------------------------- 1 | """ 2 | Telnet server 3 | 4 | Wrapper around telnetlib3, https://pypi.org/project/telnetlib3/ 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | from ..constants import DEFAULT_HOST, DEFAULT_PORT 10 | from .base import AbstractAsyncioServer 11 | from .connections.telnet import TelnetConnection 12 | 13 | try: 14 | import telnetlib3 15 | except ImportError: 16 | raise ValueError("telnetlib3 not found - pip install mara[telnet]") 17 | 18 | 19 | class TelnetServer(AbstractAsyncioServer): 20 | connection_class: type[TelnetConnection] = TelnetConnection 21 | 22 | def __init__( 23 | self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, **telnet_kwargs 24 | ): 25 | self.host = host 26 | self.port = port 27 | 28 | if "shell" in telnet_kwargs: 29 | raise ValueError("Cannot specify a shell for TelnetServer") 30 | self.telnet_kwargs = telnet_kwargs 31 | self.telnet_kwargs["shell"] = self.handle_connect 32 | 33 | super().__init__() 34 | 35 | def __str__(self): 36 | return f"Telnet {self.host}:{self.port}" 37 | 38 | async def create(self): 39 | await super().create() 40 | if not self.app or (loop := self.app.loop) is None: 41 | raise ValueError("Cannot start TelnetServer without running loop") 42 | 43 | self.server = await loop.create_server( 44 | protocol_factory=lambda: telnetlib3.TelnetServer(**self.telnet_kwargs), 45 | host=self.host, 46 | port=self.port, 47 | ) 48 | 49 | async def handle_connect( 50 | self, reader: telnetlib3.TelnetReader, writer: telnetlib3.TelnetWriter 51 | ): 52 | connection: TelnetConnection = self.connection_class( 53 | server=self, reader=reader, writer=writer 54 | ) 55 | await self.connected(connection) 56 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile 6 | # 7 | -e git+https://github.com/radiac/sphinx_radiac_theme.git#egg=sphinx_radiac_theme 8 | # via -r requirements.in 9 | alabaster==0.7.16 10 | # via sphinx 11 | anyio==4.3.0 12 | # via 13 | # starlette 14 | # watchfiles 15 | babel==2.15.0 16 | # via sphinx 17 | certifi==2024.2.2 18 | # via requests 19 | charset-normalizer==3.3.2 20 | # via requests 21 | click==8.1.7 22 | # via uvicorn 23 | colorama==0.4.6 24 | # via sphinx-autobuild 25 | docutils==0.17.1 26 | # via 27 | # sphinx 28 | # sphinx-gitref 29 | # sphinx-radiac-theme 30 | h11==0.14.0 31 | # via uvicorn 32 | idna==3.7 33 | # via 34 | # anyio 35 | # requests 36 | imagesize==1.4.1 37 | # via sphinx 38 | jinja2==3.1.4 39 | # via sphinx 40 | markupsafe==2.1.5 41 | # via jinja2 42 | packaging==24.0 43 | # via sphinx 44 | pygments==2.18.0 45 | # via sphinx 46 | requests==2.31.0 47 | # via sphinx 48 | sniffio==1.3.1 49 | # via anyio 50 | snowballstemmer==2.2.0 51 | # via sphinx 52 | sphinx==5.3.0 53 | # via 54 | # -r requirements.in 55 | # sphinx-autobuild 56 | # sphinx-gitref 57 | # sphinx-radiac-theme 58 | sphinx-autobuild==2024.4.16 59 | # via -r requirements.in 60 | sphinx-gitref==0.3.0 61 | # via -r requirements.in 62 | sphinxcontrib-applehelp==1.0.8 63 | # via sphinx 64 | sphinxcontrib-devhelp==1.0.6 65 | # via sphinx 66 | sphinxcontrib-htmlhelp==2.0.5 67 | # via sphinx 68 | sphinxcontrib-jsmath==1.0.1 69 | # via sphinx 70 | sphinxcontrib-qthelp==1.0.7 71 | # via sphinx 72 | sphinxcontrib-serializinghtml==1.1.10 73 | # via sphinx 74 | starlette==0.37.2 75 | # via sphinx-autobuild 76 | urllib3==2.2.1 77 | # via requests 78 | uvicorn==0.29.0 79 | # via sphinx-autobuild 80 | watchfiles==0.21.0 81 | # via sphinx-autobuild 82 | websockets==12.0 83 | # via sphinx-autobuild 84 | -------------------------------------------------------------------------------- /examples/http_websocket.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import aiohttp 4 | from aiohttp import WSMsgType, web 5 | from aiohttp.web import Response, WebSocketResponse 6 | from mara import App, events 7 | from mara.servers.http import HttpServer 8 | 9 | app = App() 10 | http = HttpServer(host="0", port=8000) 11 | app.add_server(http) 12 | ws = http.create_websocket("/ws/") 13 | 14 | 15 | @http.add_route("GET", "/") 16 | def home(request): 17 | return Response(body=get_html(), content_type="text/html") 18 | 19 | 20 | @ws.on(events.Connect) 21 | async def ws_connect(event: events.Connect): 22 | event.connection.write("Hello") 23 | 24 | 25 | @ws.on(events.Receive) 26 | async def ws_receive(event): 27 | print("poopy", ws.connections) 28 | data = event.data 29 | for conn in ws.connections: 30 | conn.write(data) 31 | 32 | 33 | def get_html(): 34 | # Simple HTML page with a websocket client 35 | return """ 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 |
45 | 46 | 47 | 68 | 69 | 70 | """ 71 | 72 | 73 | if __name__ == "__main__": 74 | app.run() 75 | -------------------------------------------------------------------------------- /mara/servers/connections/telnet.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from telnetlib3 import TelnetReader, TelnetWriter 6 | 7 | from .base import AbstractConnection 8 | 9 | if TYPE_CHECKING: 10 | from ..servers.telnet import TelnetServer 11 | 12 | 13 | class TelnetConnection(AbstractConnection[str]): 14 | reader: TelnetReader 15 | writer: TelnetWriter 16 | _str: str | None = None 17 | 18 | def __init__( 19 | self, 20 | server: TelnetServer, 21 | reader: TelnetReader, 22 | writer: TelnetWriter, 23 | ): 24 | super().__init__(server) 25 | self.reader = reader 26 | self.writer = writer 27 | self._str = None 28 | 29 | def __str__(self) -> str: 30 | # Cache value, it won't be available after connection closes 31 | if not self._str: 32 | ip, port = self.writer.get_extra_info("peername") 33 | self._str = str(ip) 34 | return self._str 35 | 36 | async def _write(self, data: str): 37 | # TODO: says it takes bytes but needs str 38 | self.writer.write(data) # type: ignore 39 | await self.writer.drain() 40 | self._check_is_active() 41 | 42 | def _check_is_active(self): 43 | if self.reader.at_eof() or self.writer.transport.is_closing(): 44 | self.connected = False 45 | 46 | async def close(self): 47 | # Close the streams 48 | self.writer.close() 49 | 50 | # Can't wait to make sure the socket is closed: 51 | # https://github.com/jquast/telnetlib3/issues/55 52 | # 53 | # await self.writer.wait_closed() 54 | 55 | # Terminate the listener loop 56 | # TODO 57 | # this.listener_task 58 | 59 | await super().close() 60 | 61 | async def read(self) -> str: 62 | # TODO: read size and buffers 63 | data = await self.reader.readline() 64 | data = data.rstrip("\r\n") 65 | self._check_is_active() 66 | return data 67 | 68 | def write(self, data: str, *, end: str = "\r\n"): 69 | data = f"{data}{end}" 70 | super().write(data) 71 | -------------------------------------------------------------------------------- /examples/http_websocket_telnet.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from aiohttp.web import Response 4 | from mara import App, events 5 | from mara.servers.http import HttpServer 6 | from mara.servers.telnet import TelnetServer 7 | 8 | app = App() 9 | http = HttpServer(host="0", port=8000) 10 | telnet = TelnetServer(host="0", port=9000) 11 | app.add_server(http) 12 | app.add_server(telnet) 13 | ws = http.create_websocket("/ws/") 14 | 15 | 16 | @http.add_route("GET", "/") 17 | def home(request): 18 | return Response(body=get_html(), content_type="text/html") 19 | 20 | 21 | @app.on(events.Connect) 22 | async def connect(event: events.Connect): 23 | event.connection.write("Hello") 24 | 25 | 26 | @app.on(events.Receive) 27 | async def receive(event): 28 | data = event.data 29 | msg = f"{event.connection}: {data}" 30 | for server in app.servers: 31 | for connection in server.connections: 32 | connection.write(msg) 33 | 34 | 35 | def get_html(): 36 | # Simple HTML page with a websocket client 37 | return """ 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
47 | 48 | 49 | 70 | 71 | 72 | """ 73 | 74 | 75 | if __name__ == "__main__": 76 | app.run() 77 | -------------------------------------------------------------------------------- /docs/events.rst: -------------------------------------------------------------------------------- 1 | ====== 2 | Events 3 | ====== 4 | 5 | 6 | .. _event_handlers: 7 | 8 | Event Handlers 9 | ============== 10 | 11 | An event handler is an ``async`` function which you define and register with the app to 12 | handle certain classes of events. It is passed the instance of the event, and handlers 13 | are able to modify it for future handlers if they wish. 14 | 15 | For example:: 16 | 17 | @app.on(events.Receive) 18 | async def echo(event: events.Receive): 19 | event.connection.write(event.data) 20 | 21 | 22 | Multiple handlers can listen to a single event; they will be called in the order they 23 | are defined. If a handler does not want later handlers to receive the event, it can call 24 | ``event.stop()``. 25 | 26 | Events also are bubbled up to superclass handlers - see :ref:`event_inheritance` for 27 | more details. 28 | 29 | Filtering 30 | --------- 31 | 32 | When binding events, you can also specify that the event must have certain attributes 33 | and values:: 34 | 35 | @app.on(events.Receive, server=smtp_server, data="EHLO") 36 | def smtp_greeting(event): 37 | ... 38 | 39 | Because you will often want to filter by server, you can use the ``server.on`` method to 40 | automatically filter:: 41 | 42 | @smtp_server.on(events.Receive): 43 | def smtp_handler(event, data="EHLO"): 44 | ... 45 | 46 | 47 | .. _event_inheritance: 48 | 49 | Event inheritance 50 | ================= 51 | 52 | It is often desirable to bind a handler to listen to a category of events; for 53 | example, if you want to extend all connection events by adding a user attribute. 54 | 55 | To make this easy, Mara lets you bind a handler to an event base class. For 56 | example, a handler bound to ``events.Connection`` will also be called for 57 | ``Receive``, ``Connect`` and ``Disconnect`` events. 58 | 59 | The order that handlers are bound is still respected. 60 | 61 | 62 | Writing custom events 63 | ===================== 64 | 65 | Create a subclass of ``mara.events.Event`` and ensure it sets a docstring 66 | or ``__str__`` for logging. 67 | 68 | Handlers are matched by comparing classes, so you can have two classes with the 69 | same name (as long as they are in separate modules). 70 | 71 | 72 | API reference 73 | ============= 74 | 75 | .. autoclass:: mara.events.Event 76 | :members: 77 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Getting started 3 | =============== 4 | 5 | First, install mara with:: 6 | 7 | pip install mara 8 | 9 | See :doc:`install` for more options and details. 10 | 11 | 12 | A minimal service 13 | ================= 14 | 15 | A minimal Mara service looks something like this:: 16 | 17 | from mara import App, events 18 | from mara.servers.socket import SocketServer 19 | 20 | app = App() 21 | app.add_server(SocketServer(host="127.0.0.1", port=9000)) 22 | 23 | @app.on(events.Receive) 24 | async def echo(event: events.Receive): 25 | event.connection.write(event.data) 26 | 27 | app.run() 28 | 29 | Save this as ``echo.py`` and run it using ``python``:: 30 | 31 | $ python echo.py 32 | Server listening: Socket 127.0.0.1:9000 33 | 34 | Now connect to ``telnet://127.0.0.1:9000`` and anything you enter will be sent back to 35 | you - you have built a simple echo server. 36 | 37 | 38 | Lets look at the code in more detail: 39 | 40 | #. First we import ``App`` and create an instance of it. 41 | 42 | This ``app`` will be at the core of everything we do with Mara; it 43 | manages settings, servers, and handles events. 44 | 45 | #. Next we define and add our ``SocketServer``. 46 | 47 | There are different types of server, but this one is the simplest - it deals with 48 | raw bytes. If we wanted to run additional servers on other ports, we would define 49 | and add them here. 50 | 51 | #. Next we add an event handler, ``echo(...)``. 52 | 53 | We define an async function which accepts a single argument, ``event``, and then 54 | use the ``@app.on`` decorator to register it with the app. 55 | 56 | #. When an event of the type ``Receive`` is triggered, the ``echo`` function will be 57 | called with the current event object as the only argument. 58 | 59 | The event object contains all the relevant information about that event - in 60 | this case the ``event.connection`` and ``event.data``. 61 | 62 | #. We write the received data back to the connection with 63 | ``event.connection.write(...)``. 64 | 65 | The ``event.connection`` attribute is an instance of ``SocketConnection`` - our end 66 | of a specific connection. This provides the ``write()`` method to send data, and we 67 | just send back the raw data we received. 68 | 69 | #. Lastly we call the ``app.run()`` method. 70 | 71 | This starts the app's asyncio loop and runs the registered servers. 72 | -------------------------------------------------------------------------------- /mara/servers/socket.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import logging 5 | from pathlib import Path 6 | from ssl import Purpose, SSLContext, create_default_context 7 | 8 | from ..constants import DEFAULT_HOST, DEFAULT_PORT 9 | from .base import AbstractAsyncioServer 10 | from .connections.socket import SocketConnection, SocketMixin, TextConnection 11 | 12 | logger = logging.getLogger("mara.server") 13 | 14 | 15 | class AbstractSocketServer(AbstractAsyncioServer): 16 | connection_class: type[SocketMixin] 17 | _host: str 18 | _port: int 19 | _ssl_context: SSLContext | None = None 20 | 21 | def __init__( 22 | self, 23 | host: str = DEFAULT_HOST, 24 | port: int = DEFAULT_PORT, 25 | ssl: SSLContext | None = None, 26 | ssl_cert: str | Path | None = None, 27 | ssl_key: str | Path | None = None, 28 | ): 29 | self.host = host 30 | self.port = port 31 | if ssl or ssl_cert: 32 | if ssl and ssl_cert: 33 | raise ValueError("Cannot provide both an SSL context and certificate") 34 | 35 | if ssl_cert: 36 | ssl = create_default_context(Purpose.CLIENT_AUTH) 37 | ssl.check_hostname = False 38 | ssl.load_cert_chain(certfile=ssl_cert, keyfile=ssl_key) 39 | 40 | if not isinstance(ssl, SSLContext): 41 | raise ValueError("ssl must be an ssl.SSLContext") 42 | 43 | self._ssl_context = ssl 44 | 45 | super().__init__() 46 | 47 | def __str__(self): 48 | return f"{'SSL 'if self._ssl_context else ''}Socket {self.host}:{self.port}" 49 | 50 | async def create(self): 51 | await super().create() 52 | 53 | self.server = await asyncio.start_server( 54 | client_connected_cb=self.handle_connect, 55 | host=self.host, 56 | port=self.port, 57 | ssl=self._ssl_context, 58 | ) 59 | 60 | async def handle_connect( 61 | self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter 62 | ): 63 | connection: SocketMixin = self.connection_class( 64 | server=self, reader=reader, writer=writer 65 | ) 66 | await self.connected(connection) 67 | 68 | 69 | class SocketServer(AbstractSocketServer): 70 | connection_class: type[SocketConnection] = SocketConnection 71 | 72 | 73 | class TextServer(AbstractSocketServer): 74 | connection_class: type[TextConnection] = TextConnection 75 | -------------------------------------------------------------------------------- /mara/timers/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import logging 5 | from time import time 6 | from typing import TYPE_CHECKING, Awaitable, Callable 7 | 8 | 9 | if TYPE_CHECKING: 10 | from .. import App 11 | 12 | 13 | logger = logging.getLogger("mara.timer") 14 | 15 | 16 | class AbstractTimer: 17 | app: App 18 | callback: Callable[[AbstractTimer], Awaitable[None]] | None = None 19 | running: bool = False 20 | 21 | def __str__(self): 22 | if self.callback is None: 23 | return str(id(self)) 24 | return self.callback.__name__ 25 | 26 | def __call__( 27 | self, callback: Callable[[AbstractTimer], Awaitable[None]] 28 | ) -> Callable[[AbstractTimer], Awaitable[None]]: 29 | logger.debug(f"Timer {self} assigned callback {callback.__name__}") 30 | self.callback = callback 31 | return callback 32 | 33 | async def run(self, app: App): 34 | self.app = app 35 | 36 | logger.debug(f"Timer {self} starting") 37 | if not self.callback: 38 | raise ValueError(f"Timer {self} has not been assigned a callback") 39 | 40 | self.running = True 41 | last_due = time() 42 | while self.running: 43 | now = time() 44 | next_due = self.next_due(last_due, now) 45 | if not next_due: 46 | logger.debug(f"Timer {self} at {now} has no next due, stopping") 47 | break 48 | 49 | logger.debug(f"Timer {self} at {now} is next due {next_due}") 50 | await asyncio.sleep(next_due - now) 51 | logger.debug(f"Timer {self} active") 52 | await self.callback(self) 53 | 54 | self.running = False 55 | logger.debug(f"Timer {self} stopped") 56 | # TODO 57 | # self.app.remove_timer(self) 58 | 59 | def next_due(self, last_due: float, now: float) -> int | float | None: 60 | """ 61 | Return unix time for the next time the trigger is due 62 | 63 | Arguments: 64 | 65 | last_due (float): when this was last due 66 | now (float): when this was actually triggered 67 | 68 | Timers using ``now`` for relative calculations will result in drift. Timers 69 | should ensure that their next due is after ``now``. 70 | 71 | A next due time of 0 or None will terminate the timer 72 | """ 73 | return 0 74 | 75 | def stop(self): 76 | logger.debug(f"Timer {self} stopping") 77 | self.running = False 78 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======================================= 2 | Mara - Python network service framework 3 | ======================================= 4 | 5 | An asynchronous event-based python framework designed for building TCP/IP services - run 6 | multiple socket, telnet, HTTP and websocket servers from a single async process. 7 | 8 | .. image:: https://img.shields.io/pypi/v/mara.svg 9 | :target: https://pypi.org/project/mara/ 10 | :alt: PyPI 11 | 12 | .. image:: https://readthedocs.org/projects/python-mara/badge/?version=latest 13 | :target: https://python-mara.readthedocs.io/en/latest/?badge=latest 14 | :alt: Documentation Status 15 | 16 | .. image:: https://github.com/radiac/mara/actions/workflows/ci.yml/badge.svg 17 | :target: https://github.com/radiac/mara/actions/workflows/ci.yml 18 | :alt: Tests 19 | 20 | .. image:: https://codecov.io/gh/radiac/mara/branch/main/graph/badge.svg?token=BCNM45T6GI 21 | :target: https://codecov.io/gh/radiac/mara 22 | :alt: Test coverage 23 | 24 | 25 | * Project site: https://radiac.net/projects/mara/ 26 | * Source code: https://github.com/radiac/mara 27 | 28 | 29 | Features 30 | ======== 31 | 32 | * Asynchronous event-based framework 33 | * Supports multiple servers - text, telnet, HTTP and websockets included 34 | 35 | Requires Python 3.10 or later, see installation. 36 | 37 | See the `Documentation `_ 38 | for details of how Mara works. 39 | 40 | Note: The last release to support Python 2 and 3.9 was version 0.6.3. 41 | 42 | 43 | Quickstart 44 | ========== 45 | 46 | Install Mara with ``pip install mara``, then write your service using 47 | `event handlers `_. 48 | 49 | A minimal Mara service looks something like this:: 50 | 51 | from mara import App, events 52 | from mara.servers.socket import SocketServer 53 | 54 | app = App() 55 | app.add_server(SocketServer(host="127.0.0.1", port=9000)) 56 | 57 | @app.on(events.Receive) 58 | async def echo(event: events.Receive): 59 | event.connection.write(event.data) 60 | 61 | app.run() 62 | 63 | 64 | Save it as ``echo.py`` and run it:: 65 | 66 | $ python echo.py 67 | Server listening: Socket 127.0.0.1:9000 68 | 69 | 70 | More examples 71 | ============= 72 | 73 | Take a look at the `examples `_ to 74 | see how to start writing more complex services: 75 | 76 | * Chat over a raw text TCP socket, or one with TLS encryption 77 | * Chat over a telnet server 78 | * Chat over a websocket server 79 | * Two servers, one process: chat between a websocket and a telnet server 80 | 81 | 82 | Read the `documentation `_ for details of 83 | how Mara works. 84 | 85 | -------------------------------------------------------------------------------- /mara/servers/connections/socket.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from typing import TYPE_CHECKING, Protocol 5 | 6 | from .base import AbstractConnection 7 | 8 | if TYPE_CHECKING: 9 | from ..servers import AbstractServer 10 | 11 | 12 | class ConnectionCanStream(Protocol): 13 | reader: asyncio.StreamReader 14 | writer: asyncio.StreamWriter 15 | 16 | def __init__( 17 | self, 18 | server: AbstractServer, 19 | reader: asyncio.StreamReader, 20 | writer: asyncio.StreamWriter, 21 | ): ... 22 | 23 | 24 | class SocketMixin(AbstractConnection): 25 | """ 26 | Mixin for any connection class which uses byte sockets 27 | """ 28 | 29 | reader: asyncio.StreamReader 30 | writer: asyncio.StreamWriter 31 | 32 | def __init__( 33 | self, 34 | server: AbstractServer, 35 | reader: asyncio.StreamReader, 36 | writer: asyncio.StreamWriter, 37 | ): 38 | super().__init__(server) 39 | self.reader = reader 40 | self.writer = writer 41 | 42 | def __str__(self) -> str: 43 | ip, port = self.writer.get_extra_info("peername") 44 | return str(ip) 45 | 46 | async def _write(self, data: bytes): 47 | self.writer.write(data) 48 | await self.writer.drain() 49 | self._check_is_active() 50 | 51 | def _check_is_active(self): 52 | if self.reader.at_eof() or self.writer.transport.is_closing(): 53 | self.connected = False 54 | 55 | async def close(self): 56 | # Close the streams 57 | self.writer.close() 58 | await self.writer.wait_closed() 59 | 60 | # Terminate the listener loop 61 | # TODO 62 | # this.listener_task 63 | 64 | await super().close() 65 | 66 | 67 | class SocketConnection(SocketMixin, AbstractConnection[bytes]): 68 | """ 69 | Read and write bytes 70 | """ 71 | 72 | async def read(self) -> bytes: 73 | # TODO: read size and buffers 74 | data = await self.reader.read(1024) 75 | self._check_is_active() 76 | return data 77 | 78 | 79 | class TextConnection(SocketMixin, AbstractConnection[str]): 80 | """ 81 | Read and write unicode over an underlying byte socket 82 | """ 83 | 84 | async def read(self) -> str: 85 | # TODO: read size and buffers 86 | try: 87 | data: bytes = await self.reader.readuntil(b"\r\n") 88 | except asyncio.exceptions.IncompleteReadError: 89 | self.connected = False 90 | return "" 91 | data = data.rstrip(b"\r\n") 92 | self._check_is_active() 93 | text: str = data.decode() 94 | return text 95 | 96 | def write(self, data: str, *, end: str = "\r\n"): 97 | raw_data: bytes = f"{data}{end}".encode() 98 | super().write(raw_data) 99 | -------------------------------------------------------------------------------- /mara/storage/dict.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any 3 | 4 | from .base import Store, store_classes 5 | 6 | 7 | class DictStore(Store, dict): 8 | """ 9 | A dict-based store with no permanence 10 | 11 | For convenience, keys are also available as attributes. 12 | 13 | To freeze a special case value, define: 14 | 15 | * ``freeze_KEY(key:str, value:Any) -> Any`` which must return a value serialisable 16 | by ``json``, and 17 | * ``thaw_KEY(key:str, value:Any) -> Any`` which converts the frozen value back into 18 | the real value 19 | 20 | replacing ``KEY`` with the key of the special case value. 21 | """ 22 | 23 | def __getattr__(self, key): 24 | return self[key] 25 | 26 | def __setattr__(self, key, value): 27 | self[key] = value 28 | 29 | async def store(self) -> str: 30 | # Start by making all values safe to serialise 31 | safe = {} 32 | for key, value in self.items(): 33 | safe[key] = await self._freeze_item(key, value) 34 | 35 | # Now serialise to json 36 | data = json.dumps(safe) 37 | return data 38 | 39 | async def _freeze_item(self, key: str, value: Store | Any) -> Any: 40 | """ 41 | Make value safe to serialise 42 | """ 43 | freezer_fn_name = f"freeze_{key}" 44 | if freezer_fn_name in dir(self): 45 | # can't use hasattr/getattr because of __getattr__ 46 | freezer_fn = getattr(self, freezer_fn_name) 47 | return freezer_fn(key, value) 48 | 49 | elif isinstance(value, Store): 50 | data = await value.store() 51 | return { 52 | "_store_class": type(value).__name__, 53 | "data": data, 54 | } 55 | 56 | return value 57 | 58 | @classmethod 59 | async def restore(cls, data: str): 60 | """ 61 | Deserialise and create a new object 62 | """ 63 | # Deserialise from JSON 64 | safe = json.loads(data) 65 | 66 | # Thaw values 67 | kwargs = {} 68 | for key, value in safe.items(): 69 | kwargs[key] = await cls._thaw_item(key, value) 70 | 71 | return cls(**kwargs) 72 | 73 | @classmethod 74 | async def _thaw_item(cls, key: str, value: Any) -> Any: 75 | thaw_fn_name = f"freeze_{key}" 76 | if thaw_fn_name in dir(cls): 77 | # can't use hasattr/getattr because of __getattr__ 78 | thaw_fn = getattr(cls, thaw_fn_name) 79 | return thaw_fn(key, value) 80 | 81 | elif isinstance(value, dict) and "_store_class" in value: 82 | store_cls_name = value["_store_class"] 83 | store_cls = store_classes[store_cls_name] 84 | obj = await store_cls.restore(value["data"]) 85 | return obj 86 | 87 | return value 88 | -------------------------------------------------------------------------------- /mara/servers/connections/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import logging 5 | from typing import TYPE_CHECKING, Generic, TypeVar 6 | 7 | from ...events import Connect, Disconnect, Receive 8 | from ...storage.dict import DictStore 9 | 10 | if TYPE_CHECKING: 11 | from ..base import AbstractServer 12 | 13 | 14 | ContentType = TypeVar("ContentType") 15 | 16 | logger = logging.getLogger("mara.connection") 17 | 18 | 19 | class AbstractConnection(Generic[ContentType]): 20 | server: AbstractServer 21 | connected: bool 22 | read_task: asyncio.Task 23 | write_task: asyncio.Task 24 | write_queue: asyncio.Queue 25 | session: DictStore 26 | 27 | def __init__(self, server: AbstractServer): 28 | self.server = server 29 | self.connected = True 30 | self.session = DictStore() 31 | # TODO: Queue(maxsize=?) - configure from server 32 | self.write_queue = asyncio.Queue() 33 | 34 | def __str__(self): 35 | return "unknown" 36 | 37 | async def read(self) -> ContentType: 38 | raise NotImplementedError() 39 | 40 | def write(self, data: ContentType): 41 | """ 42 | Write to the outbound queue 43 | """ 44 | self.write_queue.put_nowait(data) 45 | 46 | async def flush(self): 47 | """ 48 | Wait for all outbound data to be sent 49 | """ 50 | await self.write_queue.join() 51 | 52 | async def _write(self, data: ContentType): 53 | """ 54 | Write to the connection 55 | """ 56 | raise NotImplementedError() 57 | 58 | async def close(self): 59 | logger.info(f"Connection {self} closed") 60 | await self.server.disconnected(self) 61 | 62 | def run(self): 63 | """ 64 | Add the connection read and write tasks to the app's loop 65 | """ 66 | if not self.server.app: 67 | raise ValueError("Connection read loop must be part of an active server") 68 | self.read_task = self.server.app.create_task(self._read_loop()) 69 | self.write_task = self.server.app.create_task(self._write_loop()) 70 | 71 | async def _read_loop(self): 72 | app = self.server.app 73 | if not app: 74 | raise ValueError("Connection read loop must be part of an active server") 75 | await app.events.trigger(Connect(self)) 76 | logger.info(f"Connection {self} connected") 77 | while self.connected: 78 | data: ContentType = await self.read() 79 | if data: 80 | await app.events.trigger(Receive(self, data)) 81 | 82 | logger.info(f"Connection {self} disconnected") 83 | await app.events.trigger(Disconnect(self)) 84 | 85 | async def _write_loop(self): 86 | while self.connected: 87 | data: ContentType = await self.write_queue.get() 88 | await self._write(data) 89 | self.write_queue.task_done() 90 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | import os 18 | import sys 19 | 20 | import sphinx_radiac_theme # noqa 21 | 22 | 23 | # Make sure sphinx can find the source 24 | sys.path.insert(0, os.path.abspath("../")) 25 | sys.path.insert(0, os.path.abspath("../examples/")) 26 | 27 | from setup import find_version # noqa 28 | 29 | 30 | # -- Project information ----------------------------------------------------- 31 | 32 | project = "mara" 33 | copyright = "2022, Richard Terry" 34 | author = "Richard Terry" 35 | release = find_version("..", "mara", "__init__.py") 36 | 37 | 38 | # -- General configuration --------------------------------------------------- 39 | 40 | # Add any Sphinx extension module names here, as strings. They can be 41 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 42 | # ones. 43 | extensions = [ 44 | "sphinx.ext.napoleon", 45 | "sphinx_radiac_theme", 46 | "sphinx.ext.autodoc", 47 | "sphinx_gitref", 48 | ] 49 | 50 | # Add any paths that contain templates here, relative to this directory. 51 | templates_path = ["_templates"] 52 | 53 | # List of patterns, relative to source directory, that match files and 54 | # directories to ignore when looking for source files. 55 | # This pattern also affects html_static_path and html_extra_path. 56 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 57 | 58 | 59 | # -- Options for HTML output ------------------------------------------------- 60 | 61 | # The theme to use for HTML and HTML Help pages. See the documentation for 62 | # a list of builtin themes. 63 | # 64 | html_theme = "sphinx_radiac_theme" 65 | 66 | # Add any paths that contain custom static files (such as style sheets) here, 67 | # relative to this directory. They are copied after the builtin static files, 68 | # so a file named "default.css" will overwrite the builtin "default.css". 69 | html_static_path = ["_static"] 70 | 71 | html_theme_options = { 72 | "analytics_id": "G-NH3KEN9NBN", 73 | "logo_only": False, 74 | "display_version": True, 75 | # Toc options 76 | "collapse_navigation": True, 77 | "sticky_navigation": True, 78 | "navigation_depth": 4, 79 | "includehidden": True, 80 | "titles_only": False, 81 | # radiac.net theme 82 | "radiac_project_slug": "mara", 83 | "radiac_project_name": "mara", 84 | "radiac_subsite_links": [ 85 | # (url, label), 86 | ], 87 | } 88 | -------------------------------------------------------------------------------- /tests/fixtures/client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import socket 5 | 6 | import pytest 7 | 8 | from .constants import TEST_HOST, TEST_PORT 9 | 10 | 11 | logger = logging.getLogger("tests.fixtures.client") 12 | 13 | 14 | class BaseClient: 15 | """ 16 | Blocking test client to connect to an app server 17 | """ 18 | 19 | name: str 20 | 21 | def __init__(self, name: str): 22 | self.name = name 23 | 24 | def __str__(self): 25 | return self.name 26 | 27 | 28 | class SocketClient(BaseClient): 29 | socket: socket.socket | None 30 | buffer: bytes 31 | 32 | def __init__(self, name: str): 33 | super().__init__(name) 34 | self.buffer = b"" 35 | 36 | def connect(self, host: str, port: int): 37 | logger.debug(f"Socket client {self} connecting") 38 | self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 39 | self.socket.connect((host, port)) 40 | logger.debug(f"Socket client {self} connected") 41 | 42 | def write(self, raw: bytes): 43 | if not self.socket: 44 | raise ValueError("Socket not open") 45 | logger.debug(f"Socket client {self} writing {raw!r}") 46 | self.socket.sendall(raw) 47 | 48 | def read(self, len: int = 1024) -> bytes: 49 | if not self.socket: 50 | raise ValueError("Socket not open") 51 | raw: bytes = self.socket.recv(len) 52 | logger.debug(f"Socket client {self} received {raw!r}") 53 | return raw 54 | 55 | def read_line(self, len: int = 1024) -> bytes: 56 | if b"\r\n" not in self.buffer: 57 | self.buffer += self.read(len) 58 | 59 | if b"\r\n" not in self.buffer: 60 | raise ValueError("Line not found") 61 | 62 | line, self.buffer = self.buffer.split(b"\r\n", 1) 63 | return line 64 | 65 | def close(self): 66 | if not self.socket: 67 | raise ValueError("Socket not open") 68 | logger.debug(f"Socket client {self} closing") 69 | self.socket.close() 70 | logger.debug(f"Socket client {self} closed") 71 | 72 | 73 | @pytest.fixture 74 | def socket_client_factory(request: pytest.FixtureRequest): 75 | """ 76 | Socket client factory fixture 77 | 78 | Usage:: 79 | 80 | def test_client(app_harness, socket_client_factory): 81 | app_harness(myapp) 82 | client = socket_client_factory() 83 | client.write(b'hello') 84 | assert client.read() == b'hello' 85 | """ 86 | clients = [] 87 | 88 | def connect(name: str | None = None, host: str = TEST_HOST, port: int = TEST_PORT): 89 | client_name = request.node.name 90 | if name is not None: 91 | client_name = f"{client_name}:{name}" 92 | 93 | client = SocketClient(client_name) 94 | client.connect(host, port) 95 | clients.append(client) 96 | return client 97 | 98 | yield connect 99 | 100 | for client in clients: 101 | client.close() 102 | -------------------------------------------------------------------------------- /tests/fixtures/harness.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test harness to run mara in a separate thread to tests 3 | """ 4 | from __future__ import annotations 5 | 6 | import logging 7 | import threading 8 | import time 9 | from typing import TYPE_CHECKING 10 | 11 | import pytest 12 | from mara.app.app import Status 13 | from mara.servers.socket import AbstractSocketServer 14 | 15 | from .constants import TEST_HOST, TEST_PORT 16 | 17 | logger = logging.getLogger("tests.fixtures.harness") 18 | 19 | 20 | if TYPE_CHECKING: 21 | from mara import App 22 | 23 | # Debug everything that we do 24 | DEBUG = False 25 | 26 | # Limits for attempting to start the server, and to connect to the server 27 | ATTEMPT_MAX = 10 28 | ATTEMPT_SLEEP = 0.1 29 | 30 | 31 | class ThreadCounter(object): 32 | """ 33 | Global thread counter to generate unique ids 34 | """ 35 | 36 | def __init__(self): 37 | self.count = 0 38 | 39 | def get(self): 40 | self.count += 1 41 | return self.count 42 | 43 | 44 | _thread_counter = ThreadCounter() 45 | 46 | 47 | class AppHarness: 48 | """ 49 | Run the app in a separate thread 50 | """ 51 | 52 | name: str 53 | app: App 54 | 55 | def __init__(self, name: str): 56 | self.name = name 57 | 58 | def run(self, app: App): 59 | self.app = app 60 | 61 | # Give service a thread 62 | self.thread_id = _thread_counter.get() 63 | 64 | # Start service thread 65 | self._exception = None 66 | self.thread = threading.Thread( 67 | name="%s-%s" % (self.__class__.__name__, self.thread_id), 68 | target=self._run_app, 69 | ) 70 | self.thread.daemon = True # Kill with main process 71 | logger.debug("App thread starting") 72 | self.thread.start() 73 | 74 | # Wait for service to start or fail 75 | running = False 76 | while not self._exception and not running: 77 | time.sleep(ATTEMPT_SLEEP) 78 | running = self.app.status >= Status.RUNNING 79 | 80 | # Catch failure 81 | if not running: 82 | logger.debug("App failed to start in time, killing thread") 83 | self.thread.join() 84 | raise RuntimeError("Thread %s failed" % self.thread.name) 85 | 86 | if self._exception: 87 | logger.debug( 88 | f"App failed to start with exception {self._exception}, killing thread" 89 | ) 90 | self.thread.join() 91 | raise RuntimeError("Thread %s failed" % self.thread.name) 92 | 93 | logger.debug("App thread is running") 94 | 95 | def _run_app(self): 96 | """ 97 | Run the app. Call this as a thread target. 98 | """ 99 | logger.debug("App running") 100 | try: 101 | self.app.run() 102 | except Exception as e: 103 | self._exception = e 104 | logger.debug(f"App error: {e}") 105 | raise 106 | 107 | def stop(self): 108 | """ 109 | Stop the app 110 | """ 111 | if not self.app.loop: 112 | raise ValueError("Loop does not exist") 113 | logger.debug("App stopping") 114 | self.app.loop.call_soon_threadsafe(self.app.stop) 115 | logger.debug("App stopped, killing thread") 116 | self.thread.join() 117 | logger.debug("App thread destroyed") 118 | 119 | 120 | @pytest.fixture 121 | def app_harness(request: pytest.FixtureRequest): 122 | harness = AppHarness(name=request.node.name) 123 | 124 | def create_harness(app: App): 125 | for i, server in enumerate(app.servers): 126 | if isinstance(server, AbstractSocketServer): 127 | server.host = TEST_HOST 128 | server.port = TEST_PORT + i 129 | harness.run(app) 130 | 131 | yield create_harness 132 | 133 | harness.stop() 134 | -------------------------------------------------------------------------------- /mara/servers/http.py: -------------------------------------------------------------------------------- 1 | """ 2 | HTTP server 3 | 4 | Wrapper around aiohttp, https://pypi.org/project/aiohttp/ 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | from asyncio import sleep 10 | from typing import TYPE_CHECKING 11 | 12 | from aiohttp import web 13 | 14 | from ..constants import DEFAULT_HOST, DEFAULT_PORT 15 | from ..status import Status 16 | from .base import AbstractAsyncioServer, AbstractServer 17 | from .connections.http import WebSocketConnection 18 | 19 | if TYPE_CHECKING: 20 | from ..app import App 21 | 22 | 23 | class WebSocketServer(AbstractServer): 24 | """ 25 | A websocket server is a sub-server of an HttpServer. 26 | 27 | To create a websocket server, call HttpServer.create_websocket(..) 28 | """ 29 | 30 | connection_class = WebSocketConnection 31 | 32 | def __init__(self, http_server: HttpServer): 33 | self.http_server = http_server 34 | super().__init__() 35 | 36 | async def listen_loop(self): 37 | # Keep the server active 38 | while self.http_server.status != Status.STOPPED: 39 | await sleep(0.1) 40 | 41 | async def handle_connect(self, request): 42 | connection: WebSocketConnection = self.connection_class(server=self) 43 | await connection.prepare(request) 44 | await self.connected(connection) 45 | 46 | # Keep the connection open 47 | while connection.connected: 48 | await sleep(0.1) 49 | 50 | 51 | class HttpServer(AbstractAsyncioServer): 52 | #: Host 53 | host: str 54 | 55 | #: Port 56 | port: int 57 | 58 | #: aiohttp web.Application instance 59 | web_app: web.Application 60 | 61 | #: websocket servers provided by this server 62 | websockets: list[WebSocketServer] 63 | 64 | def __init__( 65 | self, 66 | host: str = DEFAULT_HOST, 67 | port: int = DEFAULT_PORT, 68 | web_app: web.Application | None = None, 69 | **handler_kwargs, 70 | ): 71 | self.host = host 72 | self.port = port 73 | if web_app: 74 | self.web_app = web_app 75 | else: 76 | self.web_app = web.Application() 77 | self.handler_kwargs = handler_kwargs 78 | 79 | self.websockets: list[WebSocketServer] = [] 80 | super().__init__() 81 | 82 | def __str__(self): 83 | return f"HTTP {self.host}:{self.port}" 84 | 85 | async def run(self, app: App): 86 | for ws in self.websockets: 87 | app.add_server(ws) 88 | await super().run(app) 89 | 90 | async def create(self): 91 | await super().create() 92 | 93 | loop = self.app.loop if self.app else None 94 | if loop is None: 95 | raise ValueError("Cannot start HttpServer without running loop") 96 | 97 | self.server = await loop.create_server( 98 | protocol_factory=self.web_app.make_handler(**self.handler_kwargs), 99 | host=self.host, 100 | port=self.port, 101 | ) 102 | 103 | def add_route(self, method: str, path: str, *, name=None, expect_handler=None): 104 | def decorator(handler): 105 | self.web_app.router.add_route( 106 | method, path, handler, name=name, expect_handler=expect_handler 107 | ) 108 | return handler 109 | 110 | return decorator 111 | 112 | def add_get(self, path: str, **kwargs): 113 | return self.add_route("GET", path, **kwargs) 114 | 115 | def add_post(self, path: str, **kwargs): 116 | return self.add_route("POST", path, **kwargs) 117 | 118 | def add_head(self, path: str, **kwargs): 119 | return self.add_route("HEAD", path, **kwargs) 120 | 121 | def add_put(self, path: str, **kwargs): 122 | return self.add_route("PUT", path, **kwargs) 123 | 124 | def add_patch(self, path: str, **kwargs): 125 | return self.add_route("PATCH", path, **kwargs) 126 | 127 | def add_delete(self, path: str, **kwargs): 128 | return self.add_route("DELETE", path, **kwargs) 129 | 130 | def add_view(self, path: str, **kwargs): 131 | return self.add_route("*", path, **kwargs) 132 | 133 | def create_websocket(self, path: str): 134 | ws = WebSocketServer(self) 135 | self.add_get(path)(ws.handle_connect) 136 | self.websockets.append(ws) 137 | if self.app: 138 | self.app.add_server(ws) 139 | return ws 140 | -------------------------------------------------------------------------------- /mara/servers/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import logging 5 | from typing import TYPE_CHECKING 6 | 7 | from ..app import event_manager 8 | from ..events import Event, ListenStart, ListenStop 9 | from ..status import Status 10 | 11 | if TYPE_CHECKING: 12 | from ..app import App 13 | from .connections import AbstractConnection 14 | 15 | logger = logging.getLogger("mara.server") 16 | 17 | DeferredEventType = tuple[ 18 | type[Event], event_manager.HandlerType, event_manager.FilterType 19 | ] 20 | 21 | 22 | class AbstractServer: 23 | app: App | None = None 24 | connections: list[AbstractConnection] 25 | _status: Status = Status.IDLE 26 | _events: list[DeferredEventType] 27 | 28 | def __init__(self): 29 | self.connections = [] 30 | self._events: list[DeferredEventType] = [] 31 | 32 | def __str__(self): 33 | return self.__class__.__name__ 34 | 35 | async def run(self, app: App): 36 | """ 37 | Create the server 38 | """ 39 | self.app = app 40 | 41 | # Register deferred events 42 | for event_class, handler, filters in self._events: 43 | self.app.on(event_class=event_class, handler=handler, **filters) 44 | 45 | # Initialise server and listen 46 | await self.create() 47 | await self.listen() 48 | 49 | async def create(self): 50 | logger.debug(f"Server starting: {self}") 51 | self._status = Status.STARTING 52 | 53 | async def listen(self): 54 | """ 55 | Main listening loop 56 | """ 57 | logger.debug(f"Server running: {self}") 58 | self._status = Status.RUNNING 59 | 60 | # Raise the event 61 | await self.app.events.trigger(ListenStart(self)) 62 | 63 | # Start the server 64 | await self.listen_loop() 65 | 66 | # Look has exited, likely due to a call to self.stop() 67 | logger.debug(f"Server stopping: {self}") 68 | await self.app.events.trigger(ListenStop(self)) 69 | logger.info(f"Server stopped: {self}") 70 | self._status = Status.STOPPED 71 | 72 | async def listen_loop(self): 73 | """ 74 | Placeholder for subclasses to implement their listen loop 75 | """ 76 | pass 77 | 78 | async def connected(self, connection: AbstractConnection): 79 | """ 80 | Register a new client connection and start the lifecycle 81 | """ 82 | logger.info(f"[{self}] Connection from {connection}") 83 | self.connections.append(connection) 84 | connection.run() 85 | 86 | async def disconnected(self, connection: AbstractConnection): 87 | """ 88 | Unregister a connection that has disconnected 89 | """ 90 | self.connections.remove(connection) 91 | 92 | def stop(self): 93 | """ 94 | Shut down the server 95 | """ 96 | self._status = Status.STOPPING 97 | logger.info(f"Server closing: {self}") 98 | 99 | @property 100 | def status(self) -> Status: 101 | return self._status 102 | 103 | def on( 104 | self, 105 | event_class: type[Event], 106 | handler: event_manager.HandlerType | None = None, 107 | **filters: event_manager.FilterType, 108 | ): 109 | filters["server"] = self 110 | 111 | if self.app is not None: 112 | return self.app.on(event_class=event_class, handler=handler, **filters) 113 | 114 | def defer(handler): 115 | self._events.append((event_class, handler, filters)) 116 | return handler 117 | 118 | if handler is not None: 119 | defer(handler) 120 | return handler 121 | 122 | return defer 123 | 124 | 125 | class AbstractAsyncioServer(AbstractServer): 126 | """ 127 | Base class for servers based on asyncio.Server 128 | """ 129 | 130 | server: asyncio.base_events.Server 131 | 132 | async def listen_loop(self): 133 | async with self.server: 134 | await self.server.serve_forever() 135 | 136 | def stop(self): 137 | """ 138 | Shut down the server 139 | """ 140 | super().stop() 141 | self.server.close() 142 | 143 | @property 144 | def status(self) -> Status: 145 | status = super().status 146 | 147 | if status == Status.RUNNING and not self.server.is_serving(): 148 | return Status.STARTING 149 | 150 | return status 151 | -------------------------------------------------------------------------------- /mara/app/event_manager.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from collections import defaultdict 5 | from collections.abc import Awaitable, Callable 6 | from typing import TYPE_CHECKING, Any 7 | 8 | from ..events import Event 9 | 10 | # Type aliases 11 | HandlerType = Callable[[Event], Awaitable[None]] 12 | FilterType = dict[str, Any] 13 | EventsType = dict[type[Event], list[tuple[HandlerType, FilterType]]] 14 | 15 | if TYPE_CHECKING: 16 | from .app import App 17 | 18 | 19 | logger = logging.getLogger("mara.event") 20 | 21 | 22 | class EventManager: 23 | """ 24 | Internal event manager 25 | 26 | Most apps will have one EventManager, created and managed by the App. In normal use 27 | use the ``App.listen()`` and ``App.trigger()`` methods instead of calling this 28 | directly. 29 | """ 30 | 31 | app: App 32 | 33 | # Registered events 34 | events: EventsType 35 | 36 | # Defined event classes 37 | _known_events: dict[type[Event], None] 38 | 39 | def __init__(self, app: App): 40 | # Initialise events 41 | self.app = app 42 | self.events: EventsType = defaultdict(list) 43 | self._known_events: dict[type[Event], None] = {} 44 | 45 | def listen( 46 | self, 47 | event_class: type[Event], 48 | handler: HandlerType | None = None, 49 | **filters: FilterType, 50 | ): 51 | """ 52 | Bind a handler callback to the specified event class, and to its subclasses 53 | 54 | Can be called directly:: 55 | 56 | manager.listen(Event, handler) 57 | manager.listen(Event, handler) 58 | 59 | or can be called as a decorator with no handler argument:: 60 | 61 | @manager.listen(Event) 62 | async def callback(event): 63 | ... 64 | 65 | Arguments: 66 | event_class (Type[Event]): The Event class to listen for 67 | handler (Awaitable | None): The handler, if not being decorated 68 | server (AbstractServer | List[AbstractServer] | None): The Server class or 69 | classes to filter inbound events 70 | """ 71 | # Called directly 72 | if handler is not None: 73 | self._listen(event_class, handler, filters) 74 | return handler 75 | 76 | # Called as a decorator 77 | def decorator(fn): 78 | self._listen(event_class, fn, filters) 79 | return fn 80 | 81 | return decorator 82 | 83 | def _listen( 84 | self, 85 | event_class: type[Event], 86 | handler: HandlerType, 87 | filters: FilterType, 88 | ): 89 | """ 90 | Internal method to recursively bind a handler to the specified event 91 | class and its subclasses. Call listen() instead. 92 | """ 93 | # Recurse subclasses. Do it before registering for this event in case 94 | # they're not known yet, then they'll copy handlers for this event 95 | for subclass in event_class.__subclasses__(): 96 | self._listen(subclass, handler, filters) 97 | 98 | # Register class 99 | self._ensure_known_event(event_class) 100 | self.events[event_class].append((handler, filters)) 101 | 102 | def _ensure_known_event(self, event_class: type[Event]): 103 | """ 104 | Ensure the event class is known to the service. 105 | 106 | If it is not, inherit handlers from its first base class 107 | """ 108 | # If known, nothing to do 109 | if event_class in self._known_events: 110 | return 111 | 112 | # If base class isn't an event (ie we're dealing with Event), nothing to do 113 | base_cls = event_class.__bases__[0] 114 | if not issubclass(base_cls, Event): 115 | return 116 | 117 | # We'll know this going forwards, don't want to re-register 118 | self._known_events[event_class] = None 119 | 120 | # Ensure base class is known - if it isn't, we'll keep working up until we find 121 | # something we do know 122 | self._ensure_known_event(base_cls) 123 | 124 | # We've found a known event, propagate down any handlers 125 | 126 | # Propagating at registration means that we don't need to walk the MRO for 127 | # every event raised 128 | self.events[event_class] = self.events[base_cls][:] 129 | 130 | async def trigger(self, event: Event): 131 | """ 132 | Trigger the specified event 133 | """ 134 | # Make sure we've seen this event class before - will propagate handlers if not 135 | event_class = type(event) 136 | self._ensure_known_event(event_class) 137 | 138 | # Make sure all listeners have access to the app, in case they're out of scope 139 | event.app = self.app 140 | 141 | # Log the event 142 | logger.info(str(event)) 143 | # self.app.log.event(event) 144 | 145 | # Call all handlers 146 | handler: HandlerType 147 | filters: FilterType 148 | for handler, filters in self.events[event_class]: 149 | # Catch stopped event 150 | if event.stopped: 151 | return 152 | 153 | # Filter anything which doesn't match 154 | # TODO: This could be enhanced to allow more complex filtering rules 155 | if not all( 156 | hasattr(event, filter_key) 157 | and getattr(event, filter_key) == filter_value 158 | for filter_key, filter_value in filters.items() 159 | ): 160 | return 161 | 162 | # Pass to the handler 163 | await handler(event) 164 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Changelog 3 | ========= 4 | 5 | Releases which require special upgrade steps will be marked with a link to instructions. 6 | 7 | Changes for upcoming releases will be listed without a release date - these 8 | are available by installing the development branch from github (see 9 | :doc:`install` for details). 10 | 11 | Changelog 12 | ========= 13 | 14 | 2.1.1, 2024-05-19 15 | ----------------- 16 | 17 | Changes: 18 | 19 | * Moved telnetlib3 and aiohttp from optional extras to required dependencies to match 20 | documentation 21 | 22 | 23 | 2.1.0, 2024-05-18 24 | ----------------- 25 | 26 | Features: 27 | 28 | * Added SSL/TLS support to socket server 29 | * Added support for HTTP and websockets using aiohttp 30 | * Added support for ``@server.on`` for easier event filtering to a server 31 | 32 | Internal: 33 | 34 | * Moved ``mara.clients`` to ``mara.server.connections`` 35 | * Renamed ``*Client`` classes to ``*Connection`` 36 | * Changed ``server.clients`` to ``server.connections`` 37 | * Changed ``ClientEvent.client`` to ``ConnectionEvent.connection`` 38 | 39 | Bugfixes: 40 | 41 | * Fixed issue with multiple telnet servers 42 | 43 | 44 | 2.0.0, 2022-11-18 45 | ----------------- 46 | 47 | Features: 48 | 49 | * Moved to an asyncio loop 50 | * Added support for multiple servers 51 | * Telnet support using telnetlib3 52 | 53 | 54 | Known issues: 55 | 56 | * Removed contrib modules - will return as a separate package 57 | * Angel is not implemented yet 58 | 59 | 60 | Mara 0.6.3 was the last release of version 1 of Mara. Although Version 2 has a similar 61 | API, it is a complete rewrite and most user code will need to be updated and refactored. 62 | 63 | The key differences are: 64 | 65 | * ``mara.Service`` is now ``mara.App``, and now uses asyncio. 66 | * Servers must be defined and added using ``add_server`` 67 | * Event handlers need to be ``async``. Handler classes have been removed. To capture 68 | user data a handler must ``await event.client.read()`` instead of calling ``yield``. 69 | * Timers are now defined as instances. 70 | * All contrib modules have been removed in version 2. These will be brought back as a 71 | separate package. 72 | * Version 2.0.0 is missing the angel, ``mara`` command, and telnet support. 73 | These are planned for a future version. 74 | 75 | 76 | 0.7.0, Not released 77 | ------------------- 78 | 79 | Feature: 80 | 81 | * Added Protocol to Client 82 | 83 | Changes: 84 | 85 | * Client no longer supports telnet by default; must be initialised with the 86 | * Styles refactored to support different renderers 87 | * Setting ``client_buffer_size`` is now ``recv_buffer_size`` and 88 | ``send_buffer_size`` 89 | * Flash policy server has been removed 90 | 91 | 92 | 0.6.3, 2018-10-06 93 | ----------------- 94 | 95 | Feature: 96 | 97 | * Added Python 3.6 support 98 | 99 | 100 | Bugfix 101 | 102 | * Corrected packaging 103 | 104 | 105 | Note: tests are no longer performed for Python 3.2 or 3.3 as they are EOL 106 | 107 | 108 | 0.6.2, 2017-07-25 109 | ----------------- 110 | 111 | Bugfix: 112 | 113 | * Corrected docs 114 | * Corrected manifest 115 | 116 | 117 | 0.6.1, 2016-10-20 118 | ----------------- 119 | 120 | Bugfix: 121 | 122 | * Improved password hashing algorithm (merges #5) 123 | * Fixed empty password handling (merges #6) 124 | 125 | Internal: 126 | 127 | * Improved support for custom password hashing algorithms, with support for 128 | passwords stored using old algorithms 129 | 130 | Thanks to: 131 | 132 | * Marcos Marado (marado) for #5 and #6 133 | 134 | 135 | 0.6.0, 2015-12-20 136 | ----------------- 137 | 138 | Feature: 139 | 140 | * Python 3 support (3.2, 3.3, 3.4 and 3.5) 141 | * Unicode support 142 | 143 | Changed: 144 | 145 | * Changed ``write`` to accept ``newline=False`` kwarg, to control whether the 146 | line ends with a newline when the socket is not in raw mode 147 | * Example echo service now runs in raw mode 148 | * The command registry can now unregister commands 149 | 150 | Internal: 151 | 152 | * Added angel.stop() to terminate angel from threads 153 | * Fixed angel's start delay reset when starting a service without a saved state 154 | 155 | 156 | 0.5.0, 2015-12-13 157 | ----------------- 158 | 159 | Feature: 160 | 161 | * Added class-based event handlers, with support for use as command functions 162 | * Added room support 163 | * Added YAML-based store instantiation 164 | * Added command aliases 165 | * Refactored user-related commands from talker example 166 | * Simplified social command definition and generation 167 | * Added styles 168 | 169 | Removed: 170 | 171 | * Replaced ClientSerialiser with improved Field serialiser 172 | * Replaced StoreField with improved Field serialiser 173 | * Removed socials import from contrib.commands, so the code is now only loaded 174 | if you specifically want it 175 | 176 | Internal: 177 | 178 | * Added client containers 179 | * Added ``active`` to the list of reserved store field names 180 | * Changed test root dir to ``examples`` 181 | 182 | 183 | 0.4.0, 2015-11-21 184 | ----------------- 185 | 186 | Feature: 187 | 188 | * Renamed project 189 | * Added angel to support seamless restarts 190 | 191 | Internal: 192 | 193 | * Added root_path setting for more reliable relative paths 194 | 195 | 196 | 0.3.0, 2015-02-16 197 | ----------------- 198 | 199 | Feature: 200 | 201 | * Restructured from plugin-based command to framework 202 | 203 | 204 | 0.2.1, 2012-01-20 205 | ----------------- 206 | 207 | Feature: 208 | 209 | * Extra commands in plugins 210 | 211 | Internal: 212 | 213 | * Better command error handling - now piped to users 214 | * Plugins now private namespaces with shared dict 'publics' 215 | 216 | 217 | 0.2.0, 2012-01-18 218 | ----------------- 219 | 220 | Feature: 221 | 222 | * Added telnet negotiation 223 | * Added socials 224 | 225 | Internal: 226 | 227 | * Added support for different newline types 228 | * Split User into User and Client objects 229 | * Added argument parsing to Command object 230 | 231 | 232 | 0.1.1, 2012-01-16 233 | ----------------- 234 | 235 | Internal: 236 | 237 | * Rearranged plugin files to improve clarity 238 | * Internal: Plugin lists 239 | 240 | 241 | 0.1.0, 2012-01-15 242 | ----------------- 243 | 244 | Feature: 245 | 246 | * Events, plugins 247 | * IRC- and MUD-style chat 248 | 249 | Internal: 250 | 251 | * Moved all non-core code into plugins 252 | 253 | 254 | 0.0.1, 2012-01-13 255 | ----------------- 256 | 257 | Feature: 258 | 259 | * Initial release of new version in Python 260 | -------------------------------------------------------------------------------- /mara/app/app.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import logging 5 | from typing import TYPE_CHECKING, Any, Coroutine, List 6 | 7 | from ..events import Event, PostStart, PostStop, PreStart, PreStop 8 | from ..status import Status 9 | from . import event_manager 10 | from .logging import configure as configure_logging 11 | 12 | if TYPE_CHECKING: 13 | from ..servers import AbstractServer 14 | from ..timers import AbstractTimer 15 | 16 | configure_logging() 17 | logger = logging.getLogger("mara.app") 18 | 19 | 20 | class App: 21 | """ 22 | Orchestrate servers and tasks 23 | """ 24 | 25 | loop: asyncio.AbstractEventLoop | None = None 26 | servers: List[AbstractServer] 27 | events: event_manager.EventManager 28 | timers: List[AbstractTimer] 29 | _status: Status = Status.IDLE 30 | 31 | def __init__(self): 32 | self.servers = [] 33 | self.timers = [] 34 | 35 | self.events = event_manager.EventManager(self) 36 | 37 | def add_server(self, server: AbstractServer) -> AbstractServer: 38 | """ 39 | Add a new Server instance to the async loop 40 | 41 | The server will start listening when the app is ``run()``. If it is already 42 | running, it will start listening immediately. 43 | """ 44 | logger.debug(f"Add server {server}") 45 | self.servers.append(server) 46 | 47 | if self.loop: 48 | logger.debug(f"Running server {server}") 49 | self.create_task(server.run(self)) 50 | 51 | return server 52 | 53 | def remove_server(self, server: AbstractServer): 54 | """ 55 | Stop and remove the specified Server instance 56 | """ 57 | if server.status == Status.RUNNING: 58 | logger.debug(f"Stopping server {server}") 59 | server.stop() 60 | 61 | logger.debug(f"Removing server {server}") 62 | self.servers.remove(server) 63 | 64 | def add_timer(self, timer: AbstractTimer) -> AbstractTimer: 65 | """ 66 | Add a new Timer instance to the async loop 67 | 68 | The timer will start when the app is ``run()``. If it is already running, it 69 | will start immediately. 70 | 71 | Returns the timer instance. Because timer instances are callable decorators, you 72 | can use this as a decorator to define a timer and its function in one:: 73 | 74 | @app.add_timer(PeriodicTimer(every=1)) 75 | async def tick(timer): 76 | ... 77 | 78 | which is shorthand for:: 79 | 80 | async def tick(timer): 81 | ... 82 | 83 | timer = PeriodicTimer(every=60) 84 | app.add_timer(timer) 85 | timer(tick) 86 | """ 87 | logger.debug(f"Add timer {timer}") 88 | self.timers.append(timer) 89 | 90 | if self.loop: 91 | logger.debug(f"Timer {timer} starting") 92 | self.create_task(timer.run(self)) 93 | logger.debug(f"Timer {timer} stopped") 94 | 95 | # TODO: extend self.create_task to callback to a fn to clean up self.timers 96 | 97 | return timer 98 | 99 | def run(self, debug=True): 100 | """ 101 | Start the main app async loop 102 | 103 | This will start any Servers which have been added with ``add_server()`` 104 | """ 105 | self._status = Status.STARTING 106 | 107 | # TODO: Should add some more logic around here from asyncio.run 108 | self.loop = loop = asyncio.new_event_loop() 109 | asyncio.set_event_loop(loop) 110 | logger.debug("Loop starting") 111 | loop.run_until_complete(self.events.trigger(PreStart())) 112 | 113 | for server in self.servers: 114 | self.create_task(server.run(self)) 115 | 116 | for timer in self.timers: 117 | self.create_task(timer.run(self)) 118 | 119 | logger.debug("Loop running") 120 | self._status = Status.RUNNING 121 | loop.run_until_complete(self.events.trigger(PostStart())) 122 | 123 | try: 124 | loop.run_forever() 125 | finally: 126 | logger.debug("Loop stopping") 127 | self._status = Status.STOPPING 128 | loop.run_until_complete(self.events.trigger(PreStop())) 129 | loop.run_until_complete(loop.shutdown_asyncgens()) 130 | loop.close() 131 | self._status = Status.STOPPED 132 | self.loop = None 133 | logger.debug("Loop stopped") 134 | 135 | # Loop has terminated 136 | asyncio.run(self.events.trigger(PostStop())) 137 | 138 | def create_task(self, task_fn: Coroutine[Any, Any, Any]): 139 | if self.loop is None: 140 | # TODO: Handle pending tasks here 141 | raise ValueError("Loop is not running") 142 | task = self.loop.create_task(task_fn) 143 | task.add_done_callback(self._handle_task_complete) 144 | return task 145 | 146 | def _handle_task_complete(self, task: asyncio.Task): 147 | try: 148 | task.result() 149 | except asyncio.CancelledError: 150 | pass 151 | except Exception: 152 | logger.exception("Task failed") 153 | 154 | def on( 155 | self, 156 | event_class: type[Event], 157 | handler: event_manager.HandlerType | None = None, 158 | **filters: event_manager.FilterType, 159 | ): 160 | """ 161 | Bind a handler callback to the specified event class, and to its subclasses 162 | 163 | Arguments: 164 | 165 | event_class (Type[Event]): The Event class to listen for 166 | handler (Awaitable | None): The handler, if not being decorated 167 | **filters: Key value pairs to match against inbound events 168 | 169 | Can be called directly:: 170 | 171 | app.on(Event, handler) 172 | 173 | or can be called as a decorator with no handler argument:: 174 | 175 | @app.on(Event) 176 | async def callback(event): 177 | ... 178 | 179 | and can filter based on event attributes 180 | 181 | @app.on(ServerEvent, server=telnet) 182 | async def callback(event): 183 | ... 184 | """ 185 | return self.events.listen(event_class, handler, **filters) 186 | 187 | @property 188 | def status(self): 189 | """ 190 | Get the status of the app and its servers 191 | 192 | Difference between this and self._status is when the app loop is RUNNING, this 193 | will return STARTING until all servers are listening 194 | """ 195 | if not self._status == Status.RUNNING: 196 | return self._status 197 | 198 | if all([server.status == Status.RUNNING for server in self.servers]): 199 | return Status.RUNNING 200 | return Status.STARTING 201 | 202 | def stop(self): 203 | """ 204 | This will ask the main loop to stop, shutting down all servers, connections and 205 | other async tasks. 206 | """ 207 | if self.loop: 208 | logger.debug("Stopping servers") 209 | for server in self.servers: 210 | server.stop() 211 | 212 | logger.debug("Requesting loop stop") 213 | self.loop.stop() 214 | --------------------------------------------------------------------------------