clusdr 0.1.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.
- clusdr-0.1.0/PKG-INFO +14 -0
- clusdr-0.1.0/README.md +29 -0
- clusdr-0.1.0/pyproject.toml +35 -0
- clusdr-0.1.0/setup.cfg +4 -0
- clusdr-0.1.0/src/clusdr/__init__.py +28 -0
- clusdr-0.1.0/src/clusdr/_client.py +315 -0
- clusdr-0.1.0/src/clusdr/_coord.py +345 -0
- clusdr-0.1.0/src/clusdr/_errors.py +5 -0
- clusdr-0.1.0/src/clusdr/_options.py +50 -0
- clusdr-0.1.0/src/clusdr/_retry.py +56 -0
- clusdr-0.1.0/src/clusdr/_tls.py +115 -0
- clusdr-0.1.0/src/clusdr/py.typed +0 -0
- clusdr-0.1.0/src/clusdr/v1alpha1/__init__.py +1 -0
- clusdr-0.1.0/src/clusdr/v1alpha1/events_pb2.py +41 -0
- clusdr-0.1.0/src/clusdr/v1alpha1/events_pb2.pyi +31 -0
- clusdr-0.1.0/src/clusdr/v1alpha1/events_pb2_grpc.py +118 -0
- clusdr-0.1.0/src/clusdr/v1alpha1/health_pb2.py +41 -0
- clusdr-0.1.0/src/clusdr/v1alpha1/health_pb2.pyi +21 -0
- clusdr-0.1.0/src/clusdr/v1alpha1/health_pb2_grpc.py +103 -0
- clusdr-0.1.0/src/clusdr/v1alpha1/leases_pb2.py +55 -0
- clusdr-0.1.0/src/clusdr/v1alpha1/leases_pb2.pyi +97 -0
- clusdr-0.1.0/src/clusdr/v1alpha1/leases_pb2_grpc.py +238 -0
- clusdr-0.1.0/src/clusdr/v1alpha1/locks_pb2.py +55 -0
- clusdr-0.1.0/src/clusdr/v1alpha1/locks_pb2.pyi +97 -0
- clusdr-0.1.0/src/clusdr/v1alpha1/locks_pb2_grpc.py +286 -0
- clusdr-0.1.0/src/clusdr/v1alpha1/membership_pb2.py +47 -0
- clusdr-0.1.0/src/clusdr/v1alpha1/membership_pb2.pyi +43 -0
- clusdr-0.1.0/src/clusdr/v1alpha1/membership_pb2_grpc.py +148 -0
- clusdr-0.1.0/src/clusdr/v1alpha1/watch_pb2.py +41 -0
- clusdr-0.1.0/src/clusdr/v1alpha1/watch_pb2.pyi +31 -0
- clusdr-0.1.0/src/clusdr/v1alpha1/watch_pb2_grpc.py +115 -0
- clusdr-0.1.0/src/clusdr.egg-info/PKG-INFO +14 -0
- clusdr-0.1.0/src/clusdr.egg-info/SOURCES.txt +38 -0
- clusdr-0.1.0/src/clusdr.egg-info/dependency_links.txt +1 -0
- clusdr-0.1.0/src/clusdr.egg-info/requires.txt +6 -0
- clusdr-0.1.0/src/clusdr.egg-info/top_level.txt +1 -0
- clusdr-0.1.0/tests/test_cluster.py +127 -0
- clusdr-0.1.0/tests/test_coord.py +152 -0
- clusdr-0.1.0/tests/test_retry.py +60 -0
- clusdr-0.1.0/tests/test_tls.py +32 -0
clusdr-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clusdr
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Application SDK for the local Clusdr daemon
|
|
5
|
+
Author: Clusdr
|
|
6
|
+
Project-URL: Homepage, https://clusdr.io
|
|
7
|
+
Project-URL: Documentation, https://clusdr.io/docs/sdk/python
|
|
8
|
+
Project-URL: Source, https://github.com/odurgut/clusdr-python
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: grpcio>=1.83.1
|
|
11
|
+
Requires-Dist: protobuf>=7.35.0
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
14
|
+
Requires-Dist: grpcio-tools>=1.83.0; extra == "dev"
|
clusdr-0.1.0/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# clusdr (Python)
|
|
2
|
+
|
|
3
|
+
Application SDK for the **local** Clusdr daemon. This package does not join the cluster.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from clusdr import local
|
|
7
|
+
|
|
8
|
+
c = local()
|
|
9
|
+
members = c.members()
|
|
10
|
+
c.publish("deployment", {"sha": "abc"})
|
|
11
|
+
lk = c.lock("scheduler", ttl=15)
|
|
12
|
+
c.unlock("scheduler")
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Requires a running daemon.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install clusdr
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Same version train as the daemon (first release: `0.1.0`). Python 3.10+. Product docs: [Python SDK](https://clusdr.io/docs/sdk/python).
|
|
22
|
+
|
|
23
|
+
Contributor checkout (editable + proto regen):
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install -e ".[dev]"
|
|
27
|
+
make proto # from proto next to this tree
|
|
28
|
+
pytest
|
|
29
|
+
```
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "clusdr"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Application SDK for the local Clusdr daemon"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
authors = [{ name = "Clusdr" }]
|
|
11
|
+
dependencies = [
|
|
12
|
+
"grpcio>=1.83.1",
|
|
13
|
+
"protobuf>=7.35.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = [
|
|
18
|
+
"pytest>=8.0.0",
|
|
19
|
+
"grpcio-tools>=1.83.0",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://clusdr.io"
|
|
24
|
+
Documentation = "https://clusdr.io/docs/sdk/python"
|
|
25
|
+
Source = "https://github.com/odurgut/clusdr-python"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.packages.find]
|
|
28
|
+
where = ["src"]
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.package-data]
|
|
31
|
+
clusdr = ["py.typed", "v1alpha1/*.pyi"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
35
|
+
pythonpath = ["src", "."]
|
clusdr-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Application SDK for the local Clusdr daemon.
|
|
2
|
+
|
|
3
|
+
Applications talk only to the daemon on the same host (Docker-style).
|
|
4
|
+
The daemon is the cluster member; this package does not dial other nodes.
|
|
5
|
+
|
|
6
|
+
from clusdr import local
|
|
7
|
+
|
|
8
|
+
cluster = local()
|
|
9
|
+
members = cluster.members()
|
|
10
|
+
cluster.publish("deployment", {"sha": "abc"})
|
|
11
|
+
for event in cluster.watch():
|
|
12
|
+
...
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from clusdr._client import Cluster, Event, Member, dial, local
|
|
16
|
+
from clusdr._coord import Lease, Lock
|
|
17
|
+
from clusdr._errors import ClusdrError
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Cluster",
|
|
21
|
+
"ClusdrError",
|
|
22
|
+
"Event",
|
|
23
|
+
"Lease",
|
|
24
|
+
"Lock",
|
|
25
|
+
"Member",
|
|
26
|
+
"dial",
|
|
27
|
+
"local",
|
|
28
|
+
]
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""gRPC client for the local daemon."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import grpc
|
|
15
|
+
|
|
16
|
+
from clusdr._coord import CoordMixin, Lease, Lock
|
|
17
|
+
from clusdr._errors import ClusdrError
|
|
18
|
+
from clusdr._options import (
|
|
19
|
+
DEFAULT_READY_TIMEOUT,
|
|
20
|
+
DEFAULT_REQUEST_TIMEOUT,
|
|
21
|
+
MAX_PAYLOAD,
|
|
22
|
+
Options,
|
|
23
|
+
env_addr,
|
|
24
|
+
env_insecure,
|
|
25
|
+
)
|
|
26
|
+
from clusdr._retry import retry, transient
|
|
27
|
+
from clusdr._tls import open_channel
|
|
28
|
+
from clusdr.v1alpha1 import (
|
|
29
|
+
events_pb2,
|
|
30
|
+
events_pb2_grpc,
|
|
31
|
+
health_pb2,
|
|
32
|
+
health_pb2_grpc,
|
|
33
|
+
leases_pb2_grpc,
|
|
34
|
+
locks_pb2_grpc,
|
|
35
|
+
membership_pb2,
|
|
36
|
+
membership_pb2_grpc,
|
|
37
|
+
watch_pb2,
|
|
38
|
+
watch_pb2_grpc,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Member:
|
|
44
|
+
"""A cluster node as seen by the local daemon."""
|
|
45
|
+
|
|
46
|
+
id: str
|
|
47
|
+
address: str
|
|
48
|
+
status: str
|
|
49
|
+
leader: bool
|
|
50
|
+
role: str = "voter"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Event:
|
|
55
|
+
"""A cluster or custom event from the Watch stream."""
|
|
56
|
+
|
|
57
|
+
type: str
|
|
58
|
+
source: str
|
|
59
|
+
payload: bytes
|
|
60
|
+
timestamp: datetime
|
|
61
|
+
seq: int
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Cluster(CoordMixin):
|
|
65
|
+
"""Application view of the local Clusdr daemon."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, channel: grpc.Channel, opts: Options) -> None:
|
|
68
|
+
self._channel = channel
|
|
69
|
+
self._opts = opts
|
|
70
|
+
self._closed = threading.Event()
|
|
71
|
+
self._watch_lock = threading.Lock()
|
|
72
|
+
self._watch_call: Any = None
|
|
73
|
+
self._mem = membership_pb2_grpc.MembershipServiceStub(channel)
|
|
74
|
+
self._watch = watch_pb2_grpc.WatchServiceStub(channel)
|
|
75
|
+
self._ev = events_pb2_grpc.EventServiceStub(channel)
|
|
76
|
+
self._health = health_pb2_grpc.HealthServiceStub(channel)
|
|
77
|
+
self._lock = locks_pb2_grpc.LockServiceStub(channel)
|
|
78
|
+
self._lease = leases_pb2_grpc.LeaseServiceStub(channel)
|
|
79
|
+
self._holder = opts.holder or f"sdk-{uuid.uuid4()}"
|
|
80
|
+
self._coord = threading.Lock()
|
|
81
|
+
self._held: dict[str, Lock] = {}
|
|
82
|
+
self._leased: dict[str, Lease] = {}
|
|
83
|
+
|
|
84
|
+
def members(self, timeout: float | None = None) -> list[Member]:
|
|
85
|
+
deadline = self._deadline(timeout)
|
|
86
|
+
|
|
87
|
+
def call() -> membership_pb2.ListMembersResponse:
|
|
88
|
+
return self._mem.ListMembers(
|
|
89
|
+
membership_pb2.ListMembersRequest(),
|
|
90
|
+
timeout=self._remaining(deadline),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
resp = retry(call, deadline=deadline)
|
|
95
|
+
except (grpc.RpcError, TimeoutError) as exc:
|
|
96
|
+
raise ClusdrError(f"clusdr: members: {exc}") from exc
|
|
97
|
+
return [
|
|
98
|
+
Member(
|
|
99
|
+
id=m.id,
|
|
100
|
+
address=m.address,
|
|
101
|
+
status=m.status,
|
|
102
|
+
leader=m.leader,
|
|
103
|
+
role=m.role or "voter",
|
|
104
|
+
)
|
|
105
|
+
for m in resp.members
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
def leader(self, timeout: float | None = None) -> Member:
|
|
109
|
+
deadline = self._deadline(timeout)
|
|
110
|
+
|
|
111
|
+
def call() -> membership_pb2.GetLeaderResponse:
|
|
112
|
+
return self._mem.GetLeader(
|
|
113
|
+
membership_pb2.GetLeaderRequest(),
|
|
114
|
+
timeout=self._remaining(deadline),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
resp = retry(call, deadline=deadline)
|
|
119
|
+
except (grpc.RpcError, TimeoutError) as exc:
|
|
120
|
+
raise ClusdrError(f"clusdr: leader: {exc}") from exc
|
|
121
|
+
return Member(
|
|
122
|
+
id=resp.leader_id,
|
|
123
|
+
address=resp.address,
|
|
124
|
+
status="alive",
|
|
125
|
+
leader=True,
|
|
126
|
+
role="voter",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def publish(
|
|
130
|
+
self,
|
|
131
|
+
topic: str,
|
|
132
|
+
payload: bytes | str | dict[str, Any] | list[Any] | None = None,
|
|
133
|
+
timeout: float | None = None,
|
|
134
|
+
) -> None:
|
|
135
|
+
body = encode_payload(payload)
|
|
136
|
+
if len(body) > MAX_PAYLOAD:
|
|
137
|
+
raise ClusdrError(f"clusdr: publish payload exceeds {MAX_PAYLOAD} bytes")
|
|
138
|
+
deadline = self._deadline(timeout)
|
|
139
|
+
|
|
140
|
+
def call() -> events_pb2.PublishEventResponse:
|
|
141
|
+
return self._ev.PublishEvent(
|
|
142
|
+
events_pb2.PublishEventRequest(topic=topic, payload=body),
|
|
143
|
+
timeout=self._remaining(deadline),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
resp = retry(call, deadline=deadline)
|
|
148
|
+
except (grpc.RpcError, TimeoutError) as exc:
|
|
149
|
+
raise ClusdrError(f"clusdr: publish: {exc}") from exc
|
|
150
|
+
if resp is not None and not resp.accepted:
|
|
151
|
+
raise ClusdrError(f"clusdr: publish rejected: {resp.message}")
|
|
152
|
+
|
|
153
|
+
def watch(self) -> Iterator[Event]:
|
|
154
|
+
last_seq = 0
|
|
155
|
+
backoff = 0.05
|
|
156
|
+
while not self._closed.is_set():
|
|
157
|
+
try:
|
|
158
|
+
call = self._watch.Watch(watch_pb2.WatchRequest(last_seq=last_seq))
|
|
159
|
+
except grpc.RpcError as exc:
|
|
160
|
+
if self._closed.is_set() or exc.code() == grpc.StatusCode.CANCELLED:
|
|
161
|
+
return
|
|
162
|
+
if not self._sleep(backoff):
|
|
163
|
+
return
|
|
164
|
+
backoff = min(backoff * 2, 2.0)
|
|
165
|
+
continue
|
|
166
|
+
with self._watch_lock:
|
|
167
|
+
self._watch_call = call
|
|
168
|
+
backoff = 0.05
|
|
169
|
+
try:
|
|
170
|
+
for resp in call:
|
|
171
|
+
if self._closed.is_set():
|
|
172
|
+
return
|
|
173
|
+
if resp.seq > last_seq:
|
|
174
|
+
last_seq = resp.seq
|
|
175
|
+
yield Event(
|
|
176
|
+
type=resp.type,
|
|
177
|
+
source=resp.source,
|
|
178
|
+
payload=bytes(resp.payload),
|
|
179
|
+
timestamp=_ts(resp.timestamp_unix_ms),
|
|
180
|
+
seq=resp.seq,
|
|
181
|
+
)
|
|
182
|
+
except grpc.RpcError as exc:
|
|
183
|
+
if self._closed.is_set() or exc.code() == grpc.StatusCode.CANCELLED:
|
|
184
|
+
return
|
|
185
|
+
finally:
|
|
186
|
+
with self._watch_lock:
|
|
187
|
+
if self._watch_call is call:
|
|
188
|
+
self._watch_call = None
|
|
189
|
+
if not self._sleep(backoff):
|
|
190
|
+
return
|
|
191
|
+
backoff = min(backoff * 2, 2.0)
|
|
192
|
+
|
|
193
|
+
def close(self) -> None:
|
|
194
|
+
self._closed.set()
|
|
195
|
+
self._release_grants()
|
|
196
|
+
with self._watch_lock:
|
|
197
|
+
if self._watch_call is not None:
|
|
198
|
+
self._watch_call.cancel()
|
|
199
|
+
self._watch_call = None
|
|
200
|
+
self._channel.close()
|
|
201
|
+
|
|
202
|
+
def __enter__(self) -> Cluster:
|
|
203
|
+
return self
|
|
204
|
+
|
|
205
|
+
def __exit__(self, *exc: object) -> None:
|
|
206
|
+
self.close()
|
|
207
|
+
|
|
208
|
+
def _deadline(self, timeout: float | None) -> float:
|
|
209
|
+
if timeout is None:
|
|
210
|
+
timeout = self._opts.request_timeout
|
|
211
|
+
return time.monotonic() + timeout
|
|
212
|
+
|
|
213
|
+
def _remaining(self, deadline: float) -> float:
|
|
214
|
+
left = deadline - time.monotonic()
|
|
215
|
+
if left <= 0:
|
|
216
|
+
raise TimeoutError("clusdr: request timeout")
|
|
217
|
+
return left
|
|
218
|
+
|
|
219
|
+
def _sleep(self, seconds: float) -> bool:
|
|
220
|
+
return not self._closed.wait(seconds)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def local(
|
|
224
|
+
*,
|
|
225
|
+
insecure: bool = False,
|
|
226
|
+
data_dir: str = "",
|
|
227
|
+
holder: str = "",
|
|
228
|
+
request_timeout: float = DEFAULT_REQUEST_TIMEOUT,
|
|
229
|
+
ready_timeout: float = DEFAULT_READY_TIMEOUT,
|
|
230
|
+
server_name: str = "",
|
|
231
|
+
) -> Cluster:
|
|
232
|
+
"""Connect to the daemon on this host (CLUSDR_GRPC_ADDR or 127.0.0.1:7947)."""
|
|
233
|
+
return connect(
|
|
234
|
+
Options(
|
|
235
|
+
addr=env_addr(),
|
|
236
|
+
insecure=insecure,
|
|
237
|
+
data_dir=data_dir,
|
|
238
|
+
holder=holder.strip(),
|
|
239
|
+
request_timeout=request_timeout,
|
|
240
|
+
ready_timeout=ready_timeout,
|
|
241
|
+
server_name=server_name,
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def dial(
|
|
247
|
+
addr: str,
|
|
248
|
+
*,
|
|
249
|
+
insecure: bool = False,
|
|
250
|
+
data_dir: str = "",
|
|
251
|
+
holder: str = "",
|
|
252
|
+
request_timeout: float = DEFAULT_REQUEST_TIMEOUT,
|
|
253
|
+
ready_timeout: float = DEFAULT_READY_TIMEOUT,
|
|
254
|
+
server_name: str = "",
|
|
255
|
+
) -> Cluster:
|
|
256
|
+
"""Connect to addr. Tests and operators use this; applications use local()."""
|
|
257
|
+
if not addr.strip():
|
|
258
|
+
raise ClusdrError("clusdr: empty dial address")
|
|
259
|
+
return connect(
|
|
260
|
+
Options(
|
|
261
|
+
addr=addr.strip(),
|
|
262
|
+
insecure=insecure,
|
|
263
|
+
data_dir=data_dir,
|
|
264
|
+
holder=holder.strip(),
|
|
265
|
+
request_timeout=request_timeout,
|
|
266
|
+
ready_timeout=ready_timeout,
|
|
267
|
+
server_name=server_name,
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def connect(opts: Options) -> Cluster:
|
|
273
|
+
if not opts.insecure and env_insecure() and not opts.data_dir:
|
|
274
|
+
opts.insecure = True
|
|
275
|
+
if not opts.addr:
|
|
276
|
+
raise ClusdrError("clusdr: empty dial address")
|
|
277
|
+
channel = open_channel(opts)
|
|
278
|
+
cluster = Cluster(channel, opts)
|
|
279
|
+
if opts.ready_timeout > 0:
|
|
280
|
+
deadline = time.monotonic() + opts.ready_timeout
|
|
281
|
+
try:
|
|
282
|
+
retry(
|
|
283
|
+
lambda: cluster._health.Health(
|
|
284
|
+
health_pb2.HealthRequest(),
|
|
285
|
+
timeout=min(0.5, max(0.05, deadline - time.monotonic())),
|
|
286
|
+
),
|
|
287
|
+
deadline=deadline,
|
|
288
|
+
is_transient=_ready_transient,
|
|
289
|
+
)
|
|
290
|
+
except (grpc.RpcError, TimeoutError) as exc:
|
|
291
|
+
cluster.close()
|
|
292
|
+
raise ClusdrError(f"clusdr: daemon not ready at {opts.addr}: {exc}") from exc
|
|
293
|
+
return cluster
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _ready_transient(exc: BaseException) -> bool:
|
|
297
|
+
if transient(exc):
|
|
298
|
+
return True
|
|
299
|
+
return isinstance(exc, grpc.RpcError) and exc.code() == grpc.StatusCode.DEADLINE_EXCEEDED
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def encode_payload(payload: bytes | str | dict[str, Any] | list[Any] | None) -> bytes:
|
|
303
|
+
if payload is None:
|
|
304
|
+
return b""
|
|
305
|
+
if isinstance(payload, bytes):
|
|
306
|
+
return payload
|
|
307
|
+
if isinstance(payload, str):
|
|
308
|
+
return payload.encode("utf-8")
|
|
309
|
+
if isinstance(payload, (dict, list)):
|
|
310
|
+
return json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
311
|
+
raise TypeError(f"clusdr: payload must be bytes, str, dict, or list; got {type(payload).__name__}")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _ts(unix_ms: int) -> datetime:
|
|
315
|
+
return datetime.fromtimestamp(unix_ms / 1000.0, tz=timezone.utc)
|