rutis 0.7.0__tar.gz
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-0.7.0/PKG-INFO +48 -0
- rutis-0.7.0/README.md +35 -0
- rutis-0.7.0/pyproject.toml +27 -0
- rutis-0.7.0/rutis/__init__.py +11 -0
- rutis-0.7.0/rutis/__main__.py +136 -0
- rutis-0.7.0/rutis/channel.py +45 -0
- rutis-0.7.0/rutis/peer.py +949 -0
- rutis-0.7.0/rutis/plugin.py +115 -0
- rutis-0.7.0/rutis/py.typed +0 -0
- rutis-0.7.0/rutis/runner.py +355 -0
- rutis-0.7.0/rutis/testing.py +213 -0
- rutis-0.7.0/rutis/websocket.py +209 -0
- rutis-0.7.0/rutis.egg-info/PKG-INFO +48 -0
- rutis-0.7.0/rutis.egg-info/SOURCES.txt +21 -0
- rutis-0.7.0/rutis.egg-info/dependency_links.txt +1 -0
- rutis-0.7.0/rutis.egg-info/requires.txt +3 -0
- rutis-0.7.0/rutis.egg-info/top_level.txt +1 -0
- rutis-0.7.0/setup.cfg +4 -0
- rutis-0.7.0/tests/test_entry_points.py +47 -0
- rutis-0.7.0/tests/test_peer.py +92 -0
- rutis-0.7.0/tests/test_plugin.py +72 -0
- rutis-0.7.0/tests/test_testing.py +126 -0
- rutis-0.7.0/tests/test_websocket.py +129 -0
rutis-0.7.0/PKG-INFO
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rutis
|
|
3
|
+
Version: 0.7.0
|
|
4
|
+
Summary: Write rutis plugins in Python, and the runtime that runs them
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/arcships/rutis
|
|
7
|
+
Project-URL: Documentation, https://github.com/arcships/rutis/tree/main/docs/guide
|
|
8
|
+
Keywords: rutis,plugin,rutis-plugin
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Provides-Extra: network
|
|
12
|
+
Requires-Dist: websockets>=13; extra == "network"
|
|
13
|
+
|
|
14
|
+
# rutis
|
|
15
|
+
|
|
16
|
+
Write [rutis](https://github.com/arcships/rutis) plugins in Python, and the runtime that runs them.
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from rutis import define_plugin
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Weather:
|
|
23
|
+
def __init__(self, llm, city):
|
|
24
|
+
self.llm, self.city = llm, city
|
|
25
|
+
|
|
26
|
+
async def today(self):
|
|
27
|
+
return f"{await self.llm.ask(self.city)} in {self.city}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def apply(ctx, config):
|
|
31
|
+
ctx.provide("weather", Weather(ctx.use("llm"), config["city"]))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
plugin = define_plugin(apply, inject=["llm"], provides={"weather": Weather})
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Test it without a host:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from rutis.testing import load
|
|
41
|
+
|
|
42
|
+
async with load(plugin, config={"city": "Oslo"}, services={"llm": FakeLlm()}) as t:
|
|
43
|
+
assert await t.service("weather").today() == "sunny in Oslo"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
A host runs plugins with `python -m rutis <channel> <project>` (it starts that itself); `python -m rutis listen:wss://… --id <id> --peer <controller> <project>` runs a runtime a host on another machine controls (install `rutis[network]`). Packaged plugins register under the entry point group `rutis.plugins`. Python 3.12 or later; no dependencies.
|
|
47
|
+
|
|
48
|
+
Start a project with `uvx rutis-host new <name> --lang python`. Guide (Chinese): [docs/guide/python-plugin.md](https://github.com/arcships/rutis/blob/main/docs/guide/python-plugin.md).
|
rutis-0.7.0/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# rutis
|
|
2
|
+
|
|
3
|
+
Write [rutis](https://github.com/arcships/rutis) plugins in Python, and the runtime that runs them.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from rutis import define_plugin
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Weather:
|
|
10
|
+
def __init__(self, llm, city):
|
|
11
|
+
self.llm, self.city = llm, city
|
|
12
|
+
|
|
13
|
+
async def today(self):
|
|
14
|
+
return f"{await self.llm.ask(self.city)} in {self.city}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def apply(ctx, config):
|
|
18
|
+
ctx.provide("weather", Weather(ctx.use("llm"), config["city"]))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
plugin = define_plugin(apply, inject=["llm"], provides={"weather": Weather})
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Test it without a host:
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from rutis.testing import load
|
|
28
|
+
|
|
29
|
+
async with load(plugin, config={"city": "Oslo"}, services={"llm": FakeLlm()}) as t:
|
|
30
|
+
assert await t.service("weather").today() == "sunny in Oslo"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
A host runs plugins with `python -m rutis <channel> <project>` (it starts that itself); `python -m rutis listen:wss://… --id <id> --peer <controller> <project>` runs a runtime a host on another machine controls (install `rutis[network]`). Packaged plugins register under the entry point group `rutis.plugins`. Python 3.12 or later; no dependencies.
|
|
34
|
+
|
|
35
|
+
Start a project with `uvx rutis-host new <name> --lang python`. Guide (Chinese): [docs/guide/python-plugin.md](https://github.com/arcships/rutis/blob/main/docs/guide/python-plugin.md).
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "rutis"
|
|
3
|
+
version = "0.7.0"
|
|
4
|
+
description = "Write rutis plugins in Python, and the runtime that runs them"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
|
+
dependencies = []
|
|
9
|
+
keywords = ["rutis", "plugin", "rutis-plugin"]
|
|
10
|
+
|
|
11
|
+
[project.urls]
|
|
12
|
+
Homepage = "https://github.com/arcships/rutis"
|
|
13
|
+
Documentation = "https://github.com/arcships/rutis/tree/main/docs/guide"
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
# WebSocket channels: a runtime other machines reach (listen:ws://…, listen:wss://…).
|
|
17
|
+
network = ["websockets>=13"]
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["setuptools>=68"]
|
|
21
|
+
build-backend = "setuptools.build_meta"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools]
|
|
24
|
+
packages = ["rutis"]
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.package-data]
|
|
27
|
+
rutis = ["py.typed"]
|
|
@@ -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"]
|
|
@@ -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()
|
|
@@ -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
|