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.
Files changed (40) hide show
  1. clusdr-0.1.0/PKG-INFO +14 -0
  2. clusdr-0.1.0/README.md +29 -0
  3. clusdr-0.1.0/pyproject.toml +35 -0
  4. clusdr-0.1.0/setup.cfg +4 -0
  5. clusdr-0.1.0/src/clusdr/__init__.py +28 -0
  6. clusdr-0.1.0/src/clusdr/_client.py +315 -0
  7. clusdr-0.1.0/src/clusdr/_coord.py +345 -0
  8. clusdr-0.1.0/src/clusdr/_errors.py +5 -0
  9. clusdr-0.1.0/src/clusdr/_options.py +50 -0
  10. clusdr-0.1.0/src/clusdr/_retry.py +56 -0
  11. clusdr-0.1.0/src/clusdr/_tls.py +115 -0
  12. clusdr-0.1.0/src/clusdr/py.typed +0 -0
  13. clusdr-0.1.0/src/clusdr/v1alpha1/__init__.py +1 -0
  14. clusdr-0.1.0/src/clusdr/v1alpha1/events_pb2.py +41 -0
  15. clusdr-0.1.0/src/clusdr/v1alpha1/events_pb2.pyi +31 -0
  16. clusdr-0.1.0/src/clusdr/v1alpha1/events_pb2_grpc.py +118 -0
  17. clusdr-0.1.0/src/clusdr/v1alpha1/health_pb2.py +41 -0
  18. clusdr-0.1.0/src/clusdr/v1alpha1/health_pb2.pyi +21 -0
  19. clusdr-0.1.0/src/clusdr/v1alpha1/health_pb2_grpc.py +103 -0
  20. clusdr-0.1.0/src/clusdr/v1alpha1/leases_pb2.py +55 -0
  21. clusdr-0.1.0/src/clusdr/v1alpha1/leases_pb2.pyi +97 -0
  22. clusdr-0.1.0/src/clusdr/v1alpha1/leases_pb2_grpc.py +238 -0
  23. clusdr-0.1.0/src/clusdr/v1alpha1/locks_pb2.py +55 -0
  24. clusdr-0.1.0/src/clusdr/v1alpha1/locks_pb2.pyi +97 -0
  25. clusdr-0.1.0/src/clusdr/v1alpha1/locks_pb2_grpc.py +286 -0
  26. clusdr-0.1.0/src/clusdr/v1alpha1/membership_pb2.py +47 -0
  27. clusdr-0.1.0/src/clusdr/v1alpha1/membership_pb2.pyi +43 -0
  28. clusdr-0.1.0/src/clusdr/v1alpha1/membership_pb2_grpc.py +148 -0
  29. clusdr-0.1.0/src/clusdr/v1alpha1/watch_pb2.py +41 -0
  30. clusdr-0.1.0/src/clusdr/v1alpha1/watch_pb2.pyi +31 -0
  31. clusdr-0.1.0/src/clusdr/v1alpha1/watch_pb2_grpc.py +115 -0
  32. clusdr-0.1.0/src/clusdr.egg-info/PKG-INFO +14 -0
  33. clusdr-0.1.0/src/clusdr.egg-info/SOURCES.txt +38 -0
  34. clusdr-0.1.0/src/clusdr.egg-info/dependency_links.txt +1 -0
  35. clusdr-0.1.0/src/clusdr.egg-info/requires.txt +6 -0
  36. clusdr-0.1.0/src/clusdr.egg-info/top_level.txt +1 -0
  37. clusdr-0.1.0/tests/test_cluster.py +127 -0
  38. clusdr-0.1.0/tests/test_coord.py +152 -0
  39. clusdr-0.1.0/tests/test_retry.py +60 -0
  40. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)