rutis 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
rutis/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """rutis for Python: write plugins (`define_plugin`, `rutis.testing`), and the
2
+ runtime that runs them (`python -m rutis <channel> <project>`, which a rutis
3
+ host starts).
4
+
5
+ See `rutis.plugin` for how a plugin is written.
6
+ """
7
+
8
+ from .peer import RemoteError, SyncWaitCycle
9
+ from .plugin import PLUGIN_API, Context, Plugin, define_plugin
10
+
11
+ __all__ = ["PLUGIN_API", "Context", "Plugin", "RemoteError", "SyncWaitCycle", "define_plugin"]
rutis/__main__.py ADDED
@@ -0,0 +1,136 @@
1
+ """`python3 -m rutis <channel> [--id <endpoint>] [--peer <endpoint>] <project>`:
2
+ one runtime process.
3
+
4
+ The channel is `fd:<n>`, a socket inherited from the process that started
5
+ this one; `unix:<path>` (or a bare path), a socket to dial; or
6
+ `listen:ws://…` / `listen:wss://…`, a WebSocket address to listen on for the
7
+ controlling rutis (the token it must present in RUTIS_TOKEN, a
8
+ listener certificate and key in RUTIS_CERT and RUTIS_KEY).
9
+ Local channels speak the compat protocol; network channels the endpoint
10
+ format, as `--id`, expecting `--peer` as the controller when given.
11
+ """
12
+
13
+ import asyncio
14
+ import logging
15
+ import os
16
+ import socket
17
+ import sys
18
+
19
+ from .peer import Peer
20
+ from .runner import Runtime
21
+
22
+
23
+ def open_channel(spec: str):
24
+ if spec.startswith("fd:"):
25
+ return socket.socket(fileno=int(spec[len("fd:"):]))
26
+ path = spec[len("unix:"):] if spec.startswith("unix:") else spec
27
+ connection = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
28
+ connection.connect(path)
29
+ return connection
30
+
31
+
32
+ def parse(argv: list[str]) -> tuple[str, dict | None, str]:
33
+ """The channel, the endpoint (network channels), and the project."""
34
+ if not argv:
35
+ raise ValueError("usage: python3 -m rutis <channel> [--id <endpoint>] [--peer <endpoint>] <project>")
36
+ channel, rest, flags = argv[0], list(argv[1:]), {}
37
+ while rest and rest[0].startswith("--"):
38
+ flag = rest.pop(0)[2:]
39
+ if not rest:
40
+ raise ValueError(f"--{flag} needs a value")
41
+ flags[flag] = rest.pop(0)
42
+ if len(rest) != 1:
43
+ raise ValueError("usage: python3 -m rutis <channel> [--id <endpoint>] [--peer <endpoint>] <project>")
44
+ endpoint = None
45
+ if channel.startswith("listen:"):
46
+ if "id" not in flags:
47
+ raise ValueError(f"a network channel needs --id <endpoint>: {channel}")
48
+ # A runner is a runtime: the controller manages its rows.
49
+ endpoint = {"local": flags["id"], "expected": flags.get("peer"), "declare": ["runtime"]}
50
+ return channel, endpoint, rest[0]
51
+
52
+
53
+ class Session:
54
+ """One control session and the lease it holds: the rows, proxies and
55
+ references of one controller."""
56
+
57
+ def __init__(self, channel, endpoint: dict | None):
58
+ self.channel = channel
59
+ self.runtime = Runtime()
60
+ self.peer = Peer(channel, self.runtime.dispatch, settled=None, endpoint=endpoint)
61
+ self.runtime.peer = self.peer
62
+
63
+ def start(self) -> None:
64
+ self.peer.start()
65
+
66
+ async def end(self, replaced: bool = False) -> None:
67
+ """End the lease: no frame of this session is read any more, then
68
+ every row and proxy goes."""
69
+ if replaced and hasattr(self.channel, "replaced"):
70
+ self.channel.replaced()
71
+ self.peer.close(ConnectionError("replaced by a new connection" if replaced else "session ended"))
72
+ self.runtime.closing = True
73
+ await self.runtime.dispose()
74
+
75
+
76
+ async def run(channel: str, endpoint: dict | None, project: str) -> None:
77
+ if project and project not in sys.path:
78
+ sys.path.insert(0, project)
79
+ if channel.startswith("listen:"):
80
+ await serve(channel[len("listen:"):], endpoint)
81
+ return
82
+ session = Session(open_channel(channel), endpoint)
83
+ session.start()
84
+ await session.peer.closed
85
+ await session.end()
86
+
87
+
88
+ async def serve(spec: str, endpoint: dict) -> None:
89
+ """A runtime that stays up and listens: it serves one controller at a
90
+ time, a newer connection taking over, and cleans each lease up when it
91
+ ends. A new session is greeted only once the old lease is gone."""
92
+ from . import websocket
93
+ from .peer import ENDPOINT_PROTOCOL
94
+
95
+ listener = await asyncio.to_thread(
96
+ websocket.listen,
97
+ spec,
98
+ f"rutis.{ENDPOINT_PROTOCOL}",
99
+ os.environ.get("RUTIS_TOKEN"),
100
+ os.environ.get("RUTIS_CERT"),
101
+ os.environ.get("RUTIS_KEY"),
102
+ )
103
+ current: Session | None = None
104
+ accepting = asyncio.ensure_future(asyncio.to_thread(listener.accept))
105
+ while True:
106
+ waiting = {accepting} | ({current.peer.closed} if current else set())
107
+ done, _ = await asyncio.wait(waiting, return_when=asyncio.FIRST_COMPLETED)
108
+ if accepting in done:
109
+ channel = accepting.result()
110
+ accepting = asyncio.ensure_future(asyncio.to_thread(listener.accept))
111
+ if current is not None:
112
+ await current.end(replaced=True)
113
+ current = Session(channel, endpoint)
114
+ current.start()
115
+ elif current is not None and current.peer.closed in done:
116
+ await current.end()
117
+ current = None
118
+
119
+
120
+ def main() -> None:
121
+ try:
122
+ channel, endpoint, project = parse(sys.argv[1:])
123
+ except ValueError as error:
124
+ sys.exit(str(error))
125
+ try:
126
+ asyncio.run(run(channel, endpoint, project))
127
+ finally:
128
+ # Stray plugin threads must not keep the process alive: rutis waits
129
+ # for it to exit.
130
+ sys.stdout.flush()
131
+ sys.stderr.flush()
132
+ logging.shutdown()
133
+ os._exit(0)
134
+
135
+
136
+ main()
rutis/channel.py ADDED
@@ -0,0 +1,45 @@
1
+ """Channels a session runs on: ordered, reliable messages with their
2
+ boundaries kept, independent of the encoding of what they carry.
3
+
4
+ A channel has `send(message: bytes)`, `recv() -> bytes | None` (blocking;
5
+ `None` once the far end finished) and `close(reason)`. `recv` raises
6
+ `ConnectionError` when the channel failed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import socket
12
+ import threading
13
+
14
+
15
+ class SocketChannel:
16
+ """A Unix socket (dialed, or inherited as fd 3), one message per line."""
17
+
18
+ def __init__(self, sock: socket.socket):
19
+ self._sock = sock
20
+ self._reader = sock.makefile("rb")
21
+ self._lock = threading.Lock()
22
+
23
+ def send(self, message: bytes) -> None:
24
+ if b"\n" in message:
25
+ raise ValueError("a message contains a raw newline")
26
+ with self._lock:
27
+ self._sock.sendall(message + b"\n")
28
+
29
+ def recv(self) -> bytes | None:
30
+ line = self._reader.readline()
31
+ if not line:
32
+ return None
33
+ if not line.endswith(b"\n"):
34
+ raise ConnectionError("stream ended inside a message")
35
+ return line[:-1]
36
+
37
+ def close(self, reason: str = "") -> None:
38
+ try:
39
+ self._sock.shutdown(socket.SHUT_RDWR)
40
+ except OSError:
41
+ pass
42
+ try:
43
+ self._sock.close()
44
+ except OSError:
45
+ pass