relayfabric-sdk 0.4.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.
@@ -0,0 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: relayfabric-sdk
3
+ Version: 0.4.0
4
+ Summary: RelayFabric Python plugin SDK: Plugin Protocol v1 frame codec, main-loop scaffold, bridge plumbing, sent-cache, and test harness.
5
+ License-Expression: Apache-2.0
6
+ Project-URL: Homepage, https://docs.relayfabric.org
7
+ Project-URL: Repository, https://github.com/RelayFabric/RelayFabric
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: cbor2
11
+
12
+ # relayfabric-sdk
13
+
14
+ The Python side of RelayFabric's Plugin Protocol v1: the CBOR frame codec
15
+ (`relayfabric_sdk.ipc`, golden-locked byte-for-byte against the Rust
16
+ implementation), the `run_plugin` main-loop scaffold, shared Bridge plumbing
17
+ (`relayfabric_sdk.bridge`), the sent-message loop-guard cache, and the
18
+ `FakeSock` test harness.
19
+
20
+ A complete plugin is ~30 lines — see `examples/echo_plugin.py`. Prove any
21
+ plugin against the daemon-side contract with:
22
+
23
+ switchyardctl plugin test "python my_plugin.py"
24
+
25
+ Docs: https://docs.relayfabric.org/plugin-authors/
@@ -0,0 +1,14 @@
1
+ # relayfabric-sdk
2
+
3
+ The Python side of RelayFabric's Plugin Protocol v1: the CBOR frame codec
4
+ (`relayfabric_sdk.ipc`, golden-locked byte-for-byte against the Rust
5
+ implementation), the `run_plugin` main-loop scaffold, shared Bridge plumbing
6
+ (`relayfabric_sdk.bridge`), the sent-message loop-guard cache, and the
7
+ `FakeSock` test harness.
8
+
9
+ A complete plugin is ~30 lines — see `examples/echo_plugin.py`. Prove any
10
+ plugin against the daemon-side contract with:
11
+
12
+ switchyardctl plugin test "python my_plugin.py"
13
+
14
+ Docs: https://docs.relayfabric.org/plugin-authors/
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "relayfabric-sdk"
3
+ version = "0.4.0"
4
+ description = "RelayFabric Python plugin SDK: Plugin Protocol v1 frame codec, main-loop scaffold, bridge plumbing, sent-cache, and test harness."
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ requires-python = ">=3.10"
8
+ dependencies = ["cbor2"]
9
+
10
+ [project.urls]
11
+ Homepage = "https://docs.relayfabric.org"
12
+ Repository = "https://github.com/RelayFabric/RelayFabric"
13
+
14
+ # Not pip-installed by anything in-repo: plugins and their tests consume
15
+ # this package via a sys.path insert to ../../sdk/python. `pip install -e
16
+ # sdk/python` works for out-of-repo consumption but is not required.
17
+ [build-system]
18
+ requires = ["setuptools>=61"]
19
+ build-backend = "setuptools.build_meta"
20
+
21
+ [tool.setuptools.packages.find]
22
+ include = ["relayfabric_sdk*"]
@@ -0,0 +1,25 @@
1
+ """RelayFabric Python plugin SDK.
2
+
3
+ Consolidates what the plugin fleet shares: the Plugin Protocol v1 frame
4
+ codec (ipc), the sent-message loop-guard cache (cache), shared Bridge
5
+ plumbing (bridge), the main-loop scaffold (runner), the scripted-socket
6
+ test double (harness), and the NIP-01 event primitives (nip01).
7
+
8
+ Plugins import submodules directly (`from relayfabric_sdk import ipc as
9
+ relay_ipc`). The flat names below resolve lazily (PEP 562), so a bare
10
+ `import relayfabric_sdk` — or importing the stdlib-only `bridge`
11
+ submodule — pulls in no third-party dependency.
12
+ """
13
+
14
+ _FLAT = {"FakeSock": "harness", "SentCache": "cache", "run_plugin": "runner"}
15
+
16
+ __all__ = list(_FLAT)
17
+
18
+
19
+ def __getattr__(name):
20
+ submodule = _FLAT.get(name)
21
+ if submodule is None:
22
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
23
+ from importlib import import_module
24
+
25
+ return getattr(import_module(f".{submodule}", __name__), name)
@@ -0,0 +1,71 @@
1
+ """Shared Bridge plumbing for the plugin fleet.
2
+
3
+ Module top level is stdlib-only (the package __init__ is lazy, so importing
4
+ this from a plugin's module top level pulls no cbor2/paho/etc.); ipc is
5
+ imported inside the functions that write frames, mirroring the plugins'
6
+ own lazy-import convention.
7
+ """
8
+
9
+ import logging
10
+ import threading
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+
15
+ class FrameWriter:
16
+ """Owns the daemon socket file and the write lock every Bridge
17
+ re-declared: all daemon-socket writes go through _send_frame, serialized
18
+ by one lock (handle_event runs on a backend reader thread, handle_send
19
+ on the main thread).
20
+ """
21
+
22
+ def __init__(self, sock_file):
23
+ self.sock_file = sock_file
24
+ self.write_lock = threading.Lock()
25
+
26
+ def _send_frame(self, obj):
27
+ from . import ipc
28
+
29
+ with self.write_lock:
30
+ ipc.write_frame(self.sock_file, obj)
31
+
32
+
33
+ def capped_text_send(bridge, frame, label, sent_what, publish):
34
+ """The endpoint-lookup -> size-cap -> publish -> delivery_result dance
35
+ four plugins carried verbatim (differing only in label and publish call).
36
+
37
+ `bridge` needs: cfg["channels"], cfg["max_text_bytes"], _send_frame,
38
+ sent_cache. `publish(channel_spec, endpoint, body)` raises on failure.
39
+ """
40
+ from . import ipc
41
+
42
+ corr = frame["corr"]
43
+ endpoint = frame["endpoint"]
44
+ body = frame["body"]
45
+ channel_spec = bridge.cfg["channels"].get(endpoint)
46
+ if channel_spec is None:
47
+ log.warning(f"{label} send to unknown endpoint {endpoint!r}")
48
+ bridge._send_frame(ipc.delivery_result(corr, False, "unknown endpoint"))
49
+ return
50
+
51
+ body_bytes = len(body.encode("utf-8"))
52
+ max_bytes = bridge.cfg["max_text_bytes"]
53
+ if body_bytes > max_bytes:
54
+ # defensive: the daemon should have already truncated to the
55
+ # advertised capabilities.max_payload before sending this frame.
56
+ detail = f"body {body_bytes} B exceeds max_text_bytes {max_bytes} B"
57
+ log.warning(f"{label} send to '{endpoint}' dropped: {detail}")
58
+ bridge._send_frame(ipc.delivery_result(corr, False, detail))
59
+ return
60
+
61
+ try:
62
+ publish(channel_spec, endpoint, body)
63
+ except Exception as e: # noqa: BLE001 - report the failure, don't crash
64
+ log.warning(f"{label} send to '{endpoint}' failed: {e}")
65
+ bridge._send_frame(ipc.delivery_result(corr, False, str(e)))
66
+ return
67
+ # delivered = send accepted by the backend (spec Sec70), not an
68
+ # end-to-end delivery acknowledgement.
69
+ bridge.sent_cache.record(endpoint, body)
70
+ bridge._send_frame(ipc.delivery_result(corr, True))
71
+ log.info(f"Sent {sent_what} to '{endpoint}' ({body_bytes} B)")
@@ -0,0 +1,36 @@
1
+ """SentCache: loop guard for echoes of our own bridged posts.
2
+
3
+ Moved from plugins/signal/relayfabric_signal.py into the SDK. Signal uses
4
+ it to catch linked-device sync echoes; meshtastic and meshcore use it
5
+ (with a shorter ttl_secs) to catch radio/firmware echoes of their own
6
+ downlinked messages.
7
+ """
8
+
9
+ import threading
10
+ import time
11
+
12
+
13
+ class SentCache:
14
+ """Loop guard for linked-device sync echoes of our own bridged posts."""
15
+
16
+ def __init__(self, ttl_secs=86400):
17
+ self.ttl = ttl_secs
18
+ self._entries = {}
19
+ self._lock = threading.Lock()
20
+
21
+ def record(self, group_id, text, now=None):
22
+ now = time.time() if now is None else now
23
+ with self._lock:
24
+ self._prune(now)
25
+ self._entries[(group_id, text)] = now
26
+
27
+ def match(self, group_id, text, now=None):
28
+ now = time.time() if now is None else now
29
+ with self._lock:
30
+ self._prune(now)
31
+ return self._entries.pop((group_id, text), None) is not None
32
+
33
+ def _prune(self, now):
34
+ # O(n) prune per call, fine at gateway volumes
35
+ for key in [k for k, t in self._entries.items() if now - t > self.ttl]:
36
+ del self._entries[key]
@@ -0,0 +1,40 @@
1
+ """FakeSock: scripted duplex socket double for plugin frame-IO tests.
2
+
3
+ Superset of the write-capture-only fake each plugin suite used to
4
+ re-implement locally (lxmf/signal/meshtastic/meshcore all had a
5
+ byte-identical copy): construct with no arguments for the common case
6
+ (capture frames a Bridge writes via write_frame(), decode them back with
7
+ frames()); optionally pass queued_frames to script the read side too
8
+ (read_frame() consumes them in order, then hits EOFError once exhausted,
9
+ mirroring a closed daemon connection) for main-loop-style tests.
10
+ """
11
+
12
+ import io
13
+
14
+ from . import ipc
15
+
16
+
17
+ class FakeSock:
18
+ def __init__(self, queued_frames=None):
19
+ self._in = io.BytesIO()
20
+ for obj in queued_frames or []:
21
+ ipc.write_frame(self._in, obj)
22
+ self._in.seek(0)
23
+ self._out = io.BytesIO()
24
+
25
+ def read(self, n):
26
+ return self._in.read(n)
27
+
28
+ def write(self, data):
29
+ self._out.write(data)
30
+
31
+ def flush(self):
32
+ pass
33
+
34
+ def frames(self):
35
+ out, rd = [], io.BytesIO(self._out.getvalue())
36
+ while True:
37
+ try:
38
+ out.append(ipc.read_frame(rd))
39
+ except EOFError:
40
+ return out
@@ -0,0 +1,104 @@
1
+ """RelayFabric Plugin Protocol v1 codec: 4-byte BE length prefix + CBOR.
2
+
3
+ Moved from plugins/lxmf/relay_ipc.py into the SDK so the fleet (lxmf,
4
+ signal, meshtastic, meshcore) shares one copy instead of importing across
5
+ plugin directories.
6
+
7
+ Dict key order matters: frames must be byte-identical to the Rust
8
+ relay-ipc encoding (locked by canonical_hello_frame_bytes_are_stable).
9
+ """
10
+
11
+ from datetime import datetime, timezone
12
+
13
+ import cbor2
14
+
15
+ MAX_FRAME = 16 * 1024 * 1024
16
+ PROTOCOL_VERSION = 1
17
+
18
+
19
+ def write_frame(fileobj, obj):
20
+ body = cbor2.dumps(obj)
21
+ if len(body) > MAX_FRAME:
22
+ raise ValueError(f"frame {len(body)} B exceeds MAX_FRAME")
23
+ fileobj.write(len(body).to_bytes(4, "big") + body)
24
+ fileobj.flush()
25
+
26
+
27
+ def _read_exact(fileobj, n):
28
+ data = b""
29
+ while len(data) < n:
30
+ chunk = fileobj.read(n - len(data))
31
+ if not chunk:
32
+ raise EOFError("daemon connection closed")
33
+ data += chunk
34
+ return data
35
+
36
+
37
+ def read_frame(fileobj):
38
+ length = int.from_bytes(_read_exact(fileobj, 4), "big")
39
+ if length > MAX_FRAME:
40
+ raise ValueError(f"frame {length} B exceeds MAX_FRAME")
41
+ return cbor2.loads(_read_exact(fileobj, length))
42
+
43
+
44
+ def capabilities(**overrides):
45
+ caps = {
46
+ "text": True, "direct_messages": False, "groups": False,
47
+ "attachments": False, "location": False, "reactions": False,
48
+ "receipts": False, "presence": False, "max_payload": None,
49
+ }
50
+ caps.update(overrides)
51
+ return caps
52
+
53
+
54
+ def hello(plugin, version, caps):
55
+ return {"t": "hello", "plugin": plugin, "version": version,
56
+ "protocol_version": PROTOCOL_VERSION, "capabilities": caps}
57
+
58
+
59
+ def attachment(filename, mime, data):
60
+ """Create an attachment dict for inbound frames.
61
+
62
+ Args:
63
+ filename: attachment filename
64
+ mime: MIME type string
65
+ data: bytes payload (encoded as CBOR byte string)
66
+
67
+ Returns:
68
+ dict with filename, mime, data keys
69
+ """
70
+ return {"filename": filename, "mime": mime, "data": data}
71
+
72
+
73
+ def inbound(endpoint, sender, body, created_at_epoch=None, *, attachments=None, priority=None):
74
+ created = None
75
+ if created_at_epoch is not None:
76
+ try:
77
+ created = (datetime.fromtimestamp(created_at_epoch, timezone.utc)
78
+ .isoformat().replace("+00:00", "Z"))
79
+ except (OverflowError, OSError, ValueError):
80
+ # sender-controlled timestamp out of range (e.g. 1e300); bridge
81
+ # the message anyway and let the daemon stamp receive time.
82
+ created = None
83
+ return {"t": "inbound", "endpoint": endpoint, "sender": sender,
84
+ "kind": "text", "body": body, "created_at": created,
85
+ "attachments": attachments or [], "priority": priority}
86
+
87
+
88
+ def delivery_result(corr, delivered, detail=None):
89
+ return {"t": "delivery_result", "corr": corr, "delivered": delivered,
90
+ "detail": detail}
91
+
92
+
93
+ def gauges(values):
94
+ """Create a Gauges frame (design §3, cycle D) from a dict of gauge name
95
+ -> numeric value.
96
+
97
+ Mirrors the Rust side's `PluginToDaemon::Gauges { gauges: BTreeMap<String,
98
+ f64> }`: keys are sorted here too, so a frame built from the same
99
+ name/value set on either side of the wire encodes identically. Values are
100
+ coerced to float (BTreeMap<String, f64> on the Rust side has no integer
101
+ variant). Name sanitization and the 32-gauge cap are enforced daemon-side
102
+ on receipt, not here.
103
+ """
104
+ return {"t": "gauges", "gauges": {k: float(values[k]) for k in sorted(values)}}
@@ -0,0 +1,124 @@
1
+ """NIP-01 event primitives: canonical event id, schnorr sign/verify, and
2
+ identity load/generate.
3
+
4
+ Moved verbatim from plugins/nostr/relayfabric_nostr.py (cycle J): NIP-01
5
+ event id/sig are protocol-frozen (BIP-340 schnorr over the exact canonical
6
+ `[0, pubkey, created_at, kind, tags, content]` serialization), so this is a
7
+ shared primitive like ipc/cache/harness rather than nostr-plugin-specific
8
+ code -- the bitchat plugin imports the same functions so there is one
9
+ tested copy of the crypto, not two copies that can drift.
10
+
11
+ Module top level is stdlib-only (hashlib/json/logging/os); coincurve is
12
+ imported lazily inside verify_event/sign_event/load_or_create_identity (the
13
+ fleet's lazy-import convention) so this module stays importable without
14
+ coincurve installed.
15
+ """
16
+
17
+ import hashlib
18
+ import json
19
+ import logging
20
+ import os
21
+
22
+ log = logging.getLogger(__name__)
23
+
24
+
25
+ def event_id(pubkey_hex, created_at, kind, tags, content):
26
+ """NIP-01 event id: sha256 hex of the canonical serialization
27
+ `[0, pubkey, created_at, kind, tags, content]` -- compact separators,
28
+ UTF-8, no extra whitespace, exactly as NIP-01 specifies. See
29
+ test_relayfabric_nostr.py's EventIdGoldenVectorTests for the locked
30
+ known-answer vector (nsec=1, the secp256k1 generator scalar).
31
+ """
32
+ serialized = json.dumps(
33
+ [0, pubkey_hex, created_at, kind, tags, content],
34
+ separators=(",", ":"), ensure_ascii=False)
35
+ return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
36
+
37
+
38
+ def verify_event(event):
39
+ """True iff event's id matches the recomputed NIP-01 sha256 AND its
40
+ schnorr sig verifies (BIP-340) over that id, under event['pubkey'].
41
+
42
+ MUST NOT raise: a relay sends arbitrary dicts (design Sec80 -- a relay
43
+ is untrusted), so any KeyError/TypeError/ValueError from a malformed or
44
+ adversarial event (missing keys, wrong types, non-hex/wrong-length
45
+ id/pubkey/sig, non-dict input entirely) is caught and treated as an
46
+ invalid event, never propagated.
47
+ """
48
+ try:
49
+ pubkey_hex = event["pubkey"]
50
+ created_at = event["created_at"]
51
+ kind = event["kind"]
52
+ tags = event["tags"]
53
+ content = event["content"]
54
+ claimed_id = event["id"]
55
+ sig_hex = event["sig"]
56
+
57
+ recomputed_id = event_id(pubkey_hex, created_at, kind, tags, content)
58
+ if recomputed_id != claimed_id:
59
+ return False
60
+
61
+ from coincurve import PublicKeyXOnly
62
+
63
+ pub = PublicKeyXOnly(bytes.fromhex(pubkey_hex))
64
+ return pub.verify(bytes.fromhex(sig_hex), bytes.fromhex(recomputed_id))
65
+ except Exception: # noqa: BLE001 - malformed/adversarial input, never propagate
66
+ return False
67
+
68
+
69
+ def sign_event(privkey_hex, created_at, kind, tags, content):
70
+ """Build a full signed NIP-01 event {id, pubkey, created_at, kind, tags,
71
+ content, sig} from a 32-byte hex private key ("nsec hex", design Sec1 --
72
+ the raw hex form, not bech32). Round-trips through verify_event().
73
+ """
74
+ from coincurve import PrivateKey, PublicKeyXOnly
75
+
76
+ priv = PrivateKey(bytes.fromhex(privkey_hex))
77
+ pubkey_hex = PublicKeyXOnly.from_valid_secret(priv.secret).format().hex()
78
+ eid = event_id(pubkey_hex, created_at, kind, tags, content)
79
+ sig_hex = priv.sign_schnorr(bytes.fromhex(eid)).hex()
80
+ return {
81
+ "id": eid,
82
+ "pubkey": pubkey_hex,
83
+ "created_at": created_at,
84
+ "kind": kind,
85
+ "tags": tags,
86
+ "content": content,
87
+ "sig": sig_hex,
88
+ }
89
+
90
+
91
+ def load_or_create_identity(identity_file):
92
+ """Load this plugin's Nostr keypair, generating one on first run.
93
+
94
+ If `identity_file` is set and already holds a key (one line, 32-byte
95
+ hex privkey -- the same "nsec hex" form sign_event takes), load it.
96
+ Otherwise generate a fresh secp256k1 key via coincurve; if
97
+ `identity_file` is set, persist the new key there with mode 0600
98
+ (os.open O_CREAT so the restrictive mode is atomic with creation, no
99
+ window where the key sits world-readable) so restarts reuse the same
100
+ identity. A None `identity_file` means a fresh identity every start
101
+ (no path to persist to) -- config-valid per load_config, but every
102
+ restart then publishes under a new pubkey.
103
+
104
+ Logs the public key (hex; loosely "npub", though this is the raw hex
105
+ form, not bech32) exactly once. Never logs the private key.
106
+
107
+ Returns (privkey_hex, pubkey_hex).
108
+ """
109
+ from coincurve import PrivateKey, PublicKeyXOnly
110
+
111
+ if identity_file and os.path.exists(identity_file):
112
+ with open(identity_file) as f:
113
+ privkey_hex = f.read().strip()
114
+ else:
115
+ privkey_hex = PrivateKey().secret.hex()
116
+ if identity_file:
117
+ fd = os.open(identity_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
118
+ with os.fdopen(fd, "w") as f:
119
+ f.write(privkey_hex + "\n")
120
+
121
+ pubkey_hex = PublicKeyXOnly.from_valid_secret(
122
+ PrivateKey(bytes.fromhex(privkey_hex)).secret).format().hex()
123
+ log.info(f"Nostr identity pubkey (npub, hex): {pubkey_hex}")
124
+ return privkey_hex, pubkey_hex
@@ -0,0 +1,107 @@
1
+ """run_plugin: shared main-loop scaffold for the plugin fleet.
2
+
3
+ Consolidates the env contract -> Hello/HelloAck handshake -> dispatch-table
4
+ read loop that lxmf/signal/meshtastic/meshcore each hand-rolled identically
5
+ in their main() functions. A plugin adopts it by building a `bridge_factory`
6
+ (cfg_dict, sock) -> object with a `handle_send(frame)` method, and calling
7
+ `run_plugin(name, version, bridge_factory, capabilities)`.
8
+
9
+ `connect` is a dependency-injection seam for tests only: it defaults to a
10
+ real AF_UNIX connect + a single duplex `sock.makefile("rwb")` (one object
11
+ used for both read_frame and write_frame, exactly like FakeSock), and every
12
+ real caller leaves it at the default.
13
+ """
14
+
15
+ import json
16
+ import os
17
+ import socket
18
+ import sys
19
+
20
+ from .ipc import hello, read_frame, write_frame
21
+
22
+
23
+ def _connect_socket(sock_path):
24
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
25
+ sock.connect(sock_path)
26
+ return sock.makefile("rwb")
27
+
28
+
29
+ def run_plugin(plugin_name, version, bridge_factory, capabilities, *,
30
+ socket_env="RELAYFABRIC_SOCKET", config_env="RELAYFABRIC_PLUGIN_CONFIG",
31
+ connect=_connect_socket):
32
+ """Run a plugin's main loop until shutdown or an unrecoverable error.
33
+
34
+ - Missing `socket_env` -> stderr line + exit 2.
35
+ - Connects, sends Hello(plugin_name, version, capabilities), reads
36
+ HelloAck; a non-"hello_ack" frame or a truthy "error" -> stderr line +
37
+ exit 1. `capabilities` may be a callable taking the parsed config dict
38
+ and returning the caps dict, for plugins whose advertised caps depend
39
+ on config (e.g. a config-derived max_payload).
40
+ - Calls `bridge_factory(cfg_dict, sock)`; if the returned object has a
41
+ `start()`, calls it before entering the read loop.
42
+ - A ValueError/TypeError from the capabilities callable or
43
+ bridge_factory (the plugins' load_config validation errors) -> clean
44
+ "invalid config" stderr line + exit 1.
45
+ - Read loop: "send" -> bridge.handle_send(frame); "send_direct" ->
46
+ bridge.handle_send_direct(frame) if present, else ignored; "shutdown"
47
+ -> bridge.stop() if present, then exit 0; unknown "t" -> ignored;
48
+ (EOFError, OSError, ValueError) while reading -> stderr line + exit 1.
49
+ """
50
+ try:
51
+ sock_path = os.environ[socket_env]
52
+ except KeyError:
53
+ print(f"{plugin_name}: missing required env var {socket_env}", file=sys.stderr)
54
+ sys.exit(2)
55
+
56
+ raw_cfg = json.loads(os.environ.get(config_env, "{}"))
57
+ # Scrub the resolved config (may carry secrets substituted by the daemon
58
+ # from a ${env:}/${file:} reference) out of our own environment so any
59
+ # child process this plugin spawns (e.g. lxmf's media.py running ffmpeg
60
+ # over attacker-supplied audio) doesn't inherit it.
61
+ os.environ.pop(config_env, None)
62
+ sock = connect(sock_path)
63
+
64
+ try:
65
+ caps = capabilities(raw_cfg) if callable(capabilities) else capabilities
66
+ except (ValueError, TypeError) as e:
67
+ print(f"{plugin_name}: invalid config: {e}", file=sys.stderr)
68
+ sys.exit(1)
69
+
70
+ write_frame(sock, hello(plugin_name, version, caps))
71
+ ack = read_frame(sock)
72
+ if ack.get("t") != "hello_ack" or ack.get("error"):
73
+ print(f"{plugin_name}: hello rejected: {ack.get('error')}", file=sys.stderr)
74
+ sys.exit(1)
75
+
76
+ try:
77
+ bridge = bridge_factory(raw_cfg, sock)
78
+ except (ValueError, TypeError) as e:
79
+ print(f"{plugin_name}: invalid config: {e}", file=sys.stderr)
80
+ sys.exit(1)
81
+ start = getattr(bridge, "start", None)
82
+ if start is not None:
83
+ start()
84
+
85
+ while True:
86
+ try:
87
+ frame = read_frame(sock)
88
+ except (EOFError, OSError, ValueError) as e:
89
+ # ValueError: oversize/corrupt frame (read_frame's own MAX_FRAME
90
+ # check). The stream is desynced at that point, so exit rather
91
+ # than continue -- there is no way to resume mid-frame.
92
+ print(f"{plugin_name}: daemon connection lost, exiting: {e}", file=sys.stderr)
93
+ sys.exit(1)
94
+
95
+ kind = frame.get("t")
96
+ if kind == "send":
97
+ bridge.handle_send(frame)
98
+ elif kind == "send_direct":
99
+ handle_send_direct = getattr(bridge, "handle_send_direct", None)
100
+ if handle_send_direct is not None:
101
+ handle_send_direct(frame)
102
+ elif kind == "shutdown":
103
+ stop = getattr(bridge, "stop", None)
104
+ if stop is not None:
105
+ stop()
106
+ sys.exit(0)
107
+ # unknown t: ignore, keep looping
@@ -0,0 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: relayfabric-sdk
3
+ Version: 0.4.0
4
+ Summary: RelayFabric Python plugin SDK: Plugin Protocol v1 frame codec, main-loop scaffold, bridge plumbing, sent-cache, and test harness.
5
+ License-Expression: Apache-2.0
6
+ Project-URL: Homepage, https://docs.relayfabric.org
7
+ Project-URL: Repository, https://github.com/RelayFabric/RelayFabric
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: cbor2
11
+
12
+ # relayfabric-sdk
13
+
14
+ The Python side of RelayFabric's Plugin Protocol v1: the CBOR frame codec
15
+ (`relayfabric_sdk.ipc`, golden-locked byte-for-byte against the Rust
16
+ implementation), the `run_plugin` main-loop scaffold, shared Bridge plumbing
17
+ (`relayfabric_sdk.bridge`), the sent-message loop-guard cache, and the
18
+ `FakeSock` test harness.
19
+
20
+ A complete plugin is ~30 lines — see `examples/echo_plugin.py`. Prove any
21
+ plugin against the daemon-side contract with:
22
+
23
+ switchyardctl plugin test "python my_plugin.py"
24
+
25
+ Docs: https://docs.relayfabric.org/plugin-authors/
@@ -0,0 +1,19 @@
1
+ README.md
2
+ pyproject.toml
3
+ relayfabric_sdk/__init__.py
4
+ relayfabric_sdk/bridge.py
5
+ relayfabric_sdk/cache.py
6
+ relayfabric_sdk/harness.py
7
+ relayfabric_sdk/ipc.py
8
+ relayfabric_sdk/nip01.py
9
+ relayfabric_sdk/runner.py
10
+ relayfabric_sdk.egg-info/PKG-INFO
11
+ relayfabric_sdk.egg-info/SOURCES.txt
12
+ relayfabric_sdk.egg-info/dependency_links.txt
13
+ relayfabric_sdk.egg-info/requires.txt
14
+ relayfabric_sdk.egg-info/top_level.txt
15
+ tests/test_bridge.py
16
+ tests/test_cache.py
17
+ tests/test_ipc.py
18
+ tests/test_nip01.py
19
+ tests/test_runner.py
@@ -0,0 +1 @@
1
+ relayfabric_sdk
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+