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