binomic 0.1.0__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.
binomic/__init__.py ADDED
File without changes
@@ -0,0 +1,13 @@
1
+ from .exceptions import (
2
+ BinomicError,
3
+ DeserializationError,
4
+ SerializationError,
5
+ )
6
+ from .schemas import BaseStruct
7
+
8
+ __all__ = (
9
+ "BaseStruct",
10
+ "BinomicError",
11
+ "DeserializationError",
12
+ "SerializationError",
13
+ )
@@ -0,0 +1,3 @@
1
+ from typing import Final
2
+
3
+ APP_NAME: Final[str] = "binomic"
@@ -0,0 +1,17 @@
1
+ __all__ = (
2
+ "BinomicError",
3
+ "DeserializationError",
4
+ "SerializationError",
5
+ )
6
+
7
+
8
+ class BinomicError(Exception):
9
+ """Base class for binomic exceptions."""
10
+
11
+
12
+ class SerializationError(BinomicError):
13
+ """Exception raised for errors during serialization."""
14
+
15
+
16
+ class DeserializationError(BinomicError):
17
+ """Exception raised for errors during deserialization."""
@@ -0,0 +1,39 @@
1
+ from typing import Self
2
+
3
+ from msgspec import Struct, json
4
+
5
+ from .exceptions import DeserializationError, SerializationError
6
+
7
+ __all__ = ("BaseStruct",)
8
+
9
+
10
+ class BaseStruct(Struct):
11
+ """Base data structure."""
12
+
13
+ def to_json(self) -> str:
14
+ """Convert the data structure to a JSON string."""
15
+
16
+ try:
17
+ return json.encode(self).decode("utf-8")
18
+
19
+ except Exception as exc:
20
+ raise SerializationError(
21
+ f"Failed to serialize data structure: {exc}",
22
+ ) from exc
23
+
24
+ @classmethod
25
+ def from_json(cls, json_str: str) -> Self:
26
+ """Create a data structure from a JSON string."""
27
+
28
+ try:
29
+ return json.decode(json_str, type=cls)
30
+
31
+ except Exception as exc:
32
+ raise DeserializationError(
33
+ f"Failed to deserialize data structure: {exc}",
34
+ ) from exc
35
+
36
+ def __repr__(self) -> str:
37
+ """Return a string representation of the data structure."""
38
+
39
+ return self.to_json()
@@ -0,0 +1,11 @@
1
+ from .handlers import BrokerFactory
2
+ from .protocols import Broker
3
+ from .redis import AsyncredisBroker
4
+ from .types import Entry
5
+
6
+ __all__ = (
7
+ "AsyncredisBroker",
8
+ "Broker",
9
+ "BrokerFactory",
10
+ "Entry",
11
+ )
@@ -0,0 +1,33 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from .redis import AsyncredisBroker
4
+
5
+ if TYPE_CHECKING:
6
+ from .protocols import Broker
7
+
8
+ __all__ = ("BrokerFactory",)
9
+
10
+
11
+ class BrokerFactory:
12
+ """A factory class for creating broker instances."""
13
+
14
+ def __init__(self, dsn: str, queues: list[str]) -> None:
15
+
16
+ self._dsn = dsn
17
+ self._queues = queues
18
+
19
+ def __call__(self) -> "Broker":
20
+
21
+ match self._dsn.split(":")[0]:
22
+ case "redis":
23
+ return AsyncredisBroker(
24
+ dsn=self._dsn,
25
+ queues=self._queues,
26
+ decode_responses=True,
27
+ )
28
+
29
+ case "amqp":
30
+ raise NotImplementedError("AMQP is not supported yet.")
31
+
32
+ case _:
33
+ raise ValueError("Unsupported broker type.")
@@ -0,0 +1,27 @@
1
+ from typing import TYPE_CHECKING, Protocol
2
+
3
+ if TYPE_CHECKING:
4
+ from uuid import UUID
5
+
6
+ from binomic.message import Message
7
+
8
+ from .types import Entry
9
+
10
+
11
+ __all__ = ("Broker",)
12
+
13
+
14
+ class Broker(Protocol):
15
+ """Broker protocol class."""
16
+
17
+ async def initialize(self) -> None: ...
18
+
19
+ async def enqueue(self, msg: "Message") -> "UUID": ...
20
+
21
+ async def acquire(self, consumer: str, *, count: int) -> list["Entry"]: ...
22
+
23
+ async def ack(self, entry: "Entry") -> int: ...
24
+
25
+ async def reclaim(self, consumer: str, *, min_idle_ms: int, count: int) -> int: ...
26
+
27
+ async def aclose(self) -> None: ...
@@ -0,0 +1,165 @@
1
+ from collections.abc import Sequence
2
+ from typing import TYPE_CHECKING, Any, Final, cast
3
+
4
+ from redis.asyncio import BlockingConnectionPool
5
+ from redis.asyncio import Redis as AsyncRedis
6
+ from redis.exceptions import ResponseError
7
+
8
+ from binomic.base.constants import APP_NAME
9
+ from binomic.message import Message
10
+
11
+ from .protocols import Broker
12
+ from .types import Entry
13
+
14
+ if TYPE_CHECKING:
15
+ from uuid import UUID
16
+
17
+ from redis.asyncio import ConnectionPool
18
+
19
+ from .types import Fields
20
+
21
+
22
+ __all__ = ("AsyncredisBroker",)
23
+
24
+
25
+ GROUP_NAMESPACE: Final[str] = APP_NAME
26
+
27
+
28
+ class AsyncredisBroker(Broker):
29
+ """Broker based on async redis."""
30
+
31
+ def __init__(
32
+ self,
33
+ dsn: str,
34
+ queues: Sequence[str],
35
+ **config: Any,
36
+ ) -> None:
37
+
38
+ self._dsn = dsn
39
+ self._queues = queues
40
+ self._config = config
41
+ self._group = GROUP_NAMESPACE
42
+
43
+ self._connpool: ConnectionPool | None = None
44
+
45
+ def get_stream_key(self, queue: str) -> str:
46
+ """Namespace a queue name into its Redis stream key."""
47
+
48
+ return f"{self._group}:{queue}"
49
+
50
+ @property
51
+ def client(self) -> "AsyncRedis":
52
+
53
+ if self._connpool is None:
54
+ self._connpool = BlockingConnectionPool.from_url(
55
+ url=self._dsn,
56
+ **self._config,
57
+ )
58
+
59
+ return AsyncRedis.from_pool(self._connpool)
60
+
61
+ async def initialize(self) -> None:
62
+
63
+ for queue in self._queues:
64
+ try:
65
+ await self.client.xgroup_create(
66
+ self.get_stream_key(queue),
67
+ groupname=self._group,
68
+ id="0",
69
+ mkstream=True,
70
+ )
71
+
72
+ except ResponseError as err:
73
+ if "BUSYGROUP" not in str(err):
74
+ raise
75
+
76
+ async def enqueue(self, msg: "Message") -> "UUID":
77
+
78
+ await self.client.xadd(
79
+ name=self.get_stream_key(msg.queue),
80
+ fields={
81
+ "id": str(msg.id),
82
+ "message": msg.to_json(),
83
+ },
84
+ )
85
+
86
+ return msg.id
87
+
88
+ async def acquire(self, consumer: str, *, count: int) -> list["Entry"]:
89
+
90
+ result = await self.client.xreadgroup(
91
+ groupname=self._group,
92
+ consumername=consumer,
93
+ streams={self.get_stream_key(q): ">" for q in self._queues},
94
+ count=count,
95
+ )
96
+
97
+ entries: list[Entry] = []
98
+ if not result:
99
+ return entries
100
+
101
+ queue_by_key = {self.get_stream_key(q): q for q in self._queues}
102
+
103
+ for key, msgs in cast(
104
+ "list[tuple[str, list[tuple[str, Fields]]]]",
105
+ result.items() if isinstance(result, dict) else result,
106
+ ):
107
+ queue = queue_by_key[key]
108
+ for msg_id, fields in msgs:
109
+ entries.append(Entry(queue, msg_id, fields))
110
+
111
+ return entries
112
+
113
+ async def ack(self, entry: "Entry") -> int:
114
+
115
+ queue, msg_id, _ = entry
116
+ return await self.client.xack(
117
+ self.get_stream_key(queue),
118
+ self._group,
119
+ msg_id,
120
+ )
121
+
122
+ async def reclaim(
123
+ self,
124
+ consumer: str,
125
+ *,
126
+ min_idle_ms: int,
127
+ count: int,
128
+ ) -> int:
129
+
130
+ reclaimed = 0
131
+ for queue in self._queues:
132
+ try:
133
+ result = await self.client.xautoclaim(
134
+ name=self.get_stream_key(queue),
135
+ groupname=self._group,
136
+ consumername=consumer,
137
+ min_idle_time=min_idle_ms,
138
+ start_id="0-0",
139
+ count=count,
140
+ )
141
+
142
+ except ResponseError as err:
143
+ if "NOGROUP" in str(err):
144
+ continue
145
+
146
+ raise
147
+
148
+ msgs = result[1] if result else []
149
+ for msg_id, fields in cast(
150
+ "list[tuple[str, Fields]]",
151
+ msgs,
152
+ ):
153
+ msg = Message.from_json(fields.get("message"))
154
+ await self.enqueue(msg)
155
+ await self.ack(Entry(queue, msg_id, fields))
156
+
157
+ reclaimed += 1
158
+
159
+ return reclaimed
160
+
161
+ async def aclose(self) -> None:
162
+
163
+ if self._connpool is not None:
164
+ await self._connpool.disconnect()
165
+ self._connpool = None
@@ -0,0 +1,21 @@
1
+ from typing import NamedTuple, TypedDict
2
+
3
+ __all__ = (
4
+ "Entry",
5
+ "Fields",
6
+ )
7
+
8
+
9
+ class Fields(TypedDict):
10
+ """Fields in the Entry."""
11
+
12
+ id: str
13
+ message: str
14
+
15
+
16
+ class Entry(NamedTuple):
17
+ """Entry in the broker."""
18
+
19
+ queue: str
20
+ msg_id: str
21
+ fields: Fields
binomic/client.py ADDED
@@ -0,0 +1,114 @@
1
+ from collections.abc import AsyncGenerator
2
+ from contextlib import asynccontextmanager
3
+ from typing import TYPE_CHECKING, Self
4
+
5
+ import anyio
6
+ from anyio import AsyncContextManagerMixin
7
+
8
+ from binomic.broker import Broker, BrokerFactory
9
+ from binomic.worker import Master, MasterPolicy, WorkerPolicy
10
+
11
+ if TYPE_CHECKING:
12
+ from binomic.config import BinomicConfig
13
+ from binomic.message import Message
14
+
15
+
16
+ __all__ = (
17
+ "Binomic",
18
+ "BinomicFactory",
19
+ )
20
+
21
+
22
+ class Binomic(AsyncContextManagerMixin):
23
+ """Binomic working client.
24
+
25
+ Entering the async context starts the master process group and keeps
26
+ it running until the context exits.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ broker_dsn: str,
32
+ redis_dsn: str,
33
+ module_name: str,
34
+ config: "BinomicConfig",
35
+ ) -> None:
36
+
37
+ self._broker_dsn = broker_dsn
38
+ self._redis_dsn = redis_dsn
39
+ self._module_name = module_name
40
+ self._config = config
41
+
42
+ self._broker: Broker | None = None
43
+
44
+ async def submit(self, msg: "Message") -> None:
45
+ """Enqueue a message to the broker stream.
46
+
47
+ The broker is created lazily on the first submit, so constructing
48
+ the client performs no IO.
49
+ """
50
+
51
+ if self._broker is None:
52
+ self._broker = BrokerFactory(
53
+ self._broker_dsn,
54
+ self._config.queues,
55
+ )()
56
+
57
+ await self._broker.enqueue(msg)
58
+
59
+ @asynccontextmanager
60
+ async def __asynccontextmanager__(self) -> AsyncGenerator[Self, None]:
61
+
62
+ master = Master(
63
+ broker_dsn=self._broker_dsn,
64
+ redis_dsn=self._redis_dsn,
65
+ module_name=self._module_name,
66
+ policy=MasterPolicy(
67
+ workers=self._config.workers,
68
+ worker=WorkerPolicy(
69
+ queues=self._config.queues,
70
+ concurrency=self._config.concurrency,
71
+ ),
72
+ ),
73
+ )
74
+
75
+ try:
76
+ async with anyio.create_task_group() as tg:
77
+ tg.start_soon(master.arun)
78
+ yield self
79
+ tg.cancel_scope.cancel()
80
+
81
+ finally:
82
+ await master.aclose()
83
+
84
+
85
+ class BinomicFactory:
86
+ """Binomic working client factory."""
87
+
88
+ def __init__(
89
+ self,
90
+ broker_dsn: str,
91
+ redis_dsn: str,
92
+ module_name: str,
93
+ config: "BinomicConfig",
94
+ ) -> None:
95
+
96
+ self._broker_dsn = broker_dsn
97
+ self._redis_dsn = redis_dsn
98
+ self._module_name = module_name
99
+ self._config = config
100
+
101
+ self._binomic: Binomic | None = None
102
+
103
+ def create(self) -> "Binomic":
104
+ """Return the client, creating it on the first call."""
105
+
106
+ if self._binomic is None:
107
+ self._binomic = Binomic(
108
+ broker_dsn=self._broker_dsn,
109
+ redis_dsn=self._redis_dsn,
110
+ module_name=self._module_name,
111
+ config=self._config,
112
+ )
113
+
114
+ return self._binomic
binomic/config.py ADDED
@@ -0,0 +1,16 @@
1
+ from binomic.base import BaseStruct
2
+
3
+ __all__ = ("BinomicConfig",)
4
+
5
+
6
+ class BinomicConfig(BaseStruct):
7
+ """Binomic configuration.
8
+
9
+ ``workers`` is the number of worker subprocesses the master spawns and
10
+ ``concurrency`` the number of tasks each worker executes concurrently;
11
+ every stream in ``queues`` is consumed by all workers.
12
+ """
13
+
14
+ queues: list[str]
15
+ workers: int = 1
16
+ concurrency: int = 5
@@ -0,0 +1,3 @@
1
+ from .schemas import Message
2
+
3
+ __all__ = ("Message",)
@@ -0,0 +1,54 @@
1
+ from typing import Any
2
+ from uuid import UUID
3
+
4
+ from msgspec import field, json
5
+ from uuid_utils.compat import uuid7
6
+
7
+ from binomic.base import BaseStruct
8
+
9
+ __all__ = ("Message",)
10
+
11
+
12
+ class Message(BaseStruct):
13
+ """Message for binomic.
14
+
15
+ ``name`` is the registered task name, ``queue`` the target stream and
16
+ ``enqueued_at`` the submission timestamp; ``args`` and ``kwargs`` carry
17
+ the task call arguments.
18
+ """
19
+
20
+ name: str
21
+ queue: str
22
+ enqueued_at: float
23
+
24
+ id: "UUID" = field(default_factory=uuid7)
25
+ args: list[Any] = field(default_factory=list)
26
+ kwargs: dict[str, Any] = field(default_factory=dict)
27
+
28
+ def to_json(self) -> str:
29
+
30
+ return json.encode(
31
+ {
32
+ "id": str(self.id),
33
+ "name": self.name,
34
+ "queue": self.queue,
35
+ "enqueued_at": self.enqueued_at,
36
+ "args": self.args,
37
+ "kwargs": self.kwargs,
38
+ },
39
+ order="sorted",
40
+ ).decode("utf-8")
41
+
42
+ @classmethod
43
+ def from_json(cls, json_str: str) -> "Message":
44
+
45
+ data = json.decode(json_str)
46
+
47
+ return cls(
48
+ id=UUID(data["id"]),
49
+ name=data["name"],
50
+ queue=data["queue"],
51
+ enqueued_at=data["enqueued_at"],
52
+ args=data["args"],
53
+ kwargs=data["kwargs"],
54
+ )
File without changes
@@ -0,0 +1,100 @@
1
+ from typing import TYPE_CHECKING, ClassVar, cast
2
+
3
+ from binomic.client import Binomic, BinomicFactory
4
+
5
+ if TYPE_CHECKING:
6
+ from binomic.config import BinomicConfig
7
+
8
+ try:
9
+ import litestar # noqa: F401
10
+ from litestar.di import Provide
11
+ from litestar.plugins import InitPluginProtocol
12
+
13
+ if TYPE_CHECKING:
14
+ from litestar.config.app import AppConfig
15
+ from litestar.datastructures import State
16
+
17
+ except ImportError:
18
+ raise ImportError( # noqa: B904
19
+ "litestar is not installed. Please install it using pip install litestar."
20
+ )
21
+
22
+
23
+ __all__ = ("BinomicPlugin",)
24
+
25
+
26
+ class BinomicPlugin(InitPluginProtocol):
27
+ """Binomic plugin for litestar."""
28
+
29
+ _binomic_factory_state_key: ClassVar[str] = "binomic_factory"
30
+
31
+ def __init__(
32
+ self,
33
+ app_name: str,
34
+ broker_dsn: str,
35
+ redis_dsn: str,
36
+ config: "BinomicConfig",
37
+ ) -> None:
38
+
39
+ self._app_name = app_name
40
+ self._broker_dsn = broker_dsn
41
+ self._redis_dsn = redis_dsn
42
+ self._config = config
43
+
44
+ def on_app_init(self, app_config: "AppConfig") -> "AppConfig":
45
+
46
+ self.setup_signature_namespaces(app_config)
47
+ self.setup_states(app_config)
48
+ self.setup_dependencies(app_config)
49
+
50
+ return app_config
51
+
52
+ def provide_binomic(self, state: "State") -> "Binomic":
53
+
54
+ if (
55
+ factory := cast(
56
+ "BinomicFactory | None",
57
+ state.get(self._binomic_factory_state_key),
58
+ )
59
+ ) is None:
60
+ raise RuntimeError(
61
+ "BinomicFactory is not provided. "
62
+ "Please ensure that the BinomicPlugin is properly configured."
63
+ )
64
+
65
+ return factory.create()
66
+
67
+ def setup_signature_namespaces(self, app_config: "AppConfig") -> None:
68
+
69
+ app_config.signature_namespace.update(
70
+ {
71
+ "Binomic": Binomic,
72
+ "BinomicFactory": BinomicFactory,
73
+ }
74
+ )
75
+
76
+ def setup_states(self, app_config: "AppConfig") -> None:
77
+
78
+ need_setup = {}
79
+
80
+ state = app_config.state
81
+ if self._binomic_factory_state_key not in state:
82
+ need_setup[self._binomic_factory_state_key] = BinomicFactory(
83
+ broker_dsn=self._broker_dsn,
84
+ redis_dsn=self._redis_dsn,
85
+ module_name=self._app_name,
86
+ config=self._config,
87
+ )
88
+
89
+ state.update(need_setup)
90
+
91
+ def setup_dependencies(self, app_config: "AppConfig") -> None:
92
+
93
+ app_config.dependencies.update(
94
+ {
95
+ "binomic": Provide(
96
+ dependency=self.provide_binomic,
97
+ sync_to_thread=True,
98
+ )
99
+ }
100
+ )
binomic/py.typed ADDED
File without changes
@@ -0,0 +1,12 @@
1
+ from .exceptions import DuplicateTaskError, TaskNotFoundError
2
+ from .registry import TaskRegistry, TaskSpec
3
+ from .wrappers import autodiscover, task
4
+
5
+ __all__ = (
6
+ "DuplicateTaskError",
7
+ "TaskNotFoundError",
8
+ "TaskRegistry",
9
+ "TaskSpec",
10
+ "autodiscover",
11
+ "task",
12
+ )
@@ -0,0 +1,14 @@
1
+ from binomic.base import BinomicError
2
+
3
+ __all__ = (
4
+ "DuplicateTaskError",
5
+ "TaskNotFoundError",
6
+ )
7
+
8
+
9
+ class DuplicateTaskError(BinomicError):
10
+ """Duplicate task error."""
11
+
12
+
13
+ class TaskNotFoundError(BinomicError):
14
+ """Task not found error."""