relaybus-core 0.0.2__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,26 @@
1
+ .DS_Store
2
+ *.log
3
+
4
+ # Go
5
+ *.test
6
+ *.out
7
+
8
+ # Node/TypeScript
9
+ node_modules/
10
+ dist/
11
+ *.tsbuildinfo
12
+ coverage/
13
+
14
+ # Python
15
+ __pycache__/
16
+ *.pyc
17
+ *.pyo
18
+ *.pyd
19
+ .venv/
20
+ venv/
21
+ *.egg-info/
22
+ .pytest_cache/
23
+
24
+ # IDE
25
+ .idea/
26
+ .vscode/
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: relaybus-core
3
+ Version: 0.0.2
4
+ Summary: Relaybus core envelope utilities
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+
8
+ # relaybus-core (Python)
9
+
10
+ Core envelope encode/decode utilities for Relaybus.
11
+
12
+ ## Install
13
+
14
+ ```
15
+ pip install relaybus-core
16
+ ```
17
+
18
+ ## Example
19
+
20
+ ```python
21
+ from relaybus_core import OutgoingMessage, decode_envelope, encode_envelope
22
+
23
+ encoded = encode_envelope(
24
+ OutgoingMessage(topic="alpha", payload=b"hello", meta={"source": "py"})
25
+ )
26
+ decoded = decode_envelope(encoded)
27
+
28
+ print(decoded.topic, decoded.payload)
29
+ ```
@@ -0,0 +1,22 @@
1
+ # relaybus-core (Python)
2
+
3
+ Core envelope encode/decode utilities for Relaybus.
4
+
5
+ ## Install
6
+
7
+ ```
8
+ pip install relaybus-core
9
+ ```
10
+
11
+ ## Example
12
+
13
+ ```python
14
+ from relaybus_core import OutgoingMessage, decode_envelope, encode_envelope
15
+
16
+ encoded = encode_envelope(
17
+ OutgoingMessage(topic="alpha", payload=b"hello", meta={"source": "py"})
18
+ )
19
+ decoded = decode_envelope(encoded)
20
+
21
+ print(decoded.topic, decoded.payload)
22
+ ```
@@ -0,0 +1,13 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "relaybus-core"
7
+ version = "0.0.2"
8
+ description = "Relaybus core envelope utilities"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+
12
+ [tool.hatch.build.targets.wheel]
13
+ packages = ["relaybus_core"]
@@ -0,0 +1,3 @@
1
+ from .decoder import Message, OutgoingMessage, decode_envelope, encode_envelope
2
+
3
+ __all__ = ["Message", "OutgoingMessage", "decode_envelope", "encode_envelope"]
@@ -0,0 +1,151 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timezone
7
+ from typing import Any, Callable, Dict, Optional, Union
8
+ import uuid
9
+
10
+
11
+ @dataclass
12
+ class Message:
13
+ v: str
14
+ id: str
15
+ topic: str
16
+ ts: datetime
17
+ content_type: str
18
+ payload: bytes
19
+ meta: Dict[str, str]
20
+
21
+
22
+ @dataclass
23
+ class OutgoingMessage:
24
+ topic: str
25
+ payload: bytes
26
+ id: Optional[str] = None
27
+ ts: Optional[datetime] = None
28
+ content_type: Optional[str] = None
29
+ meta: Optional[Dict[str, str]] = None
30
+
31
+
32
+ def _parse_ts(value: str) -> datetime:
33
+ if value.endswith("Z"):
34
+ value = value[:-1] + "+00:00"
35
+ if "." in value:
36
+ main, rest = value.split(".", 1)
37
+ frac = rest
38
+ tz = ""
39
+ for idx, ch in enumerate(rest):
40
+ if ch in "+-":
41
+ frac = rest[:idx]
42
+ tz = rest[idx:]
43
+ break
44
+ if len(frac) > 6:
45
+ frac = frac[:6]
46
+ if frac:
47
+ value = f"{main}.{frac}{tz}"
48
+ else:
49
+ value = f"{main}{tz}"
50
+ ts = datetime.fromisoformat(value)
51
+ if ts.tzinfo is None:
52
+ ts = ts.replace(tzinfo=timezone.utc)
53
+ return ts
54
+
55
+
56
+ def _require_str(obj: Dict[str, Any], field: str) -> str:
57
+ value = obj.get(field)
58
+ if not isinstance(value, str) or value == "":
59
+ raise ValueError(f"invalid {field}")
60
+ return value
61
+
62
+
63
+ def decode_envelope(data: Union[bytes, str]) -> Message:
64
+ raw = data.decode("utf-8") if isinstance(data, (bytes, bytearray)) else data
65
+ try:
66
+ parsed = json.loads(raw)
67
+ except json.JSONDecodeError as exc:
68
+ raise ValueError("invalid json") from exc
69
+
70
+ if not isinstance(parsed, dict):
71
+ raise ValueError("invalid envelope")
72
+
73
+ if parsed.get("v") != "v1":
74
+ raise ValueError("invalid v")
75
+
76
+ msg_id = _require_str(parsed, "id")
77
+ topic = _require_str(parsed, "topic")
78
+ ts_raw = _require_str(parsed, "ts")
79
+ content_type = _require_str(parsed, "content_type")
80
+
81
+ payload_b64 = parsed.get("payload_b64")
82
+ if not isinstance(payload_b64, str):
83
+ raise ValueError("invalid payload_b64")
84
+ try:
85
+ payload = base64.b64decode(payload_b64, validate=True)
86
+ except Exception as exc:
87
+ raise ValueError("invalid payload_b64") from exc
88
+
89
+ meta = parsed.get("meta")
90
+ if not isinstance(meta, dict):
91
+ raise ValueError("invalid meta")
92
+ for value in meta.values():
93
+ if not isinstance(value, str):
94
+ raise ValueError("invalid meta")
95
+
96
+ ts = _parse_ts(ts_raw)
97
+
98
+ return Message(
99
+ v="v1",
100
+ id=msg_id,
101
+ topic=topic,
102
+ ts=ts,
103
+ content_type=content_type,
104
+ payload=payload,
105
+ meta=meta,
106
+ )
107
+
108
+
109
+ def encode_envelope(
110
+ message: OutgoingMessage,
111
+ *,
112
+ now: Optional[Callable[[], datetime]] = None,
113
+ id_generator: Optional[Callable[[], str]] = None,
114
+ ) -> bytes:
115
+ if message is None:
116
+ raise ValueError("message is required")
117
+ if not message.topic:
118
+ raise ValueError("topic is required")
119
+ if message.payload is None:
120
+ raise ValueError("payload is required")
121
+
122
+ now_func = now or (lambda: datetime.now(timezone.utc))
123
+ id_func = id_generator or (lambda: str(uuid.uuid4()))
124
+
125
+ msg_id = message.id or id_func()
126
+ if not msg_id:
127
+ raise ValueError("id is required")
128
+
129
+ ts = message.ts or now_func()
130
+ if ts.tzinfo is None:
131
+ ts = ts.replace(tzinfo=timezone.utc)
132
+
133
+ content_type = message.content_type or "application/octet-stream"
134
+ meta = message.meta or {}
135
+ if not isinstance(meta, dict):
136
+ raise ValueError("invalid meta")
137
+ for value in meta.values():
138
+ if not isinstance(value, str):
139
+ raise ValueError("invalid meta")
140
+
141
+ payload_b64 = base64.b64encode(message.payload).decode()
142
+ env = {
143
+ "v": "v1",
144
+ "id": msg_id,
145
+ "topic": message.topic,
146
+ "ts": ts.isoformat().replace("+00:00", "Z"),
147
+ "content_type": content_type,
148
+ "payload_b64": payload_b64,
149
+ "meta": meta,
150
+ }
151
+ return json.dumps(env).encode("utf-8")
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from datetime import datetime
10
+
11
+ from relaybus_core import decode_envelope
12
+
13
+ ROOT = Path(__file__).resolve().parents[4]
14
+ SAMPLES = ROOT / "spec" / "corpus" / "samples"
15
+ EXPECTED = ROOT / "spec" / "corpus" / "expected"
16
+
17
+
18
+ def test_decode_corpus_samples():
19
+ for sample_path in SAMPLES.glob("*.json"):
20
+ expected_path = EXPECTED / sample_path.name
21
+ decoded = decode_envelope(sample_path.read_bytes())
22
+ expected = json.loads(expected_path.read_text())
23
+
24
+ assert decoded.v == expected["v"]
25
+ assert decoded.id == expected["id"]
26
+ assert decoded.topic == expected["topic"]
27
+ assert decoded.ts.isoformat().replace("+00:00", "Z") == normalize_ts(
28
+ expected["ts"]
29
+ )
30
+ assert decoded.content_type == expected["content_type"]
31
+ assert decoded.meta == expected["meta"]
32
+ assert base64.b64encode(decoded.payload).decode() == expected["payload_bytes_b64"]
33
+
34
+
35
+ def test_decode_invalid_json():
36
+ with pytest.raises(ValueError, match="invalid json"):
37
+ decode_envelope("{")
38
+
39
+
40
+ def test_decode_missing_fields():
41
+ with pytest.raises(ValueError, match="invalid id"):
42
+ decode_envelope(json.dumps({"v": "v1"}))
43
+
44
+
45
+ def test_decode_invalid_base64():
46
+ sample = {
47
+ "v": "v1",
48
+ "id": "id",
49
+ "topic": "t",
50
+ "ts": "2024-01-01T00:00:00Z",
51
+ "content_type": "text/plain",
52
+ "payload_b64": "???",
53
+ "meta": {},
54
+ }
55
+ with pytest.raises(ValueError, match="invalid payload_b64"):
56
+ decode_envelope(json.dumps(sample))
57
+
58
+
59
+ def normalize_ts(value: str) -> str:
60
+ if value.endswith("Z"):
61
+ value = value[:-1] + "+00:00"
62
+ if "." in value:
63
+ main, rest = value.split(".", 1)
64
+ frac = rest
65
+ tz = ""
66
+ for idx, ch in enumerate(rest):
67
+ if ch in "+-":
68
+ frac = rest[:idx]
69
+ tz = rest[idx:]
70
+ break
71
+ if len(frac) > 6:
72
+ frac = frac[:6]
73
+ if frac:
74
+ value = f"{main}.{frac}{tz}"
75
+ else:
76
+ value = f"{main}{tz}"
77
+ return datetime.fromisoformat(value).isoformat().replace("+00:00", "Z")
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ from datetime import datetime, timezone
6
+
7
+ import pytest
8
+
9
+ from relaybus_core import OutgoingMessage, encode_envelope
10
+
11
+
12
+ def test_encode_defaults_with_injected_clock():
13
+ fixed = datetime(2024, 1, 1, tzinfo=timezone.utc)
14
+ data = encode_envelope(
15
+ OutgoingMessage(topic="alpha", payload=b"hi"),
16
+ now=lambda: fixed,
17
+ id_generator=lambda: "id-123",
18
+ )
19
+ parsed = json.loads(data.decode())
20
+ assert parsed["v"] == "v1"
21
+ assert parsed["id"] == "id-123"
22
+ assert parsed["topic"] == "alpha"
23
+ assert parsed["ts"] == fixed.isoformat().replace("+00:00", "Z")
24
+ assert parsed["content_type"] == "application/octet-stream"
25
+ assert parsed["payload_b64"] == base64.b64encode(b"hi").decode()
26
+ assert parsed["meta"] == {}
27
+
28
+
29
+ def test_encode_requires_topic():
30
+ with pytest.raises(ValueError, match="topic is required"):
31
+ encode_envelope(OutgoingMessage(topic="", payload=b"hi"))
32
+
33
+
34
+ def test_encode_requires_payload():
35
+ with pytest.raises(ValueError, match="payload is required"):
36
+ encode_envelope(OutgoingMessage(topic="alpha", payload=None))
37
+
38
+
39
+ def test_encode_rejects_invalid_meta():
40
+ with pytest.raises(ValueError, match="invalid meta"):
41
+ encode_envelope(
42
+ OutgoingMessage(topic="alpha", payload=b"hi", meta={"k": 123})
43
+ )