starfish-queuing 3.0.0a5__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.
@@ -0,0 +1,42 @@
1
+ """``starfish-queuing`` — change-event queuing extension.
2
+
3
+ Public surface: the ``AbstractQueue`` transport base, the in-process
4
+ ``MemoryQueue`` and callback ``CustomQueue`` backends, the ``QueueMessage``
5
+ shape, the per-collection ``QueueConfig``, and ``create_queuing_server_plugin``
6
+ — a ``ServerPlugin`` whose ``after_write`` hook publishes a message after each
7
+ successful push.
8
+
9
+ ``NatsQueue``/``NatsQueueOptions`` live in ``starfish_queuing.nats`` (require the
10
+ ``nats`` optional extra) and are imported on demand.
11
+ """
12
+
13
+ from starfish_queuing.base import AbstractQueue
14
+ from starfish_queuing.memory import MemoryQueue, CustomQueue
15
+ from starfish_queuing.message import QueueMessage
16
+ from starfish_queuing.config import QueueConfig
17
+
18
+
19
+ def __getattr__(name: str):
20
+ """Lazy import of ``create_queuing_server_plugin`` (keeps the
21
+ ``starfish_protocol.plugins`` import off the hot path for backend-only
22
+ users) and of ``NatsQueue``/``NatsQueueOptions`` (keeps ``nats-py`` an
23
+ optional dependency)."""
24
+ if name == "create_queuing_server_plugin":
25
+ from starfish_queuing.plugin import create_queuing_server_plugin as _p
26
+ return _p
27
+ if name in ("NatsQueue", "NatsQueueOptions"):
28
+ from starfish_queuing import nats as _nats
29
+ return getattr(_nats, name)
30
+ raise AttributeError(f"module 'starfish_queuing' has no attribute {name!r}")
31
+
32
+
33
+ __all__ = [
34
+ "AbstractQueue",
35
+ "MemoryQueue",
36
+ "CustomQueue",
37
+ "QueueMessage",
38
+ "QueueConfig",
39
+ "create_queuing_server_plugin",
40
+ "NatsQueue",
41
+ "NatsQueueOptions",
42
+ ]
@@ -0,0 +1,28 @@
1
+ """Abstract base class for queue backends."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+
6
+ class AbstractQueue(ABC):
7
+ """Publish-only queue interface for emitting data-change events.
8
+
9
+ Implementations must provide :meth:`publish`. The :meth:`connect` and
10
+ :meth:`close` lifecycle hooks have default no-ops — override them only
11
+ when the backend requires explicit connection management (e.g. NATS).
12
+ """
13
+
14
+ async def connect(self) -> None:
15
+ """Establish a connection to the queue backend.
16
+
17
+ Override for network-backed implementations. The default is a no-op.
18
+ """
19
+
20
+ @abstractmethod
21
+ async def publish(self, subject: str, payload: bytes) -> None:
22
+ raise NotImplementedError("publish must be implemented")
23
+
24
+ async def close(self) -> None:
25
+ """Tear down the connection. Safe to call multiple times.
26
+
27
+ Override for network-backed implementations. The default is a no-op.
28
+ """
@@ -0,0 +1,24 @@
1
+ """Per-collection queue configuration (owned by the queuing plugin)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass
9
+ class QueueConfig:
10
+ """Per-collection queue publishing configuration.
11
+
12
+ Apps pass a ``{collection_name: QueueConfig}`` map to
13
+ :func:`create_queuing_server_plugin`. Collections absent from that map
14
+ publish nothing.
15
+ """
16
+
17
+ topic: str | None = None
18
+ """Subject/topic to publish to. Defaults to the collection name."""
19
+
20
+ include_params: bool = False
21
+ """Include the resolved route path parameters in the published message."""
22
+
23
+ include_body: bool = False
24
+ """Include the pushed ``data`` object in the message (JSON collections only)."""
@@ -0,0 +1,51 @@
1
+ """In-memory and callback-based queue implementations."""
2
+
3
+ import inspect
4
+ from typing import Any, Awaitable, Callable
5
+
6
+ from starfish_queuing.base import AbstractQueue
7
+
8
+
9
+ class MemoryQueue(AbstractQueue):
10
+ """In-memory queue that records published messages for testing.
11
+
12
+ Every call to :meth:`publish` appends a ``(subject, payload)`` tuple
13
+ to :attr:`messages`::
14
+
15
+ queue = MemoryQueue()
16
+ await queue.publish("posts", b'{"collection":"posts"}')
17
+ assert len(queue.messages) == 1
18
+ """
19
+
20
+ def __init__(self) -> None:
21
+ self.messages: list[tuple[str, bytes]] = []
22
+
23
+ async def publish(self, subject: str, payload: bytes) -> None:
24
+ self.messages.append((subject, payload))
25
+
26
+
27
+ async def _call(fn: Callable[..., Any], *args: Any) -> Any:
28
+ """Invoke *fn* with *args*, awaiting the result if it is a coroutine."""
29
+ result = fn(*args)
30
+ if inspect.isawaitable(result):
31
+ return await result
32
+ return result
33
+
34
+
35
+ PublishFn = Callable[[str, bytes], None | Awaitable[None]]
36
+
37
+
38
+ class CustomQueue(AbstractQueue):
39
+ """Queue backed by a user-supplied callback function.
40
+
41
+ The callback may be synchronous or ``async``::
42
+
43
+ queue = CustomQueue(on_publish=lambda subject, payload: print(subject))
44
+ """
45
+
46
+ def __init__(self, *, on_publish: PublishFn | None = None) -> None:
47
+ self._on_publish = on_publish
48
+
49
+ async def publish(self, subject: str, payload: bytes) -> None:
50
+ if self._on_publish is not None:
51
+ await _call(self._on_publish, subject, payload)
@@ -0,0 +1,25 @@
1
+ """Typed shape of the message published to the queue after a successful push."""
2
+
3
+ from typing import Any, Required, TypedDict
4
+
5
+
6
+ class QueueMessage(TypedDict, total=False):
7
+ """Message published to the queue after a successful document push.
8
+
9
+ Required fields (always present):
10
+ collection: Collection name.
11
+ hash: SHA-256 hex hash of the stored document.
12
+ timestamp: Milliseconds since epoch when the push completed.
13
+
14
+ Optional fields (present only when the corresponding QueueConfig flag is set):
15
+ params: Resolved URL path parameters — present when ``include_params=True``.
16
+ body: Push request data field — present when ``include_body=True``
17
+ (JSON collections only). Contains the document as sent by the client,
18
+ before server-side sanitization.
19
+ """
20
+
21
+ collection: Required[str]
22
+ hash: Required[str]
23
+ timestamp: Required[int]
24
+ params: dict[str, str]
25
+ body: dict[str, Any]
@@ -0,0 +1,66 @@
1
+ """NATS queue implementation."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from dataclasses import dataclass
6
+
7
+ import nats
8
+ from nats.aio.client import Client as NatsClient
9
+
10
+ from starfish_queuing.base import AbstractQueue
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ @dataclass
16
+ class NatsQueueOptions:
17
+ """Connection options for :class:`NatsQueue`."""
18
+
19
+ servers: str | list[str] = "nats://localhost:4222"
20
+ """NATS server URL(s)."""
21
+
22
+ name: str | None = None
23
+ """Optional client name (visible in NATS monitoring)."""
24
+
25
+
26
+ class NatsQueue(AbstractQueue):
27
+ """Publish messages to a NATS server.
28
+
29
+ Connects lazily on the first :meth:`publish` call if :meth:`connect` has
30
+ not been called explicitly. Use the explicit lifecycle for best control::
31
+
32
+ queue = NatsQueue(NatsQueueOptions(servers="nats://localhost:4222"))
33
+ await queue.connect()
34
+ # … use queue …
35
+ await queue.close()
36
+ """
37
+
38
+ def __init__(self, opts: NatsQueueOptions | None = None) -> None:
39
+ self._opts = opts or NatsQueueOptions()
40
+ self._nc: NatsClient | None = None
41
+ self._lock = asyncio.Lock()
42
+
43
+ async def connect(self) -> None:
44
+ async with self._lock:
45
+ if self._nc is not None and self._nc.is_connected:
46
+ return
47
+ self._nc = await nats.connect(
48
+ servers=self._opts.servers,
49
+ name=self._opts.name,
50
+ )
51
+
52
+ async def _ensure_connected(self) -> NatsClient:
53
+ if self._nc is None or not self._nc.is_connected:
54
+ await self.connect()
55
+ assert self._nc is not None # noqa: S101
56
+ return self._nc
57
+
58
+ async def publish(self, subject: str, payload: bytes) -> None:
59
+ nc = await self._ensure_connected()
60
+ await nc.publish(subject, payload)
61
+
62
+ async def close(self) -> None:
63
+ async with self._lock:
64
+ if self._nc is not None and self._nc.is_connected:
65
+ await self._nc.close()
66
+ self._nc = None
@@ -0,0 +1,49 @@
1
+ """Server plugin for the queuing extension (Python mirror).
2
+
3
+ Implements the ``after_write`` write-path hook from the ``ServerPlugin``
4
+ contract: after a successful push the server hands the plugin a
5
+ :class:`WriteEvent`; for any collection present in the plugin's ``collections``
6
+ map it builds a :class:`QueueMessage` and publishes it to the configured
7
+ :class:`AbstractQueue`. ``shutdown`` closes the queue during graceful shutdown.
8
+
9
+ The ``ServerPlugin``/``WriteEvent`` types live in ``starfish-protocol`` (the
10
+ shared contract layer), so this package needs no dependency on
11
+ ``starfish-server`` — applications wire both packages at the top level.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Mapping
17
+
18
+ from starfish_protocol.plugins import ServerPlugin, WriteEvent
19
+
20
+ from starfish_queuing.base import AbstractQueue
21
+ from starfish_queuing.config import QueueConfig
22
+ from starfish_queuing.publish import publish_change_event
23
+
24
+
25
+ def create_queuing_server_plugin(
26
+ *,
27
+ queue: AbstractQueue,
28
+ collections: Mapping[str, QueueConfig],
29
+ ) -> ServerPlugin:
30
+ """Build a :class:`ServerPlugin` that publishes a change event to *queue*
31
+ after every successful push to a configured collection."""
32
+
33
+ async def _after_write(event: WriteEvent) -> None:
34
+ cfg = collections.get(event.collection)
35
+ if cfg is None:
36
+ return
37
+ await publish_change_event(queue, cfg, event)
38
+
39
+ async def _shutdown() -> None:
40
+ await queue.close()
41
+
42
+ return ServerPlugin(
43
+ name="starfish-queuing",
44
+ after_write=_after_write,
45
+ shutdown=_shutdown,
46
+ )
47
+
48
+
49
+ __all__ = ["create_queuing_server_plugin"]
@@ -0,0 +1,49 @@
1
+ """Build and publish a queue message from a :class:`WriteEvent`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+
8
+ from starfish_protocol.plugins import WriteEvent
9
+
10
+ from starfish_queuing.base import AbstractQueue
11
+ from starfish_queuing.config import QueueConfig
12
+ from starfish_queuing.message import QueueMessage
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ async def publish_change_event(
18
+ queue: AbstractQueue,
19
+ config: QueueConfig,
20
+ event: WriteEvent,
21
+ ) -> None:
22
+ """Publish a change event for *event* using *config*.
23
+
24
+ Errors are logged but never propagate — a queue outage must not break
25
+ client writes.
26
+ """
27
+ try:
28
+ subject = config.topic or event.collection
29
+ msg: QueueMessage = {
30
+ "collection": event.collection,
31
+ "hash": event.hash,
32
+ "timestamp": event.timestamp,
33
+ }
34
+ if config.include_params and event.params:
35
+ msg["params"] = dict(event.params)
36
+ if config.include_body:
37
+ if event.body is not None:
38
+ msg["body"] = dict(event.body)
39
+ else:
40
+ logger.warning(
41
+ "include_body enabled for %s but request data is not a plain "
42
+ "object; body omitted from queue message",
43
+ event.collection,
44
+ )
45
+ await queue.publish(subject, json.dumps(msg).encode())
46
+ except Exception:
47
+ logger.warning(
48
+ "Failed to publish queue event for %s", event.collection, exc_info=True,
49
+ )
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: starfish-queuing
3
+ Version: 3.0.0a5
4
+ Summary: Starfish change-event queuing extension (post-push publish hook, MemoryQueue/CustomQueue/NatsQueue backends, per-collection config)
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: starfish-protocol
7
+ Provides-Extra: nats
8
+ Requires-Dist: nats-py>=2.0; extra == "nats"
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=7.0; extra == "dev"
11
+ Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
12
+ Requires-Dist: respx>=0.23.1; extra == "dev"
13
+ Requires-Dist: nats-py>=2.0; extra == "dev"
14
+ Requires-Dist: starfish-server; extra == "dev"
@@ -0,0 +1,12 @@
1
+ starfish_queuing/__init__.py,sha256=QbSdMhPenO3wrQdw44uLaWGTxT0wOlent6R8_JgKDe4,1560
2
+ starfish_queuing/base.py,sha256=ZdZnh7vrD0uEr3yFeORZAjZ8FuN0oiUZWjsFXndZ8uo,947
3
+ starfish_queuing/config.py,sha256=oPvrzfJUo5veew-9EoUVhcWG4wSdVxVfqxpOgjAsA7A,724
4
+ starfish_queuing/memory.py,sha256=YaR1vuzTCEHw5lILEwQ_H8KcucaiyngT6K9p0tLsicg,1536
5
+ starfish_queuing/message.py,sha256=HZxqB2miIeOfmEDmm3KI_NSK00oX6BhpuWnjYzhjygQ,980
6
+ starfish_queuing/nats.py,sha256=laGwcFChtnXp3n2xtodOLH87IDXfsZ7aMMBjypaQEqo,1985
7
+ starfish_queuing/plugin.py,sha256=QsRod4uwzsAq7IrWq3L8INppj1DNIN2AI2oWFWRfsbg,1643
8
+ starfish_queuing/publish.py,sha256=aI8gfC85oWnNmnXxYgd37ebBCM2Cuk_WAYZHOcnapR0,1538
9
+ starfish_queuing-3.0.0a5.dist-info/METADATA,sha256=NwzUk6SqRs2HnOaW99B8Pq5QO1TIS8-WBCynqVMObCg,577
10
+ starfish_queuing-3.0.0a5.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ starfish_queuing-3.0.0a5.dist-info/top_level.txt,sha256=ZSiigH0fBtHQ0DzxOy8SUtWeMy0cHCACbmmGwrcunSM,17
12
+ starfish_queuing-3.0.0a5.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ starfish_queuing