richrelay 1.0.2__py3-none-any.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.
richrelay/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ """richrelay - ship ``rich`` output, logging and exceptions to syslog and friends."""
2
+
3
+ from ._version import __version__
4
+ from .buffers import (
5
+ Buffer,
6
+ MemoryBuffer,
7
+ RabbitMQBuffer,
8
+ RedisBuffer,
9
+ RedisCache,
10
+ SpoolError,
11
+ SQLiteBuffer,
12
+ )
13
+ from .event import Event
14
+ from .formats import FORMATS, Formatter, make_formatter
15
+ from .integrations import (
16
+ RelayHandler,
17
+ RichRelayHandler,
18
+ event_from_record,
19
+ install_excepthook,
20
+ patch_rich,
21
+ )
22
+ from .relay import Destination, Relay, get_relay, setup, shutdown, tcp, tls, udp, unix
23
+ from .transports import TCPTransport, Transport, TransportError, UDPTransport, UnixTransport
24
+ from .urls import parse_buffer, parse_destination
25
+
26
+ __all__ = [
27
+ "__version__", "Event", "Relay", "Destination", "setup", "shutdown", "get_relay",
28
+ "udp", "tcp", "tls", "unix", "parse_destination", "parse_buffer",
29
+ "RelayHandler", "RichRelayHandler", "patch_rich", "install_excepthook", "event_from_record",
30
+ "Buffer", "MemoryBuffer", "SQLiteBuffer", "RedisBuffer", "RedisCache", "RabbitMQBuffer",
31
+ "SpoolError", "Formatter", "FORMATS", "make_formatter",
32
+ "Transport", "TransportError", "UDPTransport", "TCPTransport", "UnixTransport",
33
+ ] # fmt: skip
richrelay/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
richrelay/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.0.4"
@@ -0,0 +1,10 @@
1
+ from .base import Buffer, SpoolError
2
+ from .memory import MemoryBuffer
3
+ from .rabbitmq_buffer import RabbitMQBuffer
4
+ from .redis_buffer import RedisBuffer, RedisCache
5
+ from .sqlite import SQLiteBuffer
6
+
7
+ __all__ = [
8
+ "Buffer", "SpoolError", "MemoryBuffer", "SQLiteBuffer",
9
+ "RedisBuffer", "RedisCache", "RabbitMQBuffer",
10
+ ] # fmt: skip
@@ -0,0 +1,45 @@
1
+ """Buffer interface used for store-and-forward while a receiver is down."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List
6
+
7
+
8
+ class SpoolError(Exception):
9
+ """The buffer backend itself is unavailable (Redis down, broker down, disk error...)."""
10
+
11
+
12
+ class Buffer:
13
+ """FIFO of already-formatted payloads.
14
+
15
+ Contract (single consumer, used from one worker thread):
16
+ ``peek(n)`` returns up to ``n`` oldest payloads without removing them;
17
+ ``discard(n)`` removes the first ``n`` of the last peek.
18
+ """
19
+
20
+ overflow = "drop_oldest"
21
+
22
+ def __init__(self) -> None:
23
+ self.dropped = 0
24
+ self.known_len = 0 # cheap, possibly approximate size for stats
25
+
26
+ def push(self, payload: bytes) -> bool: # pragma: no cover - interface
27
+ raise NotImplementedError
28
+
29
+ def peek(self, n: int) -> List[bytes]: # pragma: no cover - interface
30
+ raise NotImplementedError
31
+
32
+ def discard(self, n: int) -> None: # pragma: no cover - interface
33
+ raise NotImplementedError
34
+
35
+ def __len__(self) -> int: # pragma: no cover - interface
36
+ raise NotImplementedError
37
+
38
+ def close(self) -> None:
39
+ pass
40
+
41
+
42
+ def check_overflow(value: str) -> str:
43
+ if value not in ("drop_oldest", "drop_newest"):
44
+ raise ValueError("overflow must be 'drop_oldest' or 'drop_newest'")
45
+ return value
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ from collections import deque
5
+ from typing import Deque, List, Optional
6
+
7
+ from .base import Buffer, check_overflow
8
+
9
+
10
+ class MemoryBuffer(Buffer):
11
+ """Bounded in-process buffer. Lost when the process exits."""
12
+
13
+ def __init__(self, max_items: int = 10_000, max_bytes: Optional[int] = None,
14
+ overflow: str = "drop_oldest") -> None:
15
+ super().__init__()
16
+ self.max_items, self.max_bytes = max_items, max_bytes
17
+ self.overflow = check_overflow(overflow)
18
+ self._d: Deque[bytes] = deque()
19
+ self._bytes = 0
20
+ self._lock = threading.Lock()
21
+
22
+ def _over(self) -> bool:
23
+ return len(self._d) > self.max_items or (
24
+ self.max_bytes is not None and self._bytes > self.max_bytes
25
+ )
26
+
27
+ def push(self, payload: bytes) -> bool:
28
+ with self._lock:
29
+ if self.max_bytes is not None and len(payload) > self.max_bytes:
30
+ self.dropped += 1
31
+ return False
32
+ if self.overflow == "drop_newest":
33
+ if len(self._d) >= self.max_items or (
34
+ self.max_bytes is not None and self._bytes + len(payload) > self.max_bytes
35
+ ):
36
+ self.dropped += 1
37
+ return False
38
+ self._d.append(payload)
39
+ self._bytes += len(payload)
40
+ while self._over() and self._d:
41
+ self._bytes -= len(self._d.popleft())
42
+ self.dropped += 1
43
+ self.known_len = len(self._d)
44
+ return True
45
+
46
+ def peek(self, n: int) -> List[bytes]:
47
+ with self._lock:
48
+ out = []
49
+ for i, item in enumerate(self._d):
50
+ if i >= n:
51
+ break
52
+ out.append(item)
53
+ return out
54
+
55
+ def discard(self, n: int) -> None:
56
+ with self._lock:
57
+ for _ in range(min(n, len(self._d))):
58
+ self._bytes -= len(self._d.popleft())
59
+ self.known_len = len(self._d)
60
+
61
+ def __len__(self) -> int:
62
+ return len(self._d)
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, List, Optional, Tuple
4
+
5
+ from .base import Buffer, SpoolError, check_overflow
6
+
7
+
8
+ class RabbitMQBuffer(Buffer):
9
+ """Durable store-and-forward queue in RabbitMQ (pika BlockingConnection).
10
+
11
+ The queue is declared durable with ``x-max-length``; messages are published persistent
12
+ and only acknowledged once the real receiver has taken them. If the process dies
13
+ mid-replay, unacknowledged messages are redelivered (at-least-once).
14
+
15
+ Note: with ``drop_oldest`` the *broker* discards the head, so ``dropped`` is not tracked.
16
+ Changing ``max_items``/``overflow`` for an existing queue requires deleting the queue
17
+ (RabbitMQ rejects redeclaration with different arguments).
18
+ """
19
+
20
+ def __init__(self, url: str = "amqp://guest:guest@localhost:5672/%2F", *,
21
+ queue: str = "richrelay.spool", max_items: int = 100_000,
22
+ overflow: str = "drop_oldest", durable: bool = True) -> None:
23
+ super().__init__()
24
+ self.url, self.queue, self.max_items, self.durable = url, queue, max_items, durable
25
+ self.overflow = check_overflow(overflow)
26
+ self._conn: Any = None
27
+ self._ch: Any = None
28
+ self._pending: List[Tuple[int, bytes]] = [] # (delivery_tag, body)
29
+
30
+ def _connect(self) -> None:
31
+ try:
32
+ import pika
33
+ except ImportError as exc: # pragma: no cover
34
+ raise ImportError("RabbitMQBuffer needs: pip install 'richrelay[rabbitmq]'") from exc
35
+ params = pika.URLParameters(self.url)
36
+ params.socket_timeout = 5
37
+ params.blocked_connection_timeout = 10
38
+ self._conn = pika.BlockingConnection(params)
39
+ self._ch = self._conn.channel()
40
+ self._ch.queue_declare(
41
+ queue=self.queue, durable=self.durable,
42
+ arguments={
43
+ "x-max-length": self.max_items,
44
+ "x-overflow": "drop-head" if self.overflow == "drop_oldest" else "reject-publish",
45
+ },
46
+ )
47
+ self._ch.confirm_delivery()
48
+
49
+ def _reset(self) -> None:
50
+ self._pending = [] # unacked messages return to the queue when the connection closes
51
+ try:
52
+ if self._conn is not None and self._conn.is_open:
53
+ self._conn.close()
54
+ except Exception:
55
+ pass
56
+ self._conn = self._ch = None
57
+
58
+ def _call(self, fn: Any) -> Any:
59
+ last: Optional[BaseException] = None
60
+ for _ in range(2): # one transparent reconnect (idle connections get closed)
61
+ try:
62
+ if self._conn is None or self._conn.is_closed:
63
+ self._pending = []
64
+ self._connect()
65
+ return fn(self._ch)
66
+ except SpoolError:
67
+ raise
68
+ except Exception as exc:
69
+ last = exc
70
+ self._reset()
71
+ raise SpoolError(f"rabbitmq {self.queue}: {last}") from last
72
+
73
+ def push(self, payload: bytes) -> bool:
74
+ import pika
75
+
76
+ def op(ch: Any) -> bool:
77
+ try:
78
+ ch.basic_publish(
79
+ exchange="", routing_key=self.queue, body=payload,
80
+ properties=pika.BasicProperties(delivery_mode=2 if self.durable else 1),
81
+ mandatory=False,
82
+ )
83
+ return True
84
+ except (pika.exceptions.NackError, pika.exceptions.UnroutableError):
85
+ self.dropped += 1 # reject-publish: queue full
86
+ return False
87
+
88
+ ok = self._call(op)
89
+ if ok:
90
+ self.known_len += 1
91
+ return bool(ok)
92
+
93
+ def peek(self, n: int) -> List[bytes]:
94
+ def op(ch: Any) -> List[bytes]:
95
+ while len(self._pending) < n:
96
+ method, _props, body = ch.basic_get(queue=self.queue, auto_ack=False)
97
+ if method is None:
98
+ break
99
+ self._pending.append((method.delivery_tag, bytes(body)))
100
+ return [b for _t, b in self._pending[:n]]
101
+
102
+ return self._call(op)
103
+
104
+ def discard(self, n: int) -> None:
105
+ if n <= 0 or not self._pending:
106
+ return
107
+
108
+ def op(ch: Any) -> None:
109
+ k = min(n, len(self._pending))
110
+ ch.basic_ack(delivery_tag=self._pending[k - 1][0], multiple=True)
111
+ self._pending = self._pending[k:]
112
+ self.known_len = max(0, self.known_len - k)
113
+
114
+ # no retry-by-reconnect here: a reconnect invalidates delivery tags
115
+ try:
116
+ if self._ch is None:
117
+ return
118
+ op(self._ch)
119
+ except Exception as exc:
120
+ self._reset()
121
+ raise SpoolError(f"rabbitmq {self.queue}: ack failed ({exc}); messages will be redelivered") from exc
122
+
123
+ def __len__(self) -> int:
124
+ def op(ch: Any) -> int:
125
+ return int(ch.queue_declare(queue=self.queue, passive=True).method.message_count)
126
+
127
+ try:
128
+ ready = self._call(op)
129
+ except SpoolError:
130
+ raise
131
+ self.known_len = ready + len(self._pending)
132
+ return self.known_len
133
+
134
+ def close(self) -> None:
135
+ self._reset()
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, List, Optional
4
+
5
+ from .base import Buffer, SpoolError, check_overflow
6
+
7
+
8
+ def _import_redis() -> Any:
9
+ try:
10
+ import redis
11
+ except ImportError as exc: # pragma: no cover
12
+ raise ImportError("RedisBuffer needs the extra: pip install 'richrelay[redis]'") from exc
13
+ return redis
14
+
15
+
16
+ class RedisBuffer(Buffer):
17
+ """Store-and-forward queue backed by a Redis list (RPUSH / LRANGE / LTRIM).
18
+
19
+ Bounded by ``max_items`` only (Redis lists have no byte limit).
20
+ """
21
+
22
+ def __init__(self, url: str = "redis://localhost:6379/0", *, key: str = "richrelay:spool",
23
+ max_items: int = 100_000, overflow: str = "drop_oldest",
24
+ client: Any = None, socket_timeout: float = 3.0) -> None:
25
+ super().__init__()
26
+ self.url, self.key, self.max_items = url, key, max_items
27
+ self.overflow = check_overflow(overflow)
28
+ self.socket_timeout = socket_timeout
29
+ self._client = client
30
+ self._owns_client = client is None # never close a client the caller injected
31
+
32
+ def _c(self) -> Any:
33
+ if self._client is None:
34
+ redis = _import_redis()
35
+ self._client = redis.Redis.from_url(
36
+ self.url, socket_timeout=self.socket_timeout,
37
+ socket_connect_timeout=self.socket_timeout,
38
+ )
39
+ return self._client
40
+
41
+ def _call(self, fn: Any) -> Any:
42
+ try:
43
+ return fn(self._c())
44
+ except SpoolError:
45
+ raise
46
+ except Exception as exc: # redis.RedisError, OSError
47
+ raise SpoolError(f"redis {self.key}: {exc}") from exc
48
+
49
+ def push(self, payload: bytes) -> bool:
50
+ if self.overflow == "drop_newest":
51
+ if self._call(lambda c: c.llen(self.key)) >= self.max_items:
52
+ self.dropped += 1
53
+ return False
54
+ self.known_len = self._call(lambda c: c.rpush(self.key, payload))
55
+ return True
56
+
57
+ def op(c: Any) -> int:
58
+ pipe = c.pipeline(transaction=True)
59
+ pipe.rpush(self.key, payload)
60
+ pipe.ltrim(self.key, -self.max_items, -1)
61
+ return int(pipe.execute()[0])
62
+
63
+ new_len = self._call(op)
64
+ if new_len > self.max_items:
65
+ self.dropped += new_len - self.max_items
66
+ new_len = self.max_items
67
+ self.known_len = new_len
68
+ return True
69
+
70
+ def peek(self, n: int) -> List[bytes]:
71
+ return [bytes(x) for x in self._call(lambda c: c.lrange(self.key, 0, n - 1))]
72
+
73
+ def discard(self, n: int) -> None:
74
+ if n > 0:
75
+ self._call(lambda c: c.ltrim(self.key, n, -1))
76
+ self.known_len = max(0, self.known_len - n)
77
+
78
+ def __len__(self) -> int:
79
+ self.known_len = int(self._call(lambda c: c.llen(self.key)))
80
+ return self.known_len
81
+
82
+ def close(self) -> None:
83
+ if not self._owns_client:
84
+ return
85
+ c, self._client = self._client, None
86
+ try:
87
+ if c is not None:
88
+ c.close()
89
+ except Exception:
90
+ pass
91
+
92
+
93
+ class RedisCache:
94
+ """Rolling cache of the most recent events (always written, delivery-independent).
95
+
96
+ Keeps the newest ``max_items`` payloads in a list, optionally expiring after ``ttl`` seconds.
97
+ Read them back with :meth:`recent`.
98
+ """
99
+
100
+ def __init__(self, url: str = "redis://localhost:6379/0", *, key: str = "richrelay:recent",
101
+ max_items: int = 1000, ttl: Optional[int] = None, format: str = "json",
102
+ client: Any = None, socket_timeout: float = 3.0) -> None:
103
+ self.url, self.key, self.max_items, self.ttl = url, key, max_items, ttl
104
+ self.format = format
105
+ self.socket_timeout = socket_timeout
106
+ self._client = client
107
+ self._owns_client = client is None
108
+
109
+ def _c(self) -> Any:
110
+ if self._client is None:
111
+ redis = _import_redis()
112
+ self._client = redis.Redis.from_url(
113
+ self.url, socket_timeout=self.socket_timeout,
114
+ socket_connect_timeout=self.socket_timeout,
115
+ )
116
+ return self._client
117
+
118
+ def put(self, payload: bytes) -> None:
119
+ pipe = self._c().pipeline(transaction=True)
120
+ pipe.lpush(self.key, payload)
121
+ pipe.ltrim(self.key, 0, self.max_items - 1)
122
+ if self.ttl:
123
+ pipe.expire(self.key, int(self.ttl))
124
+ pipe.execute()
125
+
126
+ def recent(self, n: int = 100) -> List[str]:
127
+ """Newest first."""
128
+ return [bytes(x).decode("utf-8", "replace") for x in self._c().lrange(self.key, 0, n - 1)]
129
+
130
+ def close(self) -> None:
131
+ if not self._owns_client:
132
+ return
133
+ c, self._client = self._client, None
134
+ try:
135
+ if c is not None:
136
+ c.close()
137
+ except Exception:
138
+ pass
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ import threading
5
+ from pathlib import Path
6
+ from typing import List, Optional, Union
7
+
8
+ from .base import Buffer, SpoolError, check_overflow
9
+
10
+
11
+ class SQLiteBuffer(Buffer):
12
+ """Durable on-disk spool using only the standard library.
13
+
14
+ Survives process restarts, bounded by ``max_items`` and ``max_bytes``.
15
+ Use one process per database file (counters are kept in memory).
16
+ """
17
+
18
+ def __init__(self, path: Optional[Union[str, Path]] = None, *, name: str = "default",
19
+ max_items: int = 100_000, max_bytes: Optional[int] = 64 * 1024 * 1024,
20
+ overflow: str = "drop_oldest") -> None:
21
+ super().__init__()
22
+ self.path = Path(path) if path else Path.home() / ".richrelay" / f"{name}.db"
23
+ self.max_items, self.max_bytes = max_items, max_bytes
24
+ self.overflow = check_overflow(overflow)
25
+ self._lock = threading.RLock()
26
+ self._peek_ids: List[int] = []
27
+ try:
28
+ self.path.parent.mkdir(parents=True, exist_ok=True)
29
+ self._db = sqlite3.connect(str(self.path), check_same_thread=False, timeout=10)
30
+ self._db.execute("PRAGMA journal_mode=WAL")
31
+ self._db.execute("PRAGMA synchronous=NORMAL")
32
+ self._db.execute(
33
+ "CREATE TABLE IF NOT EXISTS spool("
34
+ "id INTEGER PRIMARY KEY AUTOINCREMENT, payload BLOB NOT NULL)"
35
+ )
36
+ self._db.commit()
37
+ row = self._db.execute(
38
+ "SELECT COUNT(*), COALESCE(SUM(LENGTH(payload)), 0) FROM spool"
39
+ ).fetchone()
40
+ except (sqlite3.Error, OSError) as exc:
41
+ raise SpoolError(f"sqlite spool {self.path}: {exc}") from exc
42
+ self._count, self._bytes = int(row[0]), int(row[1])
43
+ self.known_len = self._count
44
+
45
+ def _over(self) -> bool:
46
+ return self._count > self.max_items or (
47
+ self.max_bytes is not None and self._bytes > self.max_bytes
48
+ )
49
+
50
+ def push(self, payload: bytes) -> bool:
51
+ size = len(payload)
52
+ with self._lock:
53
+ try:
54
+ if self.max_bytes is not None and size > self.max_bytes:
55
+ self.dropped += 1
56
+ return False
57
+ if self.overflow == "drop_newest" and (
58
+ self._count >= self.max_items
59
+ or (self.max_bytes is not None and self._bytes + size > self.max_bytes)
60
+ ):
61
+ self.dropped += 1
62
+ return False
63
+ self._db.execute("INSERT INTO spool(payload) VALUES (?)", (payload,))
64
+ self._count += 1
65
+ self._bytes += size
66
+ while self._over():
67
+ rows = self._db.execute(
68
+ "SELECT id, LENGTH(payload) FROM spool ORDER BY id LIMIT 64"
69
+ ).fetchall()
70
+ last = None
71
+ for rid, ln in rows:
72
+ if not self._over():
73
+ break
74
+ last = rid
75
+ self._count -= 1
76
+ self._bytes -= ln
77
+ self.dropped += 1
78
+ if last is None:
79
+ break
80
+ self._db.execute("DELETE FROM spool WHERE id <= ?", (last,))
81
+ self._db.commit()
82
+ except sqlite3.Error as exc:
83
+ raise SpoolError(f"sqlite spool {self.path}: {exc}") from exc
84
+ self.known_len = self._count
85
+ return True
86
+
87
+ def peek(self, n: int) -> List[bytes]:
88
+ with self._lock:
89
+ try:
90
+ rows = self._db.execute(
91
+ "SELECT id, payload FROM spool ORDER BY id LIMIT ?", (n,)
92
+ ).fetchall()
93
+ except sqlite3.Error as exc:
94
+ raise SpoolError(f"sqlite spool {self.path}: {exc}") from exc
95
+ self._peek_ids = [r[0] for r in rows]
96
+ return [bytes(r[1]) for r in rows]
97
+
98
+ def discard(self, n: int) -> None:
99
+ with self._lock:
100
+ ids = self._peek_ids[:n]
101
+ if not ids:
102
+ return
103
+ last = ids[-1]
104
+ try:
105
+ cnt, size = self._db.execute(
106
+ "SELECT COUNT(*), COALESCE(SUM(LENGTH(payload)), 0) FROM spool WHERE id <= ?",
107
+ (last,),
108
+ ).fetchone()
109
+ self._db.execute("DELETE FROM spool WHERE id <= ?", (last,))
110
+ self._db.commit()
111
+ except sqlite3.Error as exc:
112
+ raise SpoolError(f"sqlite spool {self.path}: {exc}") from exc
113
+ self._count -= int(cnt)
114
+ self._bytes -= int(size)
115
+ self._peek_ids = self._peek_ids[n:]
116
+ self.known_len = self._count
117
+
118
+ def __len__(self) -> int:
119
+ return self._count
120
+
121
+ def close(self) -> None:
122
+ with self._lock:
123
+ try:
124
+ self._db.close()
125
+ except sqlite3.Error:
126
+ pass