serverish 2.0.5__tar.gz → 2.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 (64) hide show
  1. {serverish-2.0.5 → serverish-2.1.0}/PKG-INFO +1 -1
  2. {serverish-2.0.5 → serverish-2.1.0}/pyproject.toml +1 -1
  3. {serverish-2.0.5 → serverish-2.1.0}/serverish/base/exceptions.py +23 -0
  4. {serverish-2.0.5 → serverish-2.1.0}/serverish/connection/connection_jets.py +28 -0
  5. {serverish-2.0.5 → serverish-2.1.0}/serverish/messenger/__init__.py +4 -0
  6. {serverish-2.0.5 → serverish-2.1.0}/serverish/messenger/messenger.py +52 -0
  7. serverish-2.1.0/serverish/messenger/msg_kv.py +111 -0
  8. serverish-2.1.0/serverish/messenger/msg_kv_read.py +78 -0
  9. serverish-2.1.0/serverish/messenger/msg_kv_store.py +141 -0
  10. serverish-2.1.0/serverish/messenger/msg_kv_sub.py +111 -0
  11. {serverish-2.0.5 → serverish-2.1.0}/LICENSE +0 -0
  12. {serverish-2.0.5 → serverish-2.1.0}/README.md +0 -0
  13. {serverish-2.0.5 → serverish-2.1.0}/serverish/__init__.py +0 -0
  14. {serverish-2.0.5 → serverish-2.1.0}/serverish/base/__init__.py +0 -0
  15. {serverish-2.0.5 → serverish-2.1.0}/serverish/base/asyncio_util_functions.py +0 -0
  16. {serverish-2.0.5 → serverish-2.1.0}/serverish/base/collector.py +0 -0
  17. {serverish-2.0.5 → serverish-2.1.0}/serverish/base/datetime.py +0 -0
  18. {serverish-2.0.5 → serverish-2.1.0}/serverish/base/fifoset.py +0 -0
  19. {serverish-2.0.5 → serverish-2.1.0}/serverish/base/hasstatuses.py +0 -0
  20. {serverish-2.0.5 → serverish-2.1.0}/serverish/base/idmanger.py +0 -0
  21. {serverish-2.0.5 → serverish-2.1.0}/serverish/base/iterators.py +0 -0
  22. {serverish-2.0.5 → serverish-2.1.0}/serverish/base/manageable.py +0 -0
  23. {serverish-2.0.5 → serverish-2.1.0}/serverish/base/singleton.py +0 -0
  24. {serverish-2.0.5 → serverish-2.1.0}/serverish/base/status.py +0 -0
  25. {serverish-2.0.5 → serverish-2.1.0}/serverish/base/task_manager.py +0 -0
  26. {serverish-2.0.5 → serverish-2.1.0}/serverish/connection/__init__.py +0 -0
  27. {serverish-2.0.5 → serverish-2.1.0}/serverish/connection/connection.py +0 -0
  28. {serverish-2.0.5 → serverish-2.1.0}/serverish/connection/connection_nats.py +0 -0
  29. {serverish-2.0.5 → serverish-2.1.0}/serverish/messenger/live_document.py +0 -0
  30. {serverish-2.0.5 → serverish-2.1.0}/serverish/messenger/logging.py +0 -0
  31. {serverish-2.0.5 → serverish-2.1.0}/serverish/messenger/msg_callback_sub.py +0 -0
  32. {serverish-2.0.5 → serverish-2.1.0}/serverish/messenger/msg_cmd_pub.py +0 -0
  33. {serverish-2.0.5 → serverish-2.1.0}/serverish/messenger/msg_cmd_sub.py +0 -0
  34. {serverish-2.0.5 → serverish-2.1.0}/serverish/messenger/msg_core_pub.py +0 -0
  35. {serverish-2.0.5 → serverish-2.1.0}/serverish/messenger/msg_core_read.py +0 -0
  36. {serverish-2.0.5 → serverish-2.1.0}/serverish/messenger/msg_core_sub.py +0 -0
  37. {serverish-2.0.5 → serverish-2.1.0}/serverish/messenger/msg_document_read.py +0 -0
  38. {serverish-2.0.5 → serverish-2.1.0}/serverish/messenger/msg_journal_pub.py +0 -0
  39. {serverish-2.0.5 → serverish-2.1.0}/serverish/messenger/msg_journal_read.py +0 -0
  40. {serverish-2.0.5 → serverish-2.1.0}/serverish/messenger/msg_progress_pub.py +0 -0
  41. {serverish-2.0.5 → serverish-2.1.0}/serverish/messenger/msg_progress_read.py +0 -0
  42. {serverish-2.0.5 → serverish-2.1.0}/serverish/messenger/msg_publisher.py +0 -0
  43. {serverish-2.0.5 → serverish-2.1.0}/serverish/messenger/msg_reader.py +0 -0
  44. {serverish-2.0.5 → serverish-2.1.0}/serverish/messenger/msg_rpc_req.py +0 -0
  45. {serverish-2.0.5 → serverish-2.1.0}/serverish/messenger/msg_rpc_resp.py +0 -0
  46. {serverish-2.0.5 → serverish-2.1.0}/serverish/messenger/msg_single_pub.py +0 -0
  47. {serverish-2.0.5 → serverish-2.1.0}/serverish/messenger/msg_single_read.py +0 -0
  48. {serverish-2.0.5 → serverish-2.1.0}/serverish/messenger/msgvalidator.py +0 -0
  49. {serverish-2.0.5 → serverish-2.1.0}/serverish/monitoring/__init__.py +0 -0
  50. {serverish-2.0.5 → serverish-2.1.0}/serverish/monitoring/bridge.py +0 -0
  51. {serverish-2.0.5 → serverish-2.1.0}/serverish/monitoring/factory.py +0 -0
  52. {serverish-2.0.5 → serverish-2.1.0}/serverish/monitoring/monitored.py +0 -0
  53. {serverish-2.0.5 → serverish-2.1.0}/serverish/monitoring/monitored_nats.py +0 -0
  54. {serverish-2.0.5 → serverish-2.1.0}/serverish/schema/config.schema.json +0 -0
  55. {serverish-2.0.5 → serverish-2.1.0}/serverish/schema/default.schema.json +0 -0
  56. {serverish-2.0.5 → serverish-2.1.0}/serverish/schema/include/timestamp.schema.json +0 -0
  57. {serverish-2.0.5 → serverish-2.1.0}/serverish/schema/journal.schema.json +0 -0
  58. {serverish-2.0.5 → serverish-2.1.0}/serverish/schema/meta.schema.json +0 -0
  59. {serverish-2.0.5 → serverish-2.1.0}/serverish/schema/program_current_state.schema.json +0 -0
  60. {serverish-2.0.5 → serverish-2.1.0}/serverish/schema/program_state.schema.json +0 -0
  61. {serverish-2.0.5 → serverish-2.1.0}/serverish/schema/progress.schema.json +0 -0
  62. {serverish-2.0.5 → serverish-2.1.0}/serverish/schema/telemetry.schema.json +0 -0
  63. {serverish-2.0.5 → serverish-2.1.0}/serverish/services/TODO.txt +0 -0
  64. {serverish-2.0.5 → serverish-2.1.0}/serverish/slog.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: serverish
3
- Version: 2.0.5
3
+ Version: 2.1.0
4
4
  Summary: helpers for server alike projects
5
5
  License-File: LICENSE
6
6
  Author: Mikołaj Kałuszyński
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "serverish"
3
- version = "2.0.5"
3
+ version = "2.1.0"
4
4
  description = "helpers for server alike projects"
5
5
  authors = ["Mikołaj Kałuszyński", "MMME team"]
6
6
  readme = "README.md"
@@ -38,4 +38,27 @@ class MessengerRequestTimeout(MessengerRequestNoResponse, TimeoutError):
38
38
  pass
39
39
 
40
40
  class MessengerRequestNoResultYet(MessengerRequestNoResponse):
41
+ pass
42
+
43
+
44
+ class MessengerKvError(Exception):
45
+ pass
46
+
47
+
48
+ class MessengerKvBucketNotFound(MessengerKvError):
49
+ """Raised when a KV bucket does not exist and the driver was not allowed to create it."""
50
+ pass
51
+
52
+
53
+ class MessengerKvKeyNotFound(MessengerKvError, KeyError):
54
+ pass
55
+
56
+
57
+ class MessengerKvMalformed(MessengerKvError):
58
+ """Raised when a KV entry does not carry a serverish envelope.
59
+
60
+ Serverish stores full ``{"data": ..., "meta": ...}`` envelopes in KV buckets.
61
+ An entry written by a non-serverish client fails loudly instead of being
62
+ silently returned as empty.
63
+ """
41
64
  pass
@@ -5,6 +5,7 @@ from typing import Mapping, Any, Iterable
5
5
  import re
6
6
 
7
7
  import nats
8
+ import nats.js.errors
8
9
  from nats.js import JetStreamContext
9
10
  import param
10
11
 
@@ -147,6 +148,33 @@ class ConnectionJetStream(ConnectionNATS):
147
148
  cfg.subjects.append(subject)
148
149
  await js.update_stream(config=cfg)
149
150
 
151
+ async def ensure_kv_bucket(self, bucket: str, create_if_needed: bool = False, **config):
152
+ """Binds to a JetStream KV bucket, optionally creating it.
153
+
154
+ Like streams (see `ensure_subject_in_stream`), buckets are not created on the fly
155
+ by default, because usually one has to control the bucket parameters.
156
+
157
+ Args:
158
+ bucket (str): KV bucket name
159
+ create_if_needed (bool): create the bucket if it does not exist
160
+ config: `nats.js.api.KeyValueConfig` parameters (e.g. history, ttl, max_bytes)
161
+ used only when the bucket is being created
162
+
163
+ Returns:
164
+ nats.js.kv.KeyValue: bound KV bucket handle
165
+
166
+ Raises:
167
+ nats.js.errors.BucketNotFoundError: bucket does not exist and create_if_needed is False
168
+ """
169
+ js: JetStreamContext = self.js
170
+ try:
171
+ return await js.key_value(bucket)
172
+ except nats.js.errors.BucketNotFoundError:
173
+ if not create_if_needed:
174
+ raise
175
+ logger.info(f"Creating KV bucket {bucket}")
176
+ return await js.create_key_value(bucket=bucket, **config)
177
+
150
178
 
151
179
  async def diagnose_stream_config(self) -> StatusReport:
152
180
  """Diagnoses stream configuration
@@ -19,3 +19,7 @@ from .msg_core_read import MsgCoreReader, get_corereader
19
19
  from .msg_core_sub import MsgCoreSub, get_coresubscriber
20
20
  from .msg_cmd_pub import MsgCommandPublisher, get_commandpublisher
21
21
  from .msg_cmd_sub import MsgCommandSubscriber, get_commandsubscriber
22
+ from .msg_kv import MsgKvDriver
23
+ from .msg_kv_store import MsgKvStore, get_kvstore, kv_get, kv_put
24
+ from .msg_kv_read import MsgKvReader, get_kvreader
25
+ from .msg_kv_sub import MsgKvSubscriber, get_kvsubscriber
@@ -627,6 +627,58 @@ class Messenger(Singleton):
627
627
  from serverish.messenger.msg_cmd_sub import MsgCommandSubscriber
628
628
  return MsgCommandSubscriber(subject=subject, parent=Messenger())
629
629
 
630
+ @staticmethod
631
+ def get_kvstore(bucket: str, **kwargs) -> 'MsgKvStore':
632
+ """Returns a key-value store for a given JetStream KV bucket
633
+
634
+ Values are full serverish envelopes, validated like published messages.
635
+
636
+ Args:
637
+ bucket (str): KV bucket name
638
+ kwargs: additional driver arguments (e.g. create_bucket, bucket_config)
639
+
640
+ Returns:
641
+ MsgKvStore: a key-value store for the given bucket
642
+ """
643
+ from serverish.messenger.msg_kv_store import MsgKvStore
644
+ return MsgKvStore(bucket=bucket, parent=Messenger(), **kwargs)
645
+
646
+ @staticmethod
647
+ def get_kvreader(bucket: str, key: str = '>', **kwargs) -> 'MsgKvReader':
648
+ """Returns a KV reader (async iterator over key changes) for a given bucket
649
+
650
+ Args:
651
+ bucket (str): KV bucket name
652
+ key (str): key or wildcard pattern to watch
653
+ kwargs: additional driver arguments (e.g. include_history, ignore_deletes)
654
+
655
+ Returns:
656
+ MsgKvReader: a KV reader for the given bucket
657
+
658
+ Usage:
659
+ reader = Messenger.get_kvreader('bucket', key='telescope.>')
660
+ await reader.open()
661
+ async for data, meta in reader:
662
+ print(meta['kv']['key'], data)
663
+ """
664
+ from serverish.messenger.msg_kv_read import MsgKvReader
665
+ return MsgKvReader(bucket=bucket, key=key, parent=Messenger(), **kwargs)
666
+
667
+ @staticmethod
668
+ def get_kvsubscriber(bucket: str, key: str = '>', **kwargs) -> 'MsgKvSubscriber':
669
+ """Returns a callback-based KV subscriber for a given bucket
670
+
671
+ Args:
672
+ bucket (str): KV bucket name
673
+ key (str): key or wildcard pattern to watch
674
+ kwargs: additional driver arguments (e.g. include_history, ignore_deletes)
675
+
676
+ Returns:
677
+ MsgKvSubscriber: a callback-based KV subscriber for the given bucket
678
+ """
679
+ from serverish.messenger.msg_kv_sub import MsgKvSubscriber
680
+ return MsgKvSubscriber(bucket=bucket, key=key, parent=Messenger(), **kwargs)
681
+
630
682
 
631
683
  class MsgDriver(Manageable):
632
684
  subject: str = param.String(default=None, allow_None=True, doc="User subject to publish to, prefix may be added")
@@ -0,0 +1,111 @@
1
+ """Key-value bucket support for Messenger.
2
+
3
+ Provides `MsgKvDriver`, the common base for all KV drivers (`MsgKvStore`,
4
+ `MsgKvReader`, `MsgKvSubscriber`). NATS JetStream KV buckets are the backend;
5
+ values are always full serverish envelopes ``{"data": ..., "meta": ...}``,
6
+ so schema validation and message metadata work exactly as for stream messages.
7
+
8
+ Delete and purge markers are the only entries without an envelope - the KV
9
+ protocol stores them as empty tombstones. Drivers surface them as
10
+ ``(None, meta)`` with ``meta['kv']['operation']`` set to ``'DEL'`` or ``'PURGE'``.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+
16
+ import jsonschema
17
+ import nats.js.errors
18
+ import param
19
+ from nats.js.kv import KeyValue
20
+
21
+ from serverish.base import MessengerKvBucketNotFound, MessengerKvMalformed, dt_ensure_array
22
+ from serverish.messenger.messenger import MsgDriver
23
+
24
+ log = logging.getLogger(__name__.rsplit('.')[-1])
25
+
26
+
27
+ class MsgKvDriver(MsgDriver):
28
+ """Base for KV bucket operators
29
+
30
+ KV drivers operate on a bucket (and keys within it) instead of a subject.
31
+ The inherited `subject` is set to the underlying ``$KV.<bucket>`` prefix
32
+ for diagnostics only.
33
+
34
+ Args:
35
+ bucket (str): KV bucket name
36
+ create_bucket (bool): create the bucket on open() if it does not exist
37
+ bucket_config (dict): `nats.js.api.KeyValueConfig` parameters (e.g. history, ttl)
38
+ used only when the bucket is being created
39
+ """
40
+ bucket = param.String(default=None, allow_None=True, doc="KV bucket name")
41
+ create_bucket = param.Boolean(default=False, doc="Create the bucket on open() if it does not exist")
42
+ bucket_config = param.Dict(default={}, doc="KeyValueConfig parameters used when creating the bucket")
43
+
44
+ def __init__(self, **kwargs) -> None:
45
+ self.kv: KeyValue | None = None
46
+ super().__init__(**kwargs)
47
+ if self.subject is None and self.bucket:
48
+ self.subject = f'$KV.{self.bucket}' # diagnostic only, KV entries are addressed by bucket/key
49
+
50
+ async def open(self) -> None:
51
+ try:
52
+ self.kv = await self.connection.ensure_kv_bucket(self.bucket,
53
+ create_if_needed=self.create_bucket,
54
+ **self.bucket_config)
55
+ except nats.js.errors.BucketNotFoundError as e:
56
+ raise MessengerKvBucketNotFound(
57
+ f"KV bucket '{self.bucket}' does not exist, "
58
+ f"create it externally or open the driver with create_bucket=True") from e
59
+ await super().open()
60
+
61
+ async def close(self) -> None:
62
+ self.kv = None
63
+ await super().close()
64
+
65
+ def encode_envelope(self, data: dict | None, meta: dict | None) -> tuple[dict, bytes]:
66
+ """Creates and validates a serverish envelope, returns (message, encoded bytes)"""
67
+ msg = self.messenger.create_msg(data, meta)
68
+ try:
69
+ self.messenger.msg_validate(msg)
70
+ except jsonschema.ValidationError as e:
71
+ log.error(f"Message {msg['meta']['id']} validation error: {e}")
72
+ raise e
73
+ return msg, self.messenger.encode(msg)
74
+
75
+ def decode_entry(self, entry: KeyValue.Entry) -> tuple[dict | None, dict]:
76
+ """Decodes a KV entry into (data, meta)
77
+
78
+ Delete/purge markers carry no envelope: data is None and meta contains
79
+ only the 'kv' section.
80
+
81
+ Raises:
82
+ MessengerKvMalformed: entry value is not a serverish envelope
83
+ """
84
+ operation = entry.operation or 'PUT'
85
+ kv_meta = {
86
+ 'bucket': entry.bucket,
87
+ 'key': entry.key,
88
+ 'revision': entry.revision,
89
+ 'operation': operation,
90
+ }
91
+ if entry.created is not None:
92
+ kv_meta['created'] = dt_ensure_array(entry.created)
93
+ if operation != 'PUT':
94
+ return None, {'kv': kv_meta}
95
+ try:
96
+ msg = self.messenger.decode(entry.value)
97
+ except (ValueError, TypeError) as e:
98
+ raise MessengerKvMalformed(
99
+ f"Entry '{entry.key}'@{entry.revision} in KV bucket '{entry.bucket}' is not "
100
+ f"a serverish envelope, was it written by a non-serverish client?") from e
101
+ if not isinstance(msg, dict) or 'meta' not in msg:
102
+ raise MessengerKvMalformed(
103
+ f"Entry '{entry.key}'@{entry.revision} in KV bucket '{entry.bucket}' carries "
104
+ f"no meta, was it written by a non-serverish client?")
105
+ data = msg.get('data', {})
106
+ meta = msg['meta']
107
+ meta['kv'] = kv_meta
108
+ return data, meta
109
+
110
+ def __str__(self):
111
+ return f'{self.name} [{self.bucket}]'
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ import param
4
+ from nats.js.kv import KeyValue
5
+
6
+ from serverish.base import MessengerReaderStopped
7
+ from serverish.messenger.messenger import Messenger
8
+ from serverish.messenger.msg_kv import MsgKvDriver, log
9
+
10
+
11
+ class MsgKvReader(MsgKvDriver):
12
+ """An async-iterator over changes of KV keys
13
+
14
+ Iteration yields (data, meta) pairs like `MsgReader`; the key, revision and
15
+ operation are available in `meta['kv']`. On open, current values of matching
16
+ keys are delivered first, then live updates as they happen.
17
+
18
+ Delete/purge markers are yielded as (None, meta) with
19
+ meta['kv']['operation'] set to 'DEL'/'PURGE' (unless ignore_deletes is set).
20
+
21
+ Usage:
22
+ reader = get_kvreader('bucket', key='telescope.>')
23
+ await reader.open()
24
+ async for data, meta in reader:
25
+ print(meta['kv']['key'], data)
26
+ """
27
+ key = param.String(default='>', doc="Key or wildcard pattern to watch")
28
+ include_history = param.Boolean(default=False, doc="Deliver historical revisions of the keys on open")
29
+ ignore_deletes = param.Boolean(default=False, doc="Skip delete/purge markers")
30
+
31
+ def __init__(self, **kwargs) -> None:
32
+ self._watcher: KeyValue.KeyWatcher | None = None
33
+ super().__init__(**kwargs)
34
+
35
+ async def open(self) -> None:
36
+ await super().open()
37
+ self._watcher = await self.kv.watch(self.key,
38
+ include_history=self.include_history,
39
+ ignore_deletes=self.ignore_deletes)
40
+
41
+ async def close(self) -> None:
42
+ if self._watcher is not None:
43
+ try:
44
+ await self._watcher.stop()
45
+ except Exception as e:
46
+ log.debug(f"Error stopping KV watcher for {self}: {e}")
47
+ self._watcher = None
48
+ await super().close()
49
+
50
+ def __aiter__(self):
51
+ return self
52
+
53
+ async def __anext__(self) -> tuple[dict | None, dict]:
54
+ if self._watcher is None:
55
+ raise MessengerReaderStopped(f"KV reader {self} is not open")
56
+ while True:
57
+ entry = await self._watcher.__anext__() # raises StopAsyncIteration when watcher is stopped
58
+ if entry is None:
59
+ # nats-py sends a None marker once the initial replay is done, it is not a value
60
+ continue
61
+ return self.decode_entry(entry)
62
+
63
+ def __str__(self):
64
+ return f'{self.name} [{self.bucket}/{self.key}]'
65
+
66
+
67
+ def get_kvreader(bucket: str, key: str = '>', **kwargs) -> MsgKvReader:
68
+ """Returns a KV reader for a given bucket and key pattern
69
+
70
+ Args:
71
+ bucket (str): KV bucket name
72
+ key (str): key or wildcard pattern to watch
73
+ kwargs: additional driver arguments (e.g. include_history, ignore_deletes)
74
+
75
+ Returns:
76
+ MsgKvReader: a KV reader for the given bucket
77
+ """
78
+ return Messenger.get_kvreader(bucket, key=key, **kwargs)
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ import nats.js.errors
4
+ from nats.js.kv import KeyValue
5
+
6
+ from serverish.base import MessengerKvKeyNotFound
7
+ from serverish.messenger.messenger import Messenger, MsgDriver
8
+ from serverish.messenger.msg_kv import MsgKvDriver, log
9
+
10
+
11
+ class MsgKvStore(MsgKvDriver):
12
+ """A key-value store over a NATS JetStream KV bucket
13
+
14
+ Values are full serverish envelopes, so `get` returns the usual
15
+ (data, meta) pair and `put` validates the message against schemas.
16
+
17
+ All operations are decorated with `ensure_open`, so the store can be used
18
+ one-shot without explicit open/close, like other messenger drivers.
19
+ """
20
+
21
+ @MsgDriver.ensure_open
22
+ async def put(self, key: str, data: dict | None = None, meta: dict | None = None) -> dict:
23
+ """Stores a value for the key, returns the published message
24
+
25
+ Args:
26
+ key (str): key to store the value under
27
+ data (dict): message data
28
+ meta (dict): message metadata
29
+
30
+ Returns:
31
+ dict: published message, with meta['kv'] carrying bucket/key/revision
32
+ """
33
+ msg, bdata = self.encode_envelope(data, meta)
34
+ revision = await self.kv.put(key, bdata)
35
+ msg['meta']['kv'] = {'bucket': self.bucket, 'key': key, 'revision': revision, 'operation': 'PUT'}
36
+ self.messenger.log_msg_trace(msg.get('data', {}), msg['meta'], f"KV PUT {self.bucket}/{key}")
37
+ return msg
38
+
39
+ @MsgDriver.ensure_open
40
+ async def get(self, key: str, revision: int | None = None) -> tuple[dict, dict]:
41
+ """Returns (data, meta) of the latest (or given revision) value for the key
42
+
43
+ Args:
44
+ key (str): key to read
45
+ revision (int): specific revision to read, latest if None
46
+
47
+ Raises:
48
+ MessengerKvKeyNotFound: key does not exist (or is deleted)
49
+ MessengerKvMalformed: entry is not a serverish envelope
50
+ """
51
+ try:
52
+ entry = await self.kv.get(key, revision=revision)
53
+ except nats.js.errors.KeyNotFoundError as e:
54
+ raise MessengerKvKeyNotFound(f"Key '{key}' not found in KV bucket '{self.bucket}'") from e
55
+ data, meta = self.decode_entry(entry)
56
+ self.messenger.log_msg_trace(data, meta, f"KV GET {self.bucket}/{key}")
57
+ return data, meta
58
+
59
+ @MsgDriver.ensure_open
60
+ async def delete(self, key: str) -> None:
61
+ """Places a delete marker for the key and removes previous revisions"""
62
+ await self.kv.delete(key)
63
+ log.debug(f"KV DEL {self.bucket}/{key}")
64
+
65
+ @MsgDriver.ensure_open
66
+ async def purge(self, key: str) -> None:
67
+ """Removes the key including all its revisions"""
68
+ await self.kv.purge(key)
69
+ log.debug(f"KV PURGE {self.bucket}/{key}")
70
+
71
+ @MsgDriver.ensure_open
72
+ async def keys(self) -> list[str]:
73
+ """Returns list of keys in the bucket, empty list for an empty bucket"""
74
+ try:
75
+ return await self.kv.keys()
76
+ except nats.js.errors.NoKeysError:
77
+ return []
78
+
79
+ @MsgDriver.ensure_open
80
+ async def history(self, key: str) -> list[tuple[dict | None, dict]]:
81
+ """Returns the revision history of the key, oldest first
82
+
83
+ Delete/purge markers are returned as (None, meta) entries.
84
+ Note: the bucket must be created with history > 1 to keep past revisions.
85
+
86
+ Raises:
87
+ MessengerKvKeyNotFound: key has no history in the bucket
88
+ """
89
+ try:
90
+ entries = await self.kv.history(key)
91
+ except nats.js.errors.NoKeysError as e:
92
+ raise MessengerKvKeyNotFound(f"Key '{key}' has no history in KV bucket '{self.bucket}'") from e
93
+ return [self.decode_entry(entry) for entry in entries]
94
+
95
+ @MsgDriver.ensure_open
96
+ async def status(self) -> KeyValue.BucketStatus:
97
+ """Returns the status of the underlying KV bucket"""
98
+ return await self.kv.status()
99
+
100
+
101
+ def get_kvstore(bucket: str, **kwargs) -> MsgKvStore:
102
+ """Returns a key-value store for a given KV bucket
103
+
104
+ Args:
105
+ bucket (str): KV bucket name
106
+ kwargs: additional driver arguments (e.g. create_bucket, bucket_config)
107
+
108
+ Returns:
109
+ MsgKvStore: a key-value store for the given bucket
110
+ """
111
+ return Messenger.get_kvstore(bucket, **kwargs)
112
+
113
+
114
+ async def kv_put(bucket: str, key: str, data: dict | None = None, meta: dict | None = None, **kwargs) -> dict:
115
+ """Stores a single value in a KV bucket (one-shot)
116
+
117
+ Args:
118
+ bucket (str): KV bucket name
119
+ key (str): key to store the value under
120
+ data (dict): message data
121
+ meta (dict): message metadata
122
+ kwargs: additional driver arguments (e.g. create_bucket)
123
+
124
+ Returns:
125
+ dict: published message
126
+ """
127
+ return await get_kvstore(bucket, **kwargs).put(key, data, meta)
128
+
129
+
130
+ async def kv_get(bucket: str, key: str, **kwargs) -> tuple[dict, dict]:
131
+ """Reads a single value from a KV bucket (one-shot)
132
+
133
+ Args:
134
+ bucket (str): KV bucket name
135
+ key (str): key to read
136
+ kwargs: additional driver arguments
137
+
138
+ Returns:
139
+ tuple[dict, dict]: (data, meta) of the stored message
140
+ """
141
+ return await get_kvstore(bucket, **kwargs).get(key)
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from asyncio import CancelledError, Event
5
+ from typing import Callable
6
+
7
+ import param
8
+
9
+ from serverish.base import Task, create_task
10
+ from serverish.messenger.messenger import Messenger
11
+ from serverish.messenger.msg_kv_read import MsgKvReader
12
+ from serverish.messenger.msg_kv import log
13
+
14
+
15
+ class MsgKvSubscriber(MsgKvReader):
16
+ """A class for watching KV keys and calling a callback function on each change
17
+
18
+ This class works like `MsgKvReader`, but allows to specify a callback function
19
+ for each change instead of iterating.
20
+
21
+ The callback is called with (data, meta); data is None for delete/purge markers
22
+ (check meta['kv']['operation']). Note that current values of matching keys are
23
+ delivered on subscribe, before live updates.
24
+
25
+ Usage:
26
+ def callback(data, meta):
27
+ print(meta['kv']['key'], data)
28
+
29
+ sub = get_kvsubscriber('bucket', key='telescope.>')
30
+ await sub.open()
31
+ await sub.subscribe(callback)
32
+ """
33
+ callback = param.Callable(default=None, doc="Callback function to call on each change")
34
+ task = param.ClassSelector(default=None, class_=Task, doc="Task for watching changes")
35
+
36
+ def __init__(self, **kwargs) -> None:
37
+ self._stop_event = Event()
38
+ super().__init__(**kwargs)
39
+
40
+ async def close(self) -> None:
41
+ await self.stop()
42
+ if self.task is not None:
43
+ self.task.cancel()
44
+ return await super().close()
45
+
46
+ async def stop(self) -> None:
47
+ """Stops watching changes"""
48
+ self._stop_event.set()
49
+
50
+ async def subscribe(self, callback: Callable[[dict | None, dict], bool] |
51
+ Callable[[dict | None, dict], asyncio.Future]) -> Task:
52
+ """Sets a callback function for each change of watched keys
53
+
54
+ Args:
55
+ callback: a callback function to call on each change, may be asynchronous
56
+ callback is called with two arguments: data dict (None for delete/purge
57
+ markers) and metadata dict, and may return False to stop watching.
58
+ Any other return value (including None — i.e. callbacks with no explicit
59
+ return — and True) keeps the subscription running.
60
+ """
61
+ self.callback = callback
62
+ if asyncio.iscoroutinefunction(callback):
63
+ self.task = await create_task(self._task_body(acb=callback), f'KVASUB.{self.bucket}.{self.key}')
64
+ else:
65
+ self.task = await create_task(self._task_body(scb=callback), f'KVSSUB.{self.bucket}.{self.key}')
66
+ return self.task
67
+
68
+ async def _task_body(self,
69
+ scb: Callable[[dict | None, dict], bool] | None = None,
70
+ acb: Callable[[dict | None, dict], asyncio.Future] | None = None
71
+ ) -> None:
72
+
73
+ assert scb is not None or acb is not None
74
+ assert not (scb is not None and acb is not None)
75
+ cb = scb or acb
76
+ cont: object = True
77
+ log.debug(f"Entering KV watch iteration {self}")
78
+ async for data, meta in self:
79
+ try:
80
+ if scb is not None:
81
+ log.debug(f"Calling sync callback {cb} for KV change {meta}{str(data):20}")
82
+ cont = scb(data, meta)
83
+ else:
84
+ log.debug(f"Calling async callback {cb} for KV change {meta}{str(data):20}")
85
+ cont = await acb(data, meta)
86
+ except CancelledError:
87
+ log.debug(f'Cancelled {self}')
88
+ break
89
+ except Exception as e:
90
+ log.exception(f'Error in callback {cb} for KV change {meta}{str(data):20}: {e}')
91
+ # Stop only on explicit False — None (the implicit "no return" value
92
+ # of a Python function) and any truthy value keep the subscription
93
+ # alive. Treating None as "stop" would silently kill any callback
94
+ # that doesn't bother returning anything, which is the common case.
95
+ if cont is False or self._stop_event.is_set():
96
+ break
97
+ log.debug(f"Exiting KV watch iteration {self}")
98
+
99
+
100
+ def get_kvsubscriber(bucket: str, key: str = '>', **kwargs) -> MsgKvSubscriber:
101
+ """Returns a callback-based KV subscriber for a given bucket and key pattern
102
+
103
+ Args:
104
+ bucket (str): KV bucket name
105
+ key (str): key or wildcard pattern to watch
106
+ kwargs: additional driver arguments (e.g. include_history, ignore_deletes)
107
+
108
+ Returns:
109
+ MsgKvSubscriber: a callback-based KV subscriber for the given bucket
110
+ """
111
+ return Messenger.get_kvsubscriber(bucket, key=key, **kwargs)
File without changes
File without changes
File without changes