jararaca 0.2.37a11__py3-none-any.whl → 0.3.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.

Potentially problematic release.


This version of jararaca might be problematic. Click here for more details.

@@ -2,7 +2,8 @@ import inspect
2
2
  from dataclasses import dataclass
3
3
  from typing import Any, Awaitable, Callable, Generic, TypeVar, cast
4
4
 
5
- from jararaca.messagebus.types import INHERITS_MESSAGE_CO, Message, MessageOf
5
+ from jararaca.messagebus.message import INHERITS_MESSAGE_CO, Message, MessageOf
6
+ from jararaca.scheduler.decorators import ScheduledAction
6
7
 
7
8
  DECORATED_FUNC = TypeVar("DECORATED_FUNC", bound=Callable[..., Any])
8
9
  DECORATED_CLASS = TypeVar("DECORATED_CLASS", bound=Any)
@@ -45,7 +46,7 @@ class MessageHandler(Generic[INHERITS_MESSAGE_CO]):
45
46
 
46
47
  @staticmethod
47
48
  def get_message_incoming(
48
- func: Callable[[MessageOf[Any]], Awaitable[Any]]
49
+ func: Callable[[MessageOf[Any]], Awaitable[Any]],
49
50
  ) -> "MessageHandler[Message] | None":
50
51
  if not hasattr(func, MessageHandler.MESSAGE_INCOMING_ATTR):
51
52
  return None
@@ -62,7 +63,21 @@ class MessageHandlerData:
62
63
  callable: Callable[[MessageOf[Any]], Awaitable[None]]
63
64
 
64
65
 
66
+ @dataclass(frozen=True)
67
+ class ScheduleDispatchData:
68
+ timestamp: float
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class ScheduledActionData:
73
+ spec: ScheduledAction
74
+ callable: Callable[
75
+ ..., Awaitable[None]
76
+ ] # Callable[[ScheduleDispatchData], Awaitable[None]]
77
+
78
+
65
79
  MESSAGE_HANDLER_DATA_SET = set[MessageHandlerData]
80
+ SCHEDULED_ACTION_DATA_SET = set[ScheduledActionData]
66
81
 
67
82
 
68
83
  class MessageBusController:
@@ -70,11 +85,16 @@ class MessageBusController:
70
85
  MESSAGEBUS_ATTR = "__messagebus__"
71
86
 
72
87
  def __init__(self) -> None:
73
- self.messagebus_factory: Callable[[Any], MESSAGE_HANDLER_DATA_SET] | None = None
88
+ self.messagebus_factory: (
89
+ Callable[[Any], tuple[MESSAGE_HANDLER_DATA_SET, SCHEDULED_ACTION_DATA_SET]]
90
+ | None
91
+ ) = None
74
92
 
75
93
  def get_messagebus_factory(
76
94
  self,
77
- ) -> Callable[[DECORATED_CLASS], MESSAGE_HANDLER_DATA_SET]:
95
+ ) -> Callable[
96
+ [DECORATED_CLASS], tuple[MESSAGE_HANDLER_DATA_SET, SCHEDULED_ACTION_DATA_SET]
97
+ ]:
78
98
  if self.messagebus_factory is None:
79
99
  raise Exception("MessageBus factory is not set")
80
100
  return self.messagebus_factory
@@ -83,33 +103,49 @@ class MessageBusController:
83
103
 
84
104
  def messagebus_factory(
85
105
  instance: DECORATED_CLASS,
86
- ) -> MESSAGE_HANDLER_DATA_SET:
106
+ ) -> tuple[MESSAGE_HANDLER_DATA_SET, SCHEDULED_ACTION_DATA_SET]:
87
107
  handlers: MESSAGE_HANDLER_DATA_SET = set()
88
- inspect.signature(func)
108
+
109
+ schedulers: SCHEDULED_ACTION_DATA_SET = set()
89
110
 
90
111
  members = inspect.getmembers(func, predicate=inspect.isfunction)
91
112
 
92
113
  for name, member in members:
93
- message_incoming = MessageHandler.get_message_incoming(member)
114
+ message_handler_decoration = MessageHandler.get_message_incoming(member)
115
+ scheduled_action_decoration = ScheduledAction.get_scheduled_action(
116
+ member
117
+ )
94
118
 
95
- if message_incoming is None:
96
- continue
119
+ if message_handler_decoration is not None:
97
120
 
98
- if not inspect.iscoroutinefunction(member):
99
- raise Exception(
100
- "Message incoming handler '%s' from '%s.%s' must be a coroutine function"
101
- % (name, func.__module__, func.__qualname__)
102
- )
121
+ if not inspect.iscoroutinefunction(member):
122
+ raise Exception(
123
+ "Message incoming handler '%s' from '%s.%s' must be a coroutine function"
124
+ % (name, func.__module__, func.__qualname__)
125
+ )
103
126
 
104
- handlers.add(
105
- MessageHandlerData(
106
- message_type=message_incoming.message_type,
107
- spec=message_incoming,
108
- callable=getattr(instance, name),
127
+ handlers.add(
128
+ MessageHandlerData(
129
+ message_type=message_handler_decoration.message_type,
130
+ spec=message_handler_decoration,
131
+ callable=getattr(instance, name),
132
+ )
133
+ )
134
+ elif scheduled_action_decoration is not None:
135
+ if not inspect.iscoroutinefunction(member):
136
+ raise Exception(
137
+ "Scheduled action handler '%s' from '%s.%s' must be a coroutine function"
138
+ % (name, func.__module__, func.__qualname__)
139
+ )
140
+
141
+ schedulers.add(
142
+ ScheduledActionData(
143
+ spec=scheduled_action_decoration,
144
+ callable=getattr(instance, name),
145
+ )
109
146
  )
110
- )
111
147
 
112
- return handlers
148
+ return handlers, schedulers
113
149
 
114
150
  self.messagebus_factory = messagebus_factory
115
151
 
@@ -1,57 +1,72 @@
1
1
  from contextlib import asynccontextmanager
2
- from typing import Any, AsyncContextManager, AsyncGenerator, Protocol
2
+ from datetime import datetime, timedelta
3
+ from datetime import tzinfo as _TzInfo
4
+ from typing import Any, AsyncGenerator
3
5
 
4
6
  import aio_pika
5
7
  from aio_pika.abc import AbstractConnection
6
8
  from pydantic import BaseModel
7
9
 
8
- from jararaca.messagebus.publisher import MessagePublisher, provide_message_publisher
9
- from jararaca.microservice import AppContext, AppInterceptor
10
+ from jararaca.broker_backend import MessageBrokerBackend
11
+ from jararaca.messagebus.interceptors.publisher_interceptor import (
12
+ MessageBusConnectionFactory,
13
+ )
14
+ from jararaca.messagebus.publisher import IMessage, MessagePublisher
15
+ from jararaca.scheduler.types import DelayedMessageData
10
16
 
11
17
 
12
- class MessageBusConnectionFactory(Protocol):
13
-
14
- def provide_connection(self) -> AsyncContextManager[MessagePublisher]: ...
15
-
16
-
17
- class MessageBusPublisherInterceptor(AppInterceptor):
18
+ class AIOPikaMessagePublisher(MessagePublisher):
18
19
 
19
20
  def __init__(
20
21
  self,
21
- connection_factory: MessageBusConnectionFactory,
22
- connection_name: str = "default",
22
+ channel: aio_pika.abc.AbstractChannel,
23
+ exchange_name: str,
24
+ message_broker_backend: MessageBrokerBackend | None = None,
23
25
  ):
24
- self.connection_factory = connection_factory
25
- self.connection_name = connection_name
26
-
27
- @asynccontextmanager
28
- async def intercept(self, app_context: AppContext) -> AsyncGenerator[None, None]:
29
- if app_context.context_type == "websocket":
30
- yield
31
- return
32
-
33
- async with self.connection_factory.provide_connection() as connection:
34
- with provide_message_publisher(self.connection_name, connection):
35
- yield
36
-
37
-
38
- class AIOPikaMessagePublisher(MessagePublisher):
39
26
 
40
- def __init__(self, channel: aio_pika.abc.AbstractChannel, exchange_name: str):
41
27
  self.channel = channel
42
28
  self.exchange_name = exchange_name
29
+ self.message_broker_backend = message_broker_backend
43
30
 
44
- async def publish(self, message: BaseModel, topic: str) -> None:
31
+ async def publish(self, message: IMessage, topic: str) -> None:
45
32
  exchange = await self.channel.declare_exchange(
46
33
  self.exchange_name,
47
34
  type=aio_pika.ExchangeType.TOPIC,
48
35
  )
49
- routing_key = f"{self.exchange_name}.{topic}."
36
+ routing_key = f"{topic}."
50
37
  await exchange.publish(
51
38
  aio_pika.Message(body=message.model_dump_json().encode()),
52
39
  routing_key=routing_key,
53
40
  )
54
41
 
42
+ async def delay(self, message: IMessage, seconds: int) -> None:
43
+ if not self.message_broker_backend:
44
+ raise NotImplementedError(
45
+ "Delay is not implemented for AIOPikaMessagePublisher"
46
+ )
47
+ await self.message_broker_backend.enqueue_delayed_message(
48
+ DelayedMessageData(
49
+ message_topic=message.MESSAGE_TOPIC,
50
+ payload=message.model_dump_json().encode(),
51
+ dispatch_time=int(
52
+ (datetime.now(tz=None) + timedelta(seconds=seconds)).timestamp()
53
+ ),
54
+ )
55
+ )
56
+
57
+ async def schedule(self, message: IMessage, when: datetime, tz: _TzInfo) -> None:
58
+ if not self.message_broker_backend:
59
+ raise NotImplementedError(
60
+ "Schedule is not implemented for AIOPikaMessagePublisher"
61
+ )
62
+ await self.message_broker_backend.enqueue_delayed_message(
63
+ DelayedMessageData(
64
+ message_topic=message.MESSAGE_TOPIC,
65
+ payload=message.model_dump_json().encode(),
66
+ dispatch_time=int(when.timestamp()),
67
+ )
68
+ )
69
+
55
70
 
56
71
  class GenericPoolConfig(BaseModel):
57
72
  max_size: int
@@ -65,10 +80,11 @@ class AIOPikaConnectionFactory(MessageBusConnectionFactory):
65
80
  exchange: str,
66
81
  connection_pool_config: GenericPoolConfig | None = None,
67
82
  channel_pool_config: GenericPoolConfig | None = None,
83
+ message_broker_backend: MessageBrokerBackend | None = None,
68
84
  ):
69
85
  self.url = url
70
86
  self.exchange = exchange
71
-
87
+ self.message_broker_backend = message_broker_backend
72
88
  self.connection_pool: aio_pika.pool.Pool[AbstractConnection] | None = None
73
89
  self.channel_pool: aio_pika.pool.Pool[aio_pika.abc.AbstractChannel] | None = (
74
90
  None
@@ -124,7 +140,11 @@ class AIOPikaConnectionFactory(MessageBusConnectionFactory):
124
140
  await tx.select()
125
141
 
126
142
  try:
127
- yield AIOPikaMessagePublisher(channel, exchange_name=self.exchange)
143
+ yield AIOPikaMessagePublisher(
144
+ channel,
145
+ exchange_name=self.exchange,
146
+ message_broker_backend=self.message_broker_backend,
147
+ )
128
148
  await tx.commit()
129
149
  except Exception as e:
130
150
  await tx.rollback()
@@ -0,0 +1,34 @@
1
+ from contextlib import asynccontextmanager
2
+ from typing import AsyncContextManager, AsyncGenerator, Protocol
3
+
4
+ from jararaca.broker_backend import MessageBrokerBackend
5
+ from jararaca.messagebus.publisher import MessagePublisher, provide_message_publisher
6
+ from jararaca.microservice import AppContext, AppInterceptor
7
+
8
+
9
+ class MessageBusConnectionFactory(Protocol):
10
+
11
+ def provide_connection(self) -> AsyncContextManager[MessagePublisher]: ...
12
+
13
+
14
+ class MessageBusPublisherInterceptor(AppInterceptor):
15
+
16
+ def __init__(
17
+ self,
18
+ connection_factory: MessageBusConnectionFactory,
19
+ connection_name: str = "default",
20
+ message_scheduler: MessageBrokerBackend | None = None,
21
+ ):
22
+ self.connection_factory = connection_factory
23
+ self.connection_name = connection_name
24
+ self.message_scheduler = message_scheduler
25
+
26
+ @asynccontextmanager
27
+ async def intercept(self, app_context: AppContext) -> AsyncGenerator[None, None]:
28
+ if app_context.context_type == "websocket":
29
+ yield
30
+ return
31
+
32
+ async with self.connection_factory.provide_connection() as connection:
33
+ with provide_message_publisher(self.connection_name, connection):
34
+ yield
@@ -0,0 +1,27 @@
1
+ from datetime import datetime, tzinfo
2
+ from typing import Generic, Protocol, TypeVar
3
+
4
+ from jararaca.messagebus.publisher import IMessage, use_publisher
5
+
6
+
7
+ class Message(IMessage):
8
+
9
+ async def publish(self) -> None:
10
+ task_publisher = use_publisher()
11
+ await task_publisher.publish(self, self.MESSAGE_TOPIC)
12
+
13
+ async def delay(self, seconds: int) -> None:
14
+ task_publisher = use_publisher()
15
+ await task_publisher.delay(self, seconds)
16
+
17
+ async def schedule(self, when: datetime, tz: tzinfo) -> None:
18
+ task_publisher = use_publisher()
19
+ await task_publisher.schedule(self, when, tz)
20
+
21
+
22
+ INHERITS_MESSAGE_CO = TypeVar("INHERITS_MESSAGE_CO", bound=Message, covariant=True)
23
+
24
+
25
+ class MessageOf(Protocol, Generic[INHERITS_MESSAGE_CO]):
26
+
27
+ def payload(self) -> INHERITS_MESSAGE_CO: ...
@@ -1,12 +1,41 @@
1
1
  from contextlib import contextmanager, suppress
2
2
  from contextvars import ContextVar
3
- from typing import Any, Generator, Protocol
3
+ from datetime import datetime, tzinfo
4
+ from typing import Any, ClassVar, Generator, Literal, Protocol
4
5
 
5
6
  from pydantic import BaseModel
6
7
 
7
8
 
9
+ class IMessage(BaseModel):
10
+ """
11
+ Base class for messages representing tasks.
12
+ A Task is a message that represents a unit of work to be done.
13
+ It is published to a TaskPublisher and consumed by a TaskHandler, wrapped in TaskData.
14
+ Note: A Task is not an Event.
15
+ """
16
+
17
+ MESSAGE_TOPIC: ClassVar[str] = "__unset__"
18
+
19
+ MESSAGE_TYPE: ClassVar[Literal["task", "event"]] = "task"
20
+
21
+
8
22
  class MessagePublisher(Protocol):
9
- async def publish(self, message: BaseModel, topic: str) -> None:
23
+ async def publish(self, message: IMessage, topic: str) -> None:
24
+ raise NotImplementedError()
25
+
26
+ async def delay(self, message: IMessage, seconds: int) -> None:
27
+ """
28
+ Delay the message for a given number of seconds.
29
+ """
30
+
31
+ raise NotImplementedError()
32
+
33
+ async def schedule(
34
+ self, message: IMessage, when: datetime, timezone: tzinfo
35
+ ) -> None:
36
+ """
37
+ Schedule the message for a given datetime.
38
+ """
10
39
  raise NotImplementedError()
11
40
 
12
41
 
@@ -24,8 +24,9 @@ from jararaca.messagebus.decorators import (
24
24
  MessageHandler,
25
25
  MessageHandlerData,
26
26
  )
27
- from jararaca.messagebus.types import Message, MessageOf
27
+ from jararaca.messagebus.message import Message, MessageOf
28
28
  from jararaca.microservice import MessageBusAppContext, Microservice
29
+ from jararaca.utils.rabbitmq_utils import RabbitmqUtils
29
30
 
30
31
  logger = logging.getLogger(__name__)
31
32
 
@@ -33,7 +34,6 @@ logger = logging.getLogger(__name__)
33
34
  @dataclass
34
35
  class AioPikaWorkerConfig:
35
36
  url: str
36
- queue: str
37
37
  exchange: str
38
38
  prefetch_count: int
39
39
 
@@ -99,32 +99,28 @@ class AioPikaMicroserviceConsumer:
99
99
 
100
100
  await channel.set_qos(prefetch_count=self.config.prefetch_count)
101
101
 
102
- main_x = await channel.declare_exchange(self.config.exchange, type="topic")
103
-
104
- dlx = await channel.declare_exchange("dlx", type="direct")
105
-
106
- dlq = await channel.declare_queue("dlq")
102
+ main_ex = await RabbitmqUtils.declare_main_exchange(
103
+ channel=channel, exchange_name=self.config.exchange
104
+ )
107
105
 
108
- await dlq.bind(dlx, routing_key="dlq")
106
+ dlx, dlq = await RabbitmqUtils.delcare_dl_kit(channel=channel)
109
107
 
110
108
  for handler in self.message_handler_set:
111
109
 
112
- queue_name = f"{self.config.exchange}.{handler.message_type.MESSAGE_TOPIC}.{handler.callable.__module__}.{handler.callable.__qualname__}"
113
- routing_key = (
114
- f"{self.config.exchange}.{handler.message_type.MESSAGE_TOPIC}.#"
115
- )
110
+ queue_name = f"{handler.message_type.MESSAGE_TOPIC}.{handler.callable.__module__}.{handler.callable.__qualname__}"
111
+ routing_key = f"{handler.message_type.MESSAGE_TOPIC}.#"
116
112
 
117
113
  self.incoming_map[queue_name] = handler
118
114
 
119
115
  queue = await channel.declare_queue(
120
116
  queue_name,
121
117
  arguments={
122
- "x-dead-letter-exchange": "dlx",
123
- "x-dead-letter-routing-key": "dlq",
118
+ "x-dead-letter-exchange": dlx.name,
119
+ "x-dead-letter-routing-key": dlq.name,
124
120
  },
125
121
  )
126
122
 
127
- await queue.bind(exchange=self.config.exchange, routing_key=routing_key)
123
+ await queue.bind(exchange=main_ex, routing_key=routing_key)
128
124
 
129
125
  await queue.consume(
130
126
  callback=MessageHandlerCallback(
@@ -205,17 +201,17 @@ class MessageHandlerCallback:
205
201
  self, aio_pika_message: aio_pika.abc.AbstractIncomingMessage
206
202
  ) -> None:
207
203
 
208
- rounting_key = self.queue_name
204
+ routing_key = self.queue_name
209
205
 
210
- if rounting_key is None:
206
+ if routing_key is None:
211
207
  logger.warning("No topic found for message")
212
208
  await self.handle_reject_message(aio_pika_message)
213
209
  return
214
210
 
215
- handler_data = self.consumer.incoming_map.get(rounting_key)
211
+ handler_data = self.consumer.incoming_map.get(routing_key)
216
212
 
217
213
  if handler_data is None:
218
- logger.warning("No handler found for topic '%s'" % rounting_key)
214
+ logger.warning("No handler found for topic '%s'" % routing_key)
219
215
  await self.handle_reject_message(aio_pika_message)
220
216
 
221
217
  return
@@ -227,7 +223,7 @@ class MessageHandlerCallback:
227
223
  if len(sig.parameters) != 1:
228
224
  logger.warning(
229
225
  "Handler for topic '%s' must have exactly one parameter which is MessageOf[T extends Message]"
230
- % rounting_key
226
+ % routing_key
231
227
  )
232
228
  return
233
229
 
@@ -238,14 +234,14 @@ class MessageHandlerCallback:
238
234
  if param_origin is not MessageOf:
239
235
  logger.warning(
240
236
  "Handler for topic '%s' must have exactly one parameter of type Message"
241
- % rounting_key
237
+ % routing_key
242
238
  )
243
239
  return
244
240
 
245
241
  if len(parameter.annotation.__args__) != 1:
246
242
  logger.warning(
247
243
  "Handler for topic '%s' must have exactly one parameter of type Message"
248
- % rounting_key
244
+ % routing_key
249
245
  )
250
246
  return
251
247
 
@@ -253,8 +249,8 @@ class MessageHandlerCallback:
253
249
 
254
250
  if not issubclass(message_type, BaseModel):
255
251
  logger.warning(
256
- "Handler for topic '%s' must have exactly one parameter of type Message[BaseModel]"
257
- % rounting_key
252
+ "Handler for topic '%s' must have exactly one parameter of type MessageOf[BaseModel]"
253
+ % routing_key
258
254
  )
259
255
  return
260
256
 
@@ -266,7 +262,7 @@ class MessageHandlerCallback:
266
262
  async with self.consumer.uow_context_provider(
267
263
  MessageBusAppContext(
268
264
  message=builded_message,
269
- topic=rounting_key,
265
+ topic=routing_key,
270
266
  )
271
267
  ):
272
268
  ctx: AsyncContextManager[Any]
@@ -293,7 +289,7 @@ class MessageHandlerCallback:
293
289
  )
294
290
  else:
295
291
  logger.exception(
296
- f"Error processing message on topic {rounting_key}"
292
+ f"Error processing message on topic {routing_key}"
297
293
  )
298
294
  if incoming_message_spec.requeue_on_exception:
299
295
  await self.handle_reject_message(aio_pika_message, requeue=True)
@@ -343,7 +339,7 @@ class MessageBusWorker:
343
339
  instance: Any = self.container.get_by_type(instance_type)
344
340
 
345
341
  factory = controller.get_messagebus_factory()
346
- handlers = factory(instance)
342
+ handlers, _ = factory(instance)
347
343
 
348
344
  message_handler_data_map: dict[str, MessageHandlerData] = {}
349
345