plasmite 1.3.0__py3-none-win_amd64.whl

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.
plasmite/__init__.py ADDED
@@ -0,0 +1,1454 @@
1
+ """
2
+ Purpose: Provide Python bindings for the libplasmite C ABI (v0).
3
+ Key Exports: Client, Pool, Stream, Durability, ErrorKind, PlasmiteError.
4
+ Role: Minimal, ergonomic wrapper around include/plasmite.h for Python users.
5
+ Invariants: JSON bytes in/out; native operations and close serialize per handle.
6
+ Invariants: Errors preserve stable kinds and context fields.
7
+ Notes: Uses ctypes and links to libplasmite resolved at runtime.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from ctypes import (
14
+ CDLL,
15
+ POINTER,
16
+ Structure,
17
+ byref,
18
+ c_char_p,
19
+ c_int,
20
+ c_size_t,
21
+ c_uint32,
22
+ c_uint64,
23
+ c_uint8,
24
+ c_void_p,
25
+ cast,
26
+ sizeof,
27
+ )
28
+ from datetime import datetime, timezone
29
+ from enum import IntEnum
30
+ from functools import wraps
31
+ from threading import Lock, RLock
32
+ import json
33
+ import os
34
+ import re
35
+ from pathlib import Path
36
+ import sys
37
+ import time as _time
38
+ from typing import Any, Generator, Iterable, Optional
39
+
40
+
41
+ class ErrorKind(IntEnum):
42
+ INTERNAL = 1
43
+ USAGE = 2
44
+ NOT_FOUND = 3
45
+ ALREADY_EXISTS = 4
46
+ BUSY = 5
47
+ PERMISSION = 6
48
+ CORRUPT = 7
49
+ IO = 8
50
+ RETENTION_GAP = 9
51
+
52
+
53
+ class Durability(IntEnum):
54
+ FAST = 0
55
+ FLUSH = 1
56
+
57
+
58
+ class PlasmiteError(RuntimeError):
59
+ def __init__(
60
+ self,
61
+ kind: ErrorKind,
62
+ message: str,
63
+ path: str | None = None,
64
+ seq: int | None = None,
65
+ offset: int | None = None,
66
+ ) -> None:
67
+ super().__init__(message)
68
+ self.kind = kind
69
+ self.path = path
70
+ self.seq = seq
71
+ self.offset = offset
72
+
73
+
74
+ class NotFoundError(PlasmiteError):
75
+ pass
76
+
77
+
78
+ class AlreadyExistsError(PlasmiteError):
79
+ pass
80
+
81
+
82
+ class BusyError(PlasmiteError):
83
+ pass
84
+
85
+
86
+ class PermissionDeniedError(PlasmiteError):
87
+ pass
88
+
89
+
90
+ class CorruptError(PlasmiteError):
91
+ pass
92
+
93
+
94
+ class IoError(PlasmiteError):
95
+ pass
96
+
97
+
98
+ class UsageError(PlasmiteError):
99
+ pass
100
+
101
+
102
+ class InternalError(PlasmiteError):
103
+ pass
104
+
105
+
106
+ class RetentionGapError(PlasmiteError):
107
+ pass
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class MessageMeta:
112
+ tags: list[str]
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class Message:
117
+ seq: int
118
+ time: datetime
119
+ time_rfc3339: str
120
+ data: Any
121
+ meta: MessageMeta
122
+ raw: bytes
123
+
124
+ @property
125
+ def tags(self) -> list[str]:
126
+ return self.meta.tags
127
+
128
+
129
+ class plsm_client_t(Structure):
130
+ pass
131
+
132
+
133
+ class plsm_pool_t(Structure):
134
+ pass
135
+
136
+
137
+ class plsm_stream_t(Structure):
138
+ pass
139
+
140
+
141
+ class plsm_lite3_stream_t(Structure):
142
+ pass
143
+
144
+
145
+ class plsm_udp_client_t(Structure):
146
+ pass
147
+
148
+
149
+ class plsm_udp_pool_t(Structure):
150
+ pass
151
+
152
+
153
+ class plsm_udp_follow_t(Structure):
154
+ pass
155
+
156
+
157
+ class plsm_buf_t(Structure):
158
+ _fields_ = [
159
+ ("data", c_void_p),
160
+ ("len", c_size_t),
161
+ ]
162
+
163
+
164
+ class plsm_udp_event_t(Structure):
165
+ _fields_ = [
166
+ ("kind", c_uint32),
167
+ ("reason", c_uint32),
168
+ ("seq", c_uint64),
169
+ ("timestamp_ns", c_uint64),
170
+ ("first", c_uint64),
171
+ ("last", c_uint64),
172
+ ("start_seq", c_uint64),
173
+ ("next_seq", c_uint64),
174
+ ("end_seq", c_uint64),
175
+ ("has_end", c_uint8),
176
+ ("terminal", c_uint8),
177
+ ("retention_gap", c_uint8),
178
+ ("reserved", c_uint8),
179
+ ("payload", plsm_buf_t),
180
+ ]
181
+
182
+
183
+ class plsm_lite3_frame_t(Structure):
184
+ _fields_ = [
185
+ ("seq", c_uint64),
186
+ ("timestamp_ns", c_uint64),
187
+ ("flags", c_uint32),
188
+ ("payload", plsm_buf_t),
189
+ ]
190
+
191
+
192
+ class plsm_error_t(Structure):
193
+ _fields_ = [
194
+ ("kind", c_int),
195
+ ("message", c_char_p),
196
+ ("path", c_char_p),
197
+ ("seq", c_uint64),
198
+ ("offset", c_uint64),
199
+ ("has_seq", c_uint8),
200
+ ("has_offset", c_uint8),
201
+ ]
202
+
203
+
204
+ class plsm_stream_options_t(Structure):
205
+ _fields_ = [
206
+ ("struct_size", c_uint32),
207
+ ("gap_policy", c_uint32),
208
+ ("since_seq", c_uint64),
209
+ ("max_messages", c_uint64),
210
+ ("timeout_ms", c_uint64),
211
+ ("has_since", c_uint32),
212
+ ("has_max", c_uint32),
213
+ ("has_timeout", c_uint32),
214
+ ("reserved", c_uint32),
215
+ ]
216
+
217
+
218
+ def _load_lib() -> CDLL:
219
+ env_dir = os.environ.get("PLASMITE_LIB_DIR")
220
+ if env_dir:
221
+ candidates = [
222
+ os.path.join(env_dir, name)
223
+ for name in ("plasmite.dll", "libplasmite.dylib", "libplasmite.so")
224
+ ]
225
+ for candidate in candidates:
226
+ if os.path.exists(candidate):
227
+ return CDLL(candidate)
228
+ native_dir = Path(__file__).resolve().parent / "_native"
229
+ for candidate in (
230
+ native_dir / "plasmite.dll",
231
+ native_dir / "libplasmite.dylib",
232
+ native_dir / "libplasmite.so",
233
+ ):
234
+ if candidate.exists():
235
+ return CDLL(str(candidate))
236
+ for name in ("plasmite.dll", "plasmite", "libplasmite"):
237
+ try:
238
+ return CDLL(name)
239
+ except OSError:
240
+ continue
241
+ raise OSError(
242
+ "libplasmite not found; set PLASMITE_LIB_DIR or build target/debug/libplasmite.*"
243
+ )
244
+
245
+
246
+ _LIB = _load_lib()
247
+
248
+ DEFAULT_POOL_SIZE_BYTES = 1024 * 1024
249
+ DEFAULT_POOL_SIZE = DEFAULT_POOL_SIZE_BYTES
250
+ _UINT64_MAX = (1 << 64) - 1
251
+
252
+
253
+ def default_pool_dir() -> str:
254
+ # Compute lazily so tests/tools can override HOME in-process without re-importing.
255
+ # `expanduser("~")` consults HOME at call time on Unix.
256
+ return os.path.join(os.path.expanduser("~"), ".plasmite", "pools")
257
+
258
+
259
+ DEFAULT_POOL_DIR = default_pool_dir()
260
+
261
+ _LIB.plsm_client_new.argtypes = [c_char_p, POINTER(POINTER(plsm_client_t)), POINTER(POINTER(plsm_error_t))]
262
+ _LIB.plsm_client_new.restype = c_int
263
+ _LIB.plsm_client_free.argtypes = [POINTER(plsm_client_t)]
264
+ _LIB.plsm_client_free.restype = None
265
+
266
+ _LIB.plsm_pool_create.argtypes = [
267
+ POINTER(plsm_client_t),
268
+ c_char_p,
269
+ c_uint64,
270
+ POINTER(POINTER(plsm_pool_t)),
271
+ POINTER(POINTER(plsm_error_t)),
272
+ ]
273
+ _LIB.plsm_pool_create.restype = c_int
274
+ _LIB.plsm_pool_open.argtypes = [
275
+ POINTER(plsm_client_t),
276
+ c_char_p,
277
+ POINTER(POINTER(plsm_pool_t)),
278
+ POINTER(POINTER(plsm_error_t)),
279
+ ]
280
+ _LIB.plsm_pool_open.restype = c_int
281
+ _LIB.plsm_pool_free.argtypes = [POINTER(plsm_pool_t)]
282
+ _LIB.plsm_pool_free.restype = None
283
+
284
+ _LIB.plsm_pool_append_json.argtypes = [
285
+ POINTER(plsm_pool_t),
286
+ POINTER(c_uint8),
287
+ c_size_t,
288
+ POINTER(c_char_p),
289
+ c_size_t,
290
+ c_uint32,
291
+ POINTER(plsm_buf_t),
292
+ POINTER(POINTER(plsm_error_t)),
293
+ ]
294
+ _LIB.plsm_pool_append_json.restype = c_int
295
+
296
+ _LIB.plsm_pool_append_lite3.argtypes = [
297
+ POINTER(plsm_pool_t),
298
+ POINTER(c_uint8),
299
+ c_size_t,
300
+ c_uint32,
301
+ POINTER(c_uint64),
302
+ POINTER(POINTER(plsm_error_t)),
303
+ ]
304
+ _LIB.plsm_pool_append_lite3.restype = c_int
305
+
306
+ _LIB.plsm_pool_get_json.argtypes = [
307
+ POINTER(plsm_pool_t),
308
+ c_uint64,
309
+ POINTER(plsm_buf_t),
310
+ POINTER(POINTER(plsm_error_t)),
311
+ ]
312
+ _LIB.plsm_pool_get_json.restype = c_int
313
+
314
+ _LIB.plsm_pool_get_lite3.argtypes = [
315
+ POINTER(plsm_pool_t),
316
+ c_uint64,
317
+ POINTER(plsm_lite3_frame_t),
318
+ POINTER(POINTER(plsm_error_t)),
319
+ ]
320
+ _LIB.plsm_pool_get_lite3.restype = c_int
321
+
322
+ _LIB.plsm_stream_open.argtypes = [
323
+ POINTER(plsm_pool_t),
324
+ c_uint64,
325
+ c_uint32,
326
+ c_uint64,
327
+ c_uint32,
328
+ c_uint64,
329
+ c_uint32,
330
+ POINTER(POINTER(plsm_stream_t)),
331
+ POINTER(POINTER(plsm_error_t)),
332
+ ]
333
+ _LIB.plsm_stream_open.restype = c_int
334
+ _LIB.plsm_stream_open_ex.argtypes = [
335
+ POINTER(plsm_pool_t),
336
+ POINTER(plsm_stream_options_t),
337
+ POINTER(POINTER(plsm_stream_t)),
338
+ POINTER(POINTER(plsm_error_t)),
339
+ ]
340
+ _LIB.plsm_stream_open_ex.restype = c_int
341
+
342
+ _LIB.plsm_lite3_stream_open.argtypes = [
343
+ POINTER(plsm_pool_t),
344
+ c_uint64,
345
+ c_uint32,
346
+ c_uint64,
347
+ c_uint32,
348
+ c_uint64,
349
+ c_uint32,
350
+ POINTER(POINTER(plsm_lite3_stream_t)),
351
+ POINTER(POINTER(plsm_error_t)),
352
+ ]
353
+ _LIB.plsm_lite3_stream_open.restype = c_int
354
+ _LIB.plsm_lite3_stream_open_ex.argtypes = [
355
+ POINTER(plsm_pool_t),
356
+ POINTER(plsm_stream_options_t),
357
+ POINTER(POINTER(plsm_lite3_stream_t)),
358
+ POINTER(POINTER(plsm_error_t)),
359
+ ]
360
+ _LIB.plsm_lite3_stream_open_ex.restype = c_int
361
+
362
+ _LIB.plsm_stream_next.argtypes = [
363
+ POINTER(plsm_stream_t),
364
+ POINTER(plsm_buf_t),
365
+ POINTER(POINTER(plsm_error_t)),
366
+ ]
367
+ _LIB.plsm_stream_next.restype = c_int
368
+
369
+ _LIB.plsm_lite3_stream_next.argtypes = [
370
+ POINTER(plsm_lite3_stream_t),
371
+ POINTER(plsm_lite3_frame_t),
372
+ POINTER(POINTER(plsm_error_t)),
373
+ ]
374
+ _LIB.plsm_lite3_stream_next.restype = c_int
375
+
376
+ _LIB.plsm_stream_free.argtypes = [POINTER(plsm_stream_t)]
377
+ _LIB.plsm_stream_free.restype = None
378
+
379
+ _LIB.plsm_lite3_stream_free.argtypes = [POINTER(plsm_lite3_stream_t)]
380
+ _LIB.plsm_lite3_stream_free.restype = None
381
+
382
+ _LIB.plsm_buf_free.argtypes = [POINTER(plsm_buf_t)]
383
+ _LIB.plsm_buf_free.restype = None
384
+
385
+ _LIB.plsm_lite3_frame_free.argtypes = [POINTER(plsm_lite3_frame_t)]
386
+ _LIB.plsm_lite3_frame_free.restype = None
387
+
388
+ _LIB.plsm_error_free.argtypes = [POINTER(plsm_error_t)]
389
+ _LIB.plsm_error_free.restype = None
390
+
391
+ _LIB.plsm_udp_client_new.argtypes = [
392
+ c_char_p,
393
+ POINTER(POINTER(plsm_udp_client_t)),
394
+ POINTER(POINTER(plsm_error_t)),
395
+ ]
396
+ _LIB.plsm_udp_client_new.restype = c_int
397
+ _LIB.plsm_udp_client_free.argtypes = [POINTER(plsm_udp_client_t)]
398
+ _LIB.plsm_udp_client_free.restype = None
399
+ _LIB.plsm_udp_client_open_pool.argtypes = [
400
+ POINTER(plsm_udp_client_t),
401
+ c_char_p,
402
+ POINTER(POINTER(plsm_udp_pool_t)),
403
+ POINTER(POINTER(plsm_error_t)),
404
+ ]
405
+ _LIB.plsm_udp_client_open_pool.restype = c_int
406
+ _LIB.plsm_udp_pool_free.argtypes = [POINTER(plsm_udp_pool_t)]
407
+ _LIB.plsm_udp_pool_free.restype = None
408
+ _LIB.plsm_udp_pool_send_json.argtypes = [
409
+ POINTER(plsm_udp_pool_t),
410
+ POINTER(c_uint8),
411
+ c_size_t,
412
+ POINTER(c_char_p),
413
+ c_size_t,
414
+ POINTER(POINTER(plsm_error_t)),
415
+ ]
416
+ _LIB.plsm_udp_pool_send_json.restype = c_int
417
+ _LIB.plsm_udp_pool_send_lite3.argtypes = [
418
+ POINTER(plsm_udp_pool_t),
419
+ POINTER(c_uint8),
420
+ c_size_t,
421
+ POINTER(POINTER(plsm_error_t)),
422
+ ]
423
+ _LIB.plsm_udp_pool_send_lite3.restype = c_int
424
+ _LIB.plsm_udp_pool_follow.argtypes = [
425
+ POINTER(plsm_udp_pool_t),
426
+ c_uint32,
427
+ c_uint64,
428
+ c_uint32,
429
+ POINTER(POINTER(plsm_udp_follow_t)),
430
+ POINTER(POINTER(plsm_error_t)),
431
+ ]
432
+ _LIB.plsm_udp_pool_follow.restype = c_int
433
+ _LIB.plsm_udp_follow_next.argtypes = [
434
+ POINTER(plsm_udp_follow_t),
435
+ c_uint64,
436
+ POINTER(plsm_udp_event_t),
437
+ POINTER(POINTER(plsm_error_t)),
438
+ ]
439
+ _LIB.plsm_udp_follow_next.restype = c_int
440
+ _LIB.plsm_udp_follow_cancel.argtypes = [POINTER(plsm_udp_follow_t)]
441
+ _LIB.plsm_udp_follow_cancel.restype = None
442
+ _LIB.plsm_udp_follow_close.argtypes = [POINTER(plsm_udp_follow_t)]
443
+ _LIB.plsm_udp_follow_close.restype = None
444
+ _LIB.plsm_udp_event_free.argtypes = [POINTER(plsm_udp_event_t)]
445
+ _LIB.plsm_udp_event_free.restype = None
446
+ _LIB.plsm_udp_event_message.argtypes = [
447
+ POINTER(plsm_udp_event_t),
448
+ POINTER(plsm_buf_t),
449
+ POINTER(POINTER(plsm_error_t)),
450
+ ]
451
+ _LIB.plsm_udp_event_message.restype = c_int
452
+
453
+ _ERROR_KIND_TO_CLASS: dict[ErrorKind, type[PlasmiteError]] = {
454
+ ErrorKind.INTERNAL: InternalError,
455
+ ErrorKind.USAGE: UsageError,
456
+ ErrorKind.NOT_FOUND: NotFoundError,
457
+ ErrorKind.ALREADY_EXISTS: AlreadyExistsError,
458
+ ErrorKind.BUSY: BusyError,
459
+ ErrorKind.PERMISSION: PermissionDeniedError,
460
+ ErrorKind.CORRUPT: CorruptError,
461
+ ErrorKind.IO: IoError,
462
+ ErrorKind.RETENTION_GAP: RetentionGapError,
463
+ }
464
+
465
+
466
+ def _take_error(err_ptr: POINTER(plsm_error_t) | None) -> PlasmiteError:
467
+ if not err_ptr:
468
+ return PlasmiteError(ErrorKind.INTERNAL, "plasmite: unknown error")
469
+ err = err_ptr.contents
470
+ raw_kind = int(err.kind)
471
+ message = err.message.decode("utf-8") if err.message else ""
472
+ path = err.path.decode("utf-8") if err.path else None
473
+ seq = int(err.seq) if err.has_seq else None
474
+ offset = int(err.offset) if err.has_offset else None
475
+ _LIB.plsm_error_free(err_ptr)
476
+ try:
477
+ kind = ErrorKind(raw_kind)
478
+ except ValueError:
479
+ kind = ErrorKind.INTERNAL
480
+ if not message:
481
+ message = _default_error_message(kind)
482
+ error_cls = _ERROR_KIND_TO_CLASS.get(kind, PlasmiteError)
483
+ return error_cls(kind, message, path, seq, offset)
484
+
485
+
486
+ def _default_error_message(kind: ErrorKind) -> str:
487
+ mapping = {
488
+ ErrorKind.INTERNAL: "internal error",
489
+ ErrorKind.USAGE: "usage error",
490
+ ErrorKind.NOT_FOUND: "not found",
491
+ ErrorKind.ALREADY_EXISTS: "already exists",
492
+ ErrorKind.BUSY: "busy",
493
+ ErrorKind.PERMISSION: "permission denied",
494
+ ErrorKind.CORRUPT: "corrupt",
495
+ ErrorKind.IO: "io error",
496
+ ErrorKind.RETENTION_GAP: "retention gap",
497
+ }
498
+ return mapping.get(kind, "error")
499
+
500
+
501
+ def _buf_to_bytes(buf: plsm_buf_t) -> bytes:
502
+ if not buf.data or buf.len == 0:
503
+ _LIB.plsm_buf_free(byref(buf))
504
+ return b""
505
+ data = (c_uint8 * buf.len).from_address(buf.data)
506
+ out = bytes(data)
507
+ _LIB.plsm_buf_free(byref(buf))
508
+ return out
509
+
510
+
511
+ def _frame_to_py(frame: plsm_lite3_frame_t) -> Lite3Frame:
512
+ seq = int(frame.seq)
513
+ timestamp_ns = int(frame.timestamp_ns)
514
+ flags = int(frame.flags)
515
+ payload = b""
516
+ if frame.payload.data and frame.payload.len:
517
+ data = (c_uint8 * frame.payload.len).from_address(frame.payload.data)
518
+ payload = bytes(data)
519
+ _LIB.plsm_lite3_frame_free(byref(frame))
520
+ return Lite3Frame(seq=seq, timestamp_ns=timestamp_ns, flags=flags, payload=payload)
521
+
522
+
523
+ def _tag_array(values: Iterable[str]) -> tuple[POINTER(c_char_p), list[bytes]]:
524
+ encoded = [value.encode("utf-8") for value in values]
525
+ if not encoded:
526
+ return POINTER(c_char_p)(), encoded
527
+ arr = (c_char_p * len(encoded))()
528
+ for idx, val in enumerate(encoded):
529
+ arr[idx] = val
530
+ return arr, encoded
531
+
532
+
533
+ def _closed_error(target: str) -> PlasmiteError:
534
+ return PlasmiteError(ErrorKind.USAGE, f"plasmite: {target} is closed")
535
+
536
+
537
+ def _require_open(ptr: object, target: str) -> None:
538
+ if not ptr:
539
+ raise _closed_error(target)
540
+
541
+
542
+ def _serialized_handle(method):
543
+ """Keep handle checks, native calls, and owned-result cleanup together."""
544
+ @wraps(method)
545
+ def locked(self, *args, **kwargs):
546
+ with self._lock:
547
+ return method(self, *args, **kwargs)
548
+
549
+ return locked
550
+
551
+
552
+ def _ensure_bytes(payload: bytes | bytearray | memoryview, name: str) -> bytes:
553
+ if isinstance(payload, bytes):
554
+ return payload
555
+ if isinstance(payload, (bytearray, memoryview)):
556
+ return bytes(payload)
557
+ raise TypeError(f"{name} must be bytes-like")
558
+
559
+
560
+ def _ensure_non_negative_int(value: int, name: str) -> int:
561
+ if not isinstance(value, int):
562
+ raise TypeError(f"{name} must be an int")
563
+ if value < 0:
564
+ raise ValueError(f"{name} must be >= 0")
565
+ if value > _UINT64_MAX:
566
+ raise ValueError(f"{name} must fit an unsigned 64-bit integer")
567
+ return value
568
+
569
+
570
+ def _optional_non_negative_int(value: Optional[int], name: str) -> Optional[int]:
571
+ if value is None:
572
+ return None
573
+ return _ensure_non_negative_int(value, name)
574
+
575
+
576
+ def _stream_options(
577
+ since_seq: Optional[int],
578
+ max_messages: Optional[int],
579
+ timeout_ms: Optional[int],
580
+ error_on_gap: bool,
581
+ ) -> plsm_stream_options_t:
582
+ options = plsm_stream_options_t()
583
+ options.struct_size = sizeof(plsm_stream_options_t)
584
+ options.gap_policy = 1 if error_on_gap else 0
585
+ options.since_seq = since_seq or 0
586
+ options.max_messages = max_messages or 0
587
+ options.timeout_ms = timeout_ms or 0
588
+ options.has_since = 1 if since_seq is not None else 0
589
+ options.has_max = 1 if max_messages is not None else 0
590
+ options.has_timeout = 1 if timeout_ms is not None else 0
591
+ return options
592
+
593
+
594
+ class Client:
595
+ def __init__(self, pool_dir: str | None = None) -> None:
596
+ self._lock = RLock()
597
+ self._ptr = None
598
+ if pool_dir is None:
599
+ pool_dir = default_pool_dir()
600
+ if not pool_dir:
601
+ raise ValueError("pool_dir is required")
602
+ out_client = POINTER(plsm_client_t)()
603
+ out_err = POINTER(plsm_error_t)()
604
+ rc = _LIB.plsm_client_new(pool_dir.encode("utf-8"), byref(out_client), byref(out_err))
605
+ if rc != 0:
606
+ raise _take_error(out_err)
607
+ self._ptr = out_client
608
+
609
+ @_serialized_handle
610
+ def create_pool(
611
+ self,
612
+ pool_ref: str,
613
+ size_bytes: int = DEFAULT_POOL_SIZE_BYTES,
614
+ ) -> Pool:
615
+ _require_open(self._ptr, "client")
616
+ if not pool_ref:
617
+ raise ValueError("pool_ref is required")
618
+ size_bytes = _ensure_non_negative_int(size_bytes, "size_bytes")
619
+ out_pool = POINTER(plsm_pool_t)()
620
+ out_err = POINTER(plsm_error_t)()
621
+ rc = _LIB.plsm_pool_create(
622
+ self._ptr,
623
+ pool_ref.encode("utf-8"),
624
+ c_uint64(size_bytes),
625
+ byref(out_pool),
626
+ byref(out_err),
627
+ )
628
+ if rc != 0:
629
+ raise _take_error(out_err)
630
+ return Pool(out_pool)
631
+
632
+ @_serialized_handle
633
+ def open_pool(self, pool_ref: str) -> Pool:
634
+ _require_open(self._ptr, "client")
635
+ if not pool_ref:
636
+ raise ValueError("pool_ref is required")
637
+ out_pool = POINTER(plsm_pool_t)()
638
+ out_err = POINTER(plsm_error_t)()
639
+ rc = _LIB.plsm_pool_open(
640
+ self._ptr,
641
+ pool_ref.encode("utf-8"),
642
+ byref(out_pool),
643
+ byref(out_err),
644
+ )
645
+ if rc != 0:
646
+ raise _take_error(out_err)
647
+ return Pool(out_pool)
648
+
649
+ def pool(
650
+ self,
651
+ pool_ref: str,
652
+ size_bytes: int = DEFAULT_POOL_SIZE_BYTES,
653
+ ) -> Pool:
654
+ try:
655
+ return self.open_pool(pool_ref)
656
+ except NotFoundError:
657
+ return self.create_pool(pool_ref, size_bytes)
658
+
659
+ @_serialized_handle
660
+ def close(self) -> None:
661
+ if getattr(self, "_ptr", None):
662
+ _LIB.plsm_client_free(self._ptr)
663
+ self._ptr = None
664
+
665
+ def __enter__(self) -> Client:
666
+ with self._lock:
667
+ _require_open(self._ptr, "client")
668
+ return self
669
+
670
+ def __exit__(self, _exc_type, _exc, _tb) -> None:
671
+ self.close()
672
+
673
+ def __del__(self) -> None:
674
+ self.close()
675
+
676
+
677
+ @dataclass(frozen=True)
678
+ class Lite3Frame:
679
+ seq: int
680
+ timestamp_ns: int
681
+ flags: int
682
+ payload: bytes
683
+
684
+ @property
685
+ def time(self) -> datetime:
686
+ seconds, nanoseconds = divmod(self.timestamp_ns, 1_000_000_000)
687
+ return datetime.fromtimestamp(seconds, tz=timezone.utc).replace(
688
+ microsecond=nanoseconds // 1_000
689
+ )
690
+
691
+
692
+ class Pool:
693
+ def __init__(self, ptr: POINTER(plsm_pool_t)) -> None:
694
+ self._lock = RLock()
695
+ self._ptr = ptr
696
+
697
+ @_serialized_handle
698
+ def append_json(self, payload: bytes, tags: Iterable[str], durability: Durability) -> bytes:
699
+ _require_open(self._ptr, "pool")
700
+ payload = _ensure_bytes(payload, "payload")
701
+ if not payload:
702
+ raise ValueError("payload is required")
703
+ buf = plsm_buf_t()
704
+ out_err = POINTER(plsm_error_t)()
705
+ arr, _keep = _tag_array(tags)
706
+ payload_buf = (c_uint8 * len(payload)).from_buffer_copy(payload)
707
+ rc = _LIB.plsm_pool_append_json(
708
+ self._ptr,
709
+ payload_buf,
710
+ c_size_t(len(payload)),
711
+ arr,
712
+ c_size_t(len(_keep)),
713
+ c_uint32(int(durability)),
714
+ byref(buf),
715
+ byref(out_err),
716
+ )
717
+ if rc != 0:
718
+ raise _take_error(out_err)
719
+ return _buf_to_bytes(buf)
720
+
721
+ def append(
722
+ self,
723
+ value,
724
+ tags: Optional[Iterable[str]] = None,
725
+ durability: Durability = Durability.FAST,
726
+ ) -> Message:
727
+ payload = self.append_json(
728
+ json.dumps(value).encode("utf-8"),
729
+ [] if tags is None else tags,
730
+ durability,
731
+ )
732
+ return parse_message(payload)
733
+
734
+ @_serialized_handle
735
+ def append_lite3(self, payload: bytes, durability: Durability) -> int:
736
+ _require_open(self._ptr, "pool")
737
+ payload = _ensure_bytes(payload, "payload")
738
+ if not payload:
739
+ raise ValueError("payload is required")
740
+ out_seq = c_uint64()
741
+ out_err = POINTER(plsm_error_t)()
742
+ payload_buf = (c_uint8 * len(payload)).from_buffer_copy(payload)
743
+ rc = _LIB.plsm_pool_append_lite3(
744
+ self._ptr,
745
+ payload_buf,
746
+ c_size_t(len(payload)),
747
+ c_uint32(int(durability)),
748
+ byref(out_seq),
749
+ byref(out_err),
750
+ )
751
+ if rc != 0:
752
+ raise _take_error(out_err)
753
+ return int(out_seq.value)
754
+
755
+ @_serialized_handle
756
+ def get_json(self, seq: int) -> bytes:
757
+ _require_open(self._ptr, "pool")
758
+ seq = _ensure_non_negative_int(seq, "seq")
759
+ buf = plsm_buf_t()
760
+ out_err = POINTER(plsm_error_t)()
761
+ rc = _LIB.plsm_pool_get_json(self._ptr, c_uint64(seq), byref(buf), byref(out_err))
762
+ if rc != 0:
763
+ raise _take_error(out_err)
764
+ return _buf_to_bytes(buf)
765
+
766
+ def get(self, seq: int) -> Message:
767
+ return parse_message(self.get_json(seq))
768
+
769
+ @_serialized_handle
770
+ def get_lite3(self, seq: int) -> Lite3Frame:
771
+ _require_open(self._ptr, "pool")
772
+ seq = _ensure_non_negative_int(seq, "seq")
773
+ frame = plsm_lite3_frame_t()
774
+ out_err = POINTER(plsm_error_t)()
775
+ rc = _LIB.plsm_pool_get_lite3(self._ptr, c_uint64(seq), byref(frame), byref(out_err))
776
+ if rc != 0:
777
+ raise _take_error(out_err)
778
+ return _frame_to_py(frame)
779
+
780
+ @_serialized_handle
781
+ def open_stream(
782
+ self,
783
+ since_seq: Optional[int] = None,
784
+ max_messages: Optional[int] = None,
785
+ timeout_ms: Optional[int] = None,
786
+ error_on_gap: bool = False,
787
+ ) -> Stream:
788
+ _require_open(self._ptr, "pool")
789
+ since_seq = _optional_non_negative_int(since_seq, "since_seq")
790
+ max_messages = _optional_non_negative_int(max_messages, "max_messages")
791
+ timeout_ms = _optional_non_negative_int(timeout_ms, "timeout_ms")
792
+ out_stream = POINTER(plsm_stream_t)()
793
+ out_err = POINTER(plsm_error_t)()
794
+ if error_on_gap:
795
+ options = _stream_options(since_seq, max_messages, timeout_ms, True)
796
+ rc = _LIB.plsm_stream_open_ex(
797
+ self._ptr,
798
+ byref(options),
799
+ byref(out_stream),
800
+ byref(out_err),
801
+ )
802
+ else:
803
+ rc = _LIB.plsm_stream_open(
804
+ self._ptr,
805
+ c_uint64(since_seq or 0),
806
+ c_uint32(1 if since_seq is not None else 0),
807
+ c_uint64(max_messages or 0),
808
+ c_uint32(1 if max_messages is not None else 0),
809
+ c_uint64(timeout_ms or 0),
810
+ c_uint32(1 if timeout_ms is not None else 0),
811
+ byref(out_stream),
812
+ byref(out_err),
813
+ )
814
+ if rc != 0:
815
+ raise _take_error(out_err)
816
+ return Stream(out_stream)
817
+
818
+ @_serialized_handle
819
+ def open_lite3_stream(
820
+ self,
821
+ since_seq: Optional[int] = None,
822
+ max_messages: Optional[int] = None,
823
+ timeout_ms: Optional[int] = None,
824
+ error_on_gap: bool = False,
825
+ ) -> Lite3Stream:
826
+ _require_open(self._ptr, "pool")
827
+ since_seq = _optional_non_negative_int(since_seq, "since_seq")
828
+ max_messages = _optional_non_negative_int(max_messages, "max_messages")
829
+ timeout_ms = _optional_non_negative_int(timeout_ms, "timeout_ms")
830
+ out_stream = POINTER(plsm_lite3_stream_t)()
831
+ out_err = POINTER(plsm_error_t)()
832
+ if error_on_gap:
833
+ options = _stream_options(since_seq, max_messages, timeout_ms, True)
834
+ rc = _LIB.plsm_lite3_stream_open_ex(
835
+ self._ptr,
836
+ byref(options),
837
+ byref(out_stream),
838
+ byref(out_err),
839
+ )
840
+ else:
841
+ rc = _LIB.plsm_lite3_stream_open(
842
+ self._ptr,
843
+ c_uint64(since_seq or 0),
844
+ c_uint32(1 if since_seq is not None else 0),
845
+ c_uint64(max_messages or 0),
846
+ c_uint32(1 if max_messages is not None else 0),
847
+ c_uint64(timeout_ms or 0),
848
+ c_uint32(1 if timeout_ms is not None else 0),
849
+ byref(out_stream),
850
+ byref(out_err),
851
+ )
852
+ if rc != 0:
853
+ raise _take_error(out_err)
854
+ return Lite3Stream(out_stream)
855
+
856
+ def replay(
857
+ self,
858
+ speed: float = 1.0,
859
+ since_seq: Optional[int] = None,
860
+ max_messages: Optional[int] = None,
861
+ timeout_ms: Optional[int] = None,
862
+ tags: Optional[Iterable[str]] = None,
863
+ ) -> Generator[Message, None, None]:
864
+ """Replay messages with original timing scaled by speed.
865
+
866
+ Yields Message objects with inter-message delays derived from
867
+ each message's ``time``. The first message is yielded
868
+ immediately; subsequent messages are delayed by
869
+ ``(current_time - prev_time) / speed``.
870
+ """
871
+ if speed <= 0:
872
+ raise ValueError("speed must be positive")
873
+
874
+ required_tags = list(tags or [])
875
+ stream_max_messages = (
876
+ None if (max_messages is not None and required_tags) else max_messages
877
+ )
878
+
879
+ stream = self.open_stream(
880
+ since_seq=since_seq,
881
+ max_messages=stream_max_messages,
882
+ timeout_ms=timeout_ms,
883
+ )
884
+ try:
885
+ prev_dt: Optional[datetime] = None
886
+ delivered = 0
887
+ filter_by_tags = bool(required_tags)
888
+ while True:
889
+ msg = stream.next_json()
890
+ if msg is None:
891
+ break
892
+ message = parse_message(msg)
893
+ if filter_by_tags and not _message_has_tags(message, required_tags):
894
+ continue
895
+ cur_dt = message.time
896
+ if prev_dt is not None and cur_dt is not None:
897
+ delta = (cur_dt - prev_dt).total_seconds() / speed
898
+ if delta > 0:
899
+ _time.sleep(delta)
900
+ if cur_dt is not None:
901
+ prev_dt = cur_dt
902
+ delivered += 1
903
+ yield message
904
+ if max_messages is not None and delivered >= max_messages:
905
+ break
906
+ finally:
907
+ stream.close()
908
+
909
+ def tail(
910
+ self,
911
+ since_seq: Optional[int] = None,
912
+ max_messages: Optional[int] = None,
913
+ timeout_ms: Optional[int] = None,
914
+ tags: Optional[Iterable[str]] = None,
915
+ error_on_gap: bool = False,
916
+ ) -> Generator[Message, None, None]:
917
+ """Tail JSON messages and optionally filter by exact tags."""
918
+ required_tags = list(tags or [])
919
+ stream_max_messages = (
920
+ None if (max_messages is not None and required_tags) else max_messages
921
+ )
922
+ stream = self.open_stream(
923
+ since_seq=since_seq,
924
+ max_messages=stream_max_messages,
925
+ timeout_ms=timeout_ms,
926
+ error_on_gap=error_on_gap,
927
+ )
928
+ try:
929
+ delivered = 0
930
+ filter_by_tags = bool(required_tags)
931
+ while True:
932
+ msg = stream.next_json()
933
+ if msg is None:
934
+ break
935
+ message = parse_message(msg)
936
+ if filter_by_tags:
937
+ if not _message_has_tags(message, required_tags):
938
+ continue
939
+ delivered += 1
940
+ yield message
941
+ if max_messages is not None and delivered >= max_messages:
942
+ break
943
+ finally:
944
+ stream.close()
945
+
946
+ @_serialized_handle
947
+ def close(self) -> None:
948
+ if getattr(self, "_ptr", None):
949
+ _LIB.plsm_pool_free(self._ptr)
950
+ self._ptr = None
951
+
952
+ def __enter__(self) -> Pool:
953
+ with self._lock:
954
+ _require_open(self._ptr, "pool")
955
+ return self
956
+
957
+ def __exit__(self, _exc_type, _exc, _tb) -> None:
958
+ self.close()
959
+
960
+ def __del__(self) -> None:
961
+ self.close()
962
+
963
+
964
+ class Stream:
965
+ def __init__(self, ptr: POINTER(plsm_stream_t)) -> None:
966
+ self._lock = RLock()
967
+ self._ptr = ptr
968
+
969
+ @_serialized_handle
970
+ def next_json(self) -> Optional[bytes]:
971
+ _require_open(self._ptr, "stream")
972
+ buf = plsm_buf_t()
973
+ out_err = POINTER(plsm_error_t)()
974
+ rc = _LIB.plsm_stream_next(self._ptr, byref(buf), byref(out_err))
975
+ if rc == 1:
976
+ return _buf_to_bytes(buf)
977
+ if rc == 0:
978
+ return None
979
+ raise _take_error(out_err)
980
+
981
+ def __iter__(self) -> Stream:
982
+ return self
983
+
984
+ def __next__(self) -> bytes:
985
+ message = self.next_json()
986
+ if message is None:
987
+ raise StopIteration
988
+ return message
989
+
990
+ @_serialized_handle
991
+ def close(self) -> None:
992
+ if getattr(self, "_ptr", None):
993
+ _LIB.plsm_stream_free(self._ptr)
994
+ self._ptr = None
995
+
996
+ def __enter__(self) -> Stream:
997
+ with self._lock:
998
+ _require_open(self._ptr, "stream")
999
+ return self
1000
+
1001
+ def __exit__(self, _exc_type, _exc, _tb) -> None:
1002
+ self.close()
1003
+
1004
+ def __del__(self) -> None:
1005
+ self.close()
1006
+
1007
+
1008
+ class Lite3Stream:
1009
+ def __init__(self, ptr: POINTER(plsm_lite3_stream_t)) -> None:
1010
+ self._lock = RLock()
1011
+ self._ptr = ptr
1012
+
1013
+ @_serialized_handle
1014
+ def next(self) -> Optional[Lite3Frame]:
1015
+ _require_open(self._ptr, "stream")
1016
+ frame = plsm_lite3_frame_t()
1017
+ out_err = POINTER(plsm_error_t)()
1018
+ rc = _LIB.plsm_lite3_stream_next(self._ptr, byref(frame), byref(out_err))
1019
+ if rc == 1:
1020
+ return _frame_to_py(frame)
1021
+ if rc == 0:
1022
+ return None
1023
+ raise _take_error(out_err)
1024
+
1025
+ def __iter__(self) -> Lite3Stream:
1026
+ return self
1027
+
1028
+ def __next__(self) -> Lite3Frame:
1029
+ frame = self.next()
1030
+ if frame is None:
1031
+ raise StopIteration
1032
+ return frame
1033
+
1034
+ @_serialized_handle
1035
+ def close(self) -> None:
1036
+ if getattr(self, "_ptr", None):
1037
+ _LIB.plsm_lite3_stream_free(self._ptr)
1038
+ self._ptr = None
1039
+
1040
+ def __enter__(self) -> Lite3Stream:
1041
+ with self._lock:
1042
+ _require_open(self._ptr, "stream")
1043
+ return self
1044
+
1045
+ def __exit__(self, _exc_type, _exc, _tb) -> None:
1046
+ self.close()
1047
+
1048
+ def __del__(self) -> None:
1049
+ self.close()
1050
+
1051
+
1052
+ @dataclass(frozen=True)
1053
+ class UdpStart:
1054
+ """A UDP follow start position. Use the named constructors."""
1055
+
1056
+ kind: int
1057
+ value: int = 0
1058
+
1059
+ @classmethod
1060
+ def new(cls) -> UdpStart:
1061
+ return cls(0)
1062
+
1063
+ @classmethod
1064
+ def last(cls, count: int) -> UdpStart:
1065
+ return cls(1, _ensure_non_negative_int(count, "count"))
1066
+
1067
+ @classmethod
1068
+ def since_ns(cls, timestamp_ns: int) -> UdpStart:
1069
+ return cls(2, _ensure_non_negative_int(timestamp_ns, "timestamp_ns"))
1070
+
1071
+ @classmethod
1072
+ def sequence(cls, seq: int) -> UdpStart:
1073
+ return cls(3, _ensure_non_negative_int(seq, "seq"))
1074
+
1075
+
1076
+ @dataclass(frozen=True)
1077
+ class UdpFollowOptions:
1078
+ start: UdpStart = UdpStart(0)
1079
+ live: bool = True
1080
+
1081
+
1082
+ @dataclass(frozen=True)
1083
+ class UdpFrame:
1084
+ seq: int
1085
+ timestamp_ns: int
1086
+ payload: bytes
1087
+
1088
+ @property
1089
+ def time(self) -> datetime:
1090
+ seconds, nanoseconds = divmod(self.timestamp_ns, 1_000_000_000)
1091
+ return datetime.fromtimestamp(seconds, tz=timezone.utc).replace(
1092
+ microsecond=nanoseconds // 1_000
1093
+ )
1094
+
1095
+ def message(self) -> Message:
1096
+ payload_buf = (c_uint8 * len(self.payload)).from_buffer_copy(self.payload)
1097
+ event = plsm_udp_event_t()
1098
+ event.kind = 1
1099
+ event.seq = self.seq
1100
+ event.timestamp_ns = self.timestamp_ns
1101
+ event.payload.data = cast(payload_buf, c_void_p) if self.payload else None
1102
+ event.payload.len = len(self.payload)
1103
+ out_message = plsm_buf_t()
1104
+ out_err = POINTER(plsm_error_t)()
1105
+ rc = _LIB.plsm_udp_event_message(byref(event), byref(out_message), byref(out_err))
1106
+ if rc != 0:
1107
+ raise _take_error(out_err)
1108
+ return parse_message(_buf_to_bytes(out_message))
1109
+
1110
+
1111
+ @dataclass(frozen=True)
1112
+ class UdpGap:
1113
+ first: int
1114
+ last: int
1115
+ reason: str
1116
+
1117
+
1118
+ @dataclass(frozen=True)
1119
+ class UdpProgress:
1120
+ start_seq: int
1121
+ next_seq: int
1122
+ end_seq: int | None
1123
+ terminal: bool
1124
+ retention_gap: bool
1125
+
1126
+
1127
+ UdpEvent = UdpFrame | UdpGap | UdpProgress
1128
+
1129
+
1130
+ class UdpClient:
1131
+ """Client for one explicit UDP endpoint."""
1132
+
1133
+ def __init__(self, endpoint: str) -> None:
1134
+ self._lock = RLock()
1135
+ self._ptr = None
1136
+ if not endpoint:
1137
+ raise ValueError("endpoint is required")
1138
+ out_client = POINTER(plsm_udp_client_t)()
1139
+ out_err = POINTER(plsm_error_t)()
1140
+ rc = _LIB.plsm_udp_client_new(endpoint.encode("utf-8"), byref(out_client), byref(out_err))
1141
+ if rc != 0:
1142
+ raise _take_error(out_err)
1143
+ self._ptr = out_client
1144
+
1145
+ @_serialized_handle
1146
+ def open_pool(self, pool_name: str) -> UdpPool:
1147
+ _require_open(self._ptr, "UDP client")
1148
+ if not pool_name:
1149
+ raise ValueError("pool_name is required")
1150
+ out_pool = POINTER(plsm_udp_pool_t)()
1151
+ out_err = POINTER(plsm_error_t)()
1152
+ rc = _LIB.plsm_udp_client_open_pool(
1153
+ self._ptr,
1154
+ pool_name.encode("utf-8"),
1155
+ byref(out_pool),
1156
+ byref(out_err),
1157
+ )
1158
+ if rc != 0:
1159
+ raise _take_error(out_err)
1160
+ return UdpPool(out_pool)
1161
+
1162
+ @_serialized_handle
1163
+ def close(self) -> None:
1164
+ if self._ptr:
1165
+ _LIB.plsm_udp_client_free(self._ptr)
1166
+ self._ptr = None
1167
+
1168
+ def __enter__(self) -> UdpClient:
1169
+ with self._lock:
1170
+ _require_open(self._ptr, "UDP client")
1171
+ return self
1172
+
1173
+ def __exit__(self, _exc_type, _exc, _tb) -> None:
1174
+ self.close()
1175
+
1176
+ def __del__(self) -> None:
1177
+ self.close()
1178
+
1179
+
1180
+ class UdpPool:
1181
+ """A named pool on one UDP endpoint."""
1182
+
1183
+ def __init__(self, ptr: POINTER(plsm_udp_pool_t)) -> None:
1184
+ self._lock = RLock()
1185
+ self._ptr = ptr
1186
+
1187
+ @_serialized_handle
1188
+ def send_json(self, value: Any, tags: Iterable[str] = ()) -> None:
1189
+ _require_open(self._ptr, "UDP pool")
1190
+ payload = json.dumps(value, separators=(",", ":")).encode("utf-8")
1191
+ payload_buf = (c_uint8 * len(payload)).from_buffer_copy(payload)
1192
+ tag_arr, keepalive = _tag_array(tags)
1193
+ out_err = POINTER(plsm_error_t)()
1194
+ rc = _LIB.plsm_udp_pool_send_json(
1195
+ self._ptr,
1196
+ payload_buf,
1197
+ c_size_t(len(payload)),
1198
+ tag_arr,
1199
+ c_size_t(len(keepalive)),
1200
+ byref(out_err),
1201
+ )
1202
+ if rc != 0:
1203
+ raise _take_error(out_err)
1204
+
1205
+ @_serialized_handle
1206
+ def send_lite3(self, payload: bytes | bytearray | memoryview) -> None:
1207
+ _require_open(self._ptr, "UDP pool")
1208
+ payload = _ensure_bytes(payload, "payload")
1209
+ if not payload:
1210
+ raise ValueError("payload is required")
1211
+ payload_buf = (c_uint8 * len(payload)).from_buffer_copy(payload)
1212
+ out_err = POINTER(plsm_error_t)()
1213
+ rc = _LIB.plsm_udp_pool_send_lite3(
1214
+ self._ptr,
1215
+ payload_buf,
1216
+ c_size_t(len(payload)),
1217
+ byref(out_err),
1218
+ )
1219
+ if rc != 0:
1220
+ raise _take_error(out_err)
1221
+
1222
+ @_serialized_handle
1223
+ def follow(self, options: UdpFollowOptions | None = None) -> UdpFollow:
1224
+ _require_open(self._ptr, "UDP pool")
1225
+ options = options or UdpFollowOptions()
1226
+ if not isinstance(options, UdpFollowOptions):
1227
+ raise TypeError("options must be UdpFollowOptions")
1228
+ start = options.start
1229
+ if not isinstance(start, UdpStart) or start.kind not in (0, 1, 2, 3):
1230
+ raise ValueError("invalid UDP follow start")
1231
+ start_value = _ensure_non_negative_int(start.value, "start.value")
1232
+ out_follow = POINTER(plsm_udp_follow_t)()
1233
+ out_err = POINTER(plsm_error_t)()
1234
+ rc = _LIB.plsm_udp_pool_follow(
1235
+ self._ptr,
1236
+ c_uint32(start.kind),
1237
+ c_uint64(start_value),
1238
+ c_uint32(1 if options.live else 0),
1239
+ byref(out_follow),
1240
+ byref(out_err),
1241
+ )
1242
+ if rc != 0:
1243
+ raise _take_error(out_err)
1244
+ return UdpFollow(out_follow)
1245
+
1246
+ @_serialized_handle
1247
+ def close(self) -> None:
1248
+ if self._ptr:
1249
+ _LIB.plsm_udp_pool_free(self._ptr)
1250
+ self._ptr = None
1251
+
1252
+ def __enter__(self) -> UdpPool:
1253
+ with self._lock:
1254
+ _require_open(self._ptr, "UDP pool")
1255
+ return self
1256
+
1257
+ def __exit__(self, _exc_type, _exc, _tb) -> None:
1258
+ self.close()
1259
+
1260
+ def __del__(self) -> None:
1261
+ self.close()
1262
+
1263
+
1264
+ class UdpFollow:
1265
+ """Best-effort UDP event stream. Close cancels a blocked wait."""
1266
+
1267
+ def __init__(self, ptr: POINTER(plsm_udp_follow_t)) -> None:
1268
+ self._state_lock = RLock()
1269
+ self._read_lock = Lock()
1270
+ self._ptr = ptr
1271
+ self._closing = False
1272
+ self._cancelled = False
1273
+
1274
+ def next_event(self, timeout_ms: int | None = None) -> UdpEvent | None:
1275
+ timeout_ms = _optional_non_negative_int(timeout_ms, "timeout_ms")
1276
+ with self._read_lock:
1277
+ with self._state_lock:
1278
+ _require_open(self._ptr, "UDP follow")
1279
+ if self._closing or self._cancelled:
1280
+ return None
1281
+ ptr = self._ptr
1282
+ remaining = timeout_ms
1283
+ while True:
1284
+ event = plsm_udp_event_t()
1285
+ out_err = POINTER(plsm_error_t)()
1286
+ wait_ms = 1000 if remaining is None else remaining
1287
+ rc = _LIB.plsm_udp_follow_next(
1288
+ ptr,
1289
+ c_uint64(wait_ms),
1290
+ byref(event),
1291
+ byref(out_err),
1292
+ )
1293
+ if rc == 1:
1294
+ return _udp_event_to_python(event)
1295
+ if rc == 2 or rc == 3:
1296
+ with self._state_lock:
1297
+ self._cancelled = rc == 3
1298
+ return None
1299
+ if rc < 0:
1300
+ raise _take_error(out_err)
1301
+ if remaining is not None:
1302
+ if remaining == 0:
1303
+ return None
1304
+ remaining -= wait_ms
1305
+ if remaining <= 0:
1306
+ return None
1307
+
1308
+ def events(self) -> Generator[UdpEvent, None, None]:
1309
+ while True:
1310
+ event = self.next_event()
1311
+ if event is None:
1312
+ return
1313
+ yield event
1314
+
1315
+ def __iter__(self) -> UdpFollow:
1316
+ return self
1317
+
1318
+ def __next__(self) -> UdpEvent:
1319
+ event = self.next_event()
1320
+ if event is None:
1321
+ raise StopIteration
1322
+ return event
1323
+
1324
+ def cancel(self) -> None:
1325
+ with self._state_lock:
1326
+ if self._ptr and not self._cancelled:
1327
+ _LIB.plsm_udp_follow_cancel(self._ptr)
1328
+ self._cancelled = True
1329
+
1330
+ def close(self) -> None:
1331
+ with self._state_lock:
1332
+ ptr = self._ptr
1333
+ if not ptr:
1334
+ return
1335
+ self._closing = True
1336
+ _LIB.plsm_udp_follow_cancel(ptr)
1337
+ with self._read_lock:
1338
+ with self._state_lock:
1339
+ if self._ptr:
1340
+ _LIB.plsm_udp_follow_close(self._ptr)
1341
+ self._ptr = None
1342
+ self._cancelled = True
1343
+
1344
+ def __enter__(self) -> UdpFollow:
1345
+ with self._state_lock:
1346
+ _require_open(self._ptr, "UDP follow")
1347
+ return self
1348
+
1349
+ def __exit__(self, _exc_type, _exc, _tb) -> None:
1350
+ self.close()
1351
+
1352
+ def __del__(self) -> None:
1353
+ self.close()
1354
+
1355
+
1356
+ def _udp_event_to_python(event: plsm_udp_event_t) -> UdpEvent:
1357
+ try:
1358
+ if event.kind == 1:
1359
+ payload = b""
1360
+ if event.payload.data and event.payload.len:
1361
+ payload = bytes((c_uint8 * event.payload.len).from_address(event.payload.data))
1362
+ return UdpFrame(int(event.seq), int(event.timestamp_ns), payload)
1363
+ if event.kind == 2:
1364
+ reasons = {0: "unknown", 1: "oversized", 2: "retention", 3: "pressure"}
1365
+ return UdpGap(int(event.first), int(event.last), reasons.get(int(event.reason), "unknown"))
1366
+ if event.kind == 3:
1367
+ return UdpProgress(
1368
+ int(event.start_seq),
1369
+ int(event.next_seq),
1370
+ int(event.end_seq) if event.has_end else None,
1371
+ bool(event.terminal),
1372
+ bool(event.retention_gap),
1373
+ )
1374
+ raise PlasmiteError(ErrorKind.INTERNAL, f"plasmite: unknown UDP event kind {event.kind}")
1375
+ finally:
1376
+ _LIB.plsm_udp_event_free(byref(event))
1377
+
1378
+
1379
+ def _parse_rfc3339_utc(raw_time: str) -> datetime:
1380
+ # Python 3.10 accepts only 3 or 6 fractional digits. datetime keeps
1381
+ # microseconds; the original nanosecond timestamp remains in Message.raw.
1382
+ raw_time = re.sub(r"\.(\d+)", lambda match: "." + match[1][:6].ljust(6, "0"), raw_time)
1383
+ parsed = datetime.fromisoformat(raw_time.replace("Z", "+00:00"))
1384
+ if parsed.tzinfo is None:
1385
+ raise ValueError("message time must include timezone")
1386
+ return parsed.astimezone(timezone.utc)
1387
+
1388
+
1389
+ def parse_message(payload: bytes) -> Message:
1390
+ raw = _ensure_bytes(payload, "payload")
1391
+ parsed = json.loads(raw.decode("utf-8"))
1392
+ if not isinstance(parsed, dict):
1393
+ raise ValueError("message payload must decode to an object")
1394
+ raw_time = parsed.get("time")
1395
+ if not isinstance(raw_time, str):
1396
+ raise ValueError("message time must be an RFC3339 string")
1397
+ meta = parsed.get("meta", {})
1398
+ tags = meta.get("tags") if isinstance(meta, dict) else None
1399
+ if not isinstance(tags, list):
1400
+ tags = []
1401
+ tag_list = [str(tag) for tag in tags]
1402
+ seq = parsed.get("seq")
1403
+ if not isinstance(seq, int):
1404
+ raise ValueError("message seq must be an int")
1405
+ return Message(
1406
+ seq=seq,
1407
+ time=_parse_rfc3339_utc(raw_time),
1408
+ time_rfc3339=raw_time,
1409
+ data=parsed.get("data"),
1410
+ meta=MessageMeta(tags=tag_list),
1411
+ raw=raw,
1412
+ )
1413
+
1414
+
1415
+ def _message_has_tags(message: Message, required_tags: list[str]) -> bool:
1416
+ if not required_tags:
1417
+ return True
1418
+ return all(tag in message.tags for tag in required_tags)
1419
+
1420
+
1421
+ __all__ = [
1422
+ "Client",
1423
+ "Pool",
1424
+ "Message",
1425
+ "MessageMeta",
1426
+ "Stream",
1427
+ "Lite3Frame",
1428
+ "Lite3Stream",
1429
+ "UdpClient",
1430
+ "UdpPool",
1431
+ "UdpFollow",
1432
+ "UdpStart",
1433
+ "UdpFollowOptions",
1434
+ "UdpFrame",
1435
+ "UdpGap",
1436
+ "UdpProgress",
1437
+ "Durability",
1438
+ "ErrorKind",
1439
+ "PlasmiteError",
1440
+ "NotFoundError",
1441
+ "AlreadyExistsError",
1442
+ "BusyError",
1443
+ "PermissionDeniedError",
1444
+ "CorruptError",
1445
+ "IoError",
1446
+ "UsageError",
1447
+ "InternalError",
1448
+ "RetentionGapError",
1449
+ "DEFAULT_POOL_DIR",
1450
+ "DEFAULT_POOL_SIZE",
1451
+ "DEFAULT_POOL_SIZE_BYTES",
1452
+ "default_pool_dir",
1453
+ "parse_message",
1454
+ ]