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.
- relaybus_core-0.0.2/.gitignore +26 -0
- relaybus_core-0.0.2/PKG-INFO +29 -0
- relaybus_core-0.0.2/README.md +22 -0
- relaybus_core-0.0.2/pyproject.toml +13 -0
- relaybus_core-0.0.2/relaybus_core/__init__.py +3 -0
- relaybus_core-0.0.2/relaybus_core/decoder.py +151 -0
- relaybus_core-0.0.2/tests/test_decode.py +77 -0
- relaybus_core-0.0.2/tests/test_encode.py +43 -0
|
@@ -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,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
|
+
)
|