jararaca 0.2.37a12__py3-none-any.whl → 0.4.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.
Files changed (91) hide show
  1. README.md +121 -0
  2. jararaca/__init__.py +267 -15
  3. jararaca/__main__.py +4 -0
  4. jararaca/broker_backend/__init__.py +106 -0
  5. jararaca/broker_backend/mapper.py +25 -0
  6. jararaca/broker_backend/redis_broker_backend.py +168 -0
  7. jararaca/cli.py +840 -103
  8. jararaca/common/__init__.py +3 -0
  9. jararaca/core/__init__.py +3 -0
  10. jararaca/core/providers.py +4 -0
  11. jararaca/core/uow.py +55 -16
  12. jararaca/di.py +4 -0
  13. jararaca/files/entity.py.mako +4 -0
  14. jararaca/lifecycle.py +6 -2
  15. jararaca/messagebus/__init__.py +5 -1
  16. jararaca/messagebus/bus_message_controller.py +4 -0
  17. jararaca/messagebus/consumers/__init__.py +3 -0
  18. jararaca/messagebus/decorators.py +90 -85
  19. jararaca/messagebus/implicit_headers.py +49 -0
  20. jararaca/messagebus/interceptors/__init__.py +3 -0
  21. jararaca/messagebus/interceptors/aiopika_publisher_interceptor.py +95 -37
  22. jararaca/messagebus/interceptors/publisher_interceptor.py +42 -0
  23. jararaca/messagebus/message.py +31 -0
  24. jararaca/messagebus/publisher.py +47 -4
  25. jararaca/messagebus/worker.py +1615 -135
  26. jararaca/microservice.py +248 -36
  27. jararaca/observability/constants.py +7 -0
  28. jararaca/observability/decorators.py +177 -16
  29. jararaca/observability/fastapi_exception_handler.py +37 -0
  30. jararaca/observability/hooks.py +109 -0
  31. jararaca/observability/interceptor.py +8 -2
  32. jararaca/observability/providers/__init__.py +3 -0
  33. jararaca/observability/providers/otel.py +213 -18
  34. jararaca/persistence/base.py +40 -3
  35. jararaca/persistence/exports.py +4 -0
  36. jararaca/persistence/interceptors/__init__.py +3 -0
  37. jararaca/persistence/interceptors/aiosqa_interceptor.py +187 -23
  38. jararaca/persistence/interceptors/constants.py +5 -0
  39. jararaca/persistence/interceptors/decorators.py +50 -0
  40. jararaca/persistence/session.py +3 -0
  41. jararaca/persistence/sort_filter.py +4 -0
  42. jararaca/persistence/utilities.py +74 -32
  43. jararaca/presentation/__init__.py +3 -0
  44. jararaca/presentation/decorators.py +170 -82
  45. jararaca/presentation/exceptions.py +23 -0
  46. jararaca/presentation/hooks.py +4 -0
  47. jararaca/presentation/http_microservice.py +4 -0
  48. jararaca/presentation/server.py +120 -41
  49. jararaca/presentation/websocket/__init__.py +3 -0
  50. jararaca/presentation/websocket/base_types.py +4 -0
  51. jararaca/presentation/websocket/context.py +34 -4
  52. jararaca/presentation/websocket/decorators.py +8 -41
  53. jararaca/presentation/websocket/redis.py +280 -53
  54. jararaca/presentation/websocket/types.py +6 -2
  55. jararaca/presentation/websocket/websocket_interceptor.py +74 -23
  56. jararaca/reflect/__init__.py +3 -0
  57. jararaca/reflect/controller_inspect.py +81 -0
  58. jararaca/reflect/decorators.py +238 -0
  59. jararaca/reflect/metadata.py +76 -0
  60. jararaca/rpc/__init__.py +3 -0
  61. jararaca/rpc/http/__init__.py +101 -0
  62. jararaca/rpc/http/backends/__init__.py +14 -0
  63. jararaca/rpc/http/backends/httpx.py +43 -9
  64. jararaca/rpc/http/backends/otel.py +4 -0
  65. jararaca/rpc/http/decorators.py +378 -113
  66. jararaca/rpc/http/httpx.py +3 -0
  67. jararaca/scheduler/__init__.py +3 -0
  68. jararaca/scheduler/beat_worker.py +758 -0
  69. jararaca/scheduler/decorators.py +89 -28
  70. jararaca/scheduler/types.py +11 -0
  71. jararaca/tools/app_config/__init__.py +3 -0
  72. jararaca/tools/app_config/decorators.py +7 -19
  73. jararaca/tools/app_config/interceptor.py +10 -4
  74. jararaca/tools/typescript/__init__.py +3 -0
  75. jararaca/tools/typescript/decorators.py +120 -0
  76. jararaca/tools/typescript/interface_parser.py +1126 -189
  77. jararaca/utils/__init__.py +3 -0
  78. jararaca/utils/rabbitmq_utils.py +372 -0
  79. jararaca/utils/retry.py +148 -0
  80. jararaca-0.4.0a5.dist-info/LICENSE +674 -0
  81. jararaca-0.4.0a5.dist-info/LICENSES/GPL-3.0-or-later.txt +232 -0
  82. {jararaca-0.2.37a12.dist-info → jararaca-0.4.0a5.dist-info}/METADATA +14 -7
  83. jararaca-0.4.0a5.dist-info/RECORD +88 -0
  84. {jararaca-0.2.37a12.dist-info → jararaca-0.4.0a5.dist-info}/WHEEL +1 -1
  85. pyproject.toml +131 -0
  86. jararaca/messagebus/types.py +0 -30
  87. jararaca/scheduler/scheduler.py +0 -154
  88. jararaca/tools/metadata.py +0 -47
  89. jararaca-0.2.37a12.dist-info/RECORD +0 -63
  90. /jararaca-0.2.37a12.dist-info/LICENSE → /LICENSE +0 -0
  91. {jararaca-0.2.37a12.dist-info → jararaca-0.4.0a5.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
jararaca/core/__init__.py CHANGED
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
@@ -1,3 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
1
5
  from dataclasses import dataclass
2
6
  from typing import Any, Callable, Generic, Type, TypeVar
3
7
 
jararaca/core/uow.py CHANGED
@@ -1,14 +1,19 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
1
5
  from contextlib import asynccontextmanager
2
6
  from typing import AsyncGenerator, Sequence
3
7
 
4
8
  from jararaca.microservice import (
5
- AppContext,
6
9
  AppInterceptor,
10
+ AppTransactionContext,
7
11
  Container,
8
12
  Microservice,
9
13
  provide_app_context,
10
14
  provide_container,
11
15
  )
16
+ from jararaca.reflect.metadata import start_transaction_metadata_context
12
17
 
13
18
 
14
19
  class ContainerInterceptor(AppInterceptor):
@@ -17,7 +22,9 @@ class ContainerInterceptor(AppInterceptor):
17
22
  self.container = container
18
23
 
19
24
  @asynccontextmanager
20
- async def intercept(self, app_context: AppContext) -> AsyncGenerator[None, None]:
25
+ async def intercept(
26
+ self, app_context: AppTransactionContext
27
+ ) -> AsyncGenerator[None, None]:
21
28
 
22
29
  with provide_app_context(app_context), provide_container(self.container):
23
30
  yield None
@@ -30,7 +37,6 @@ class UnitOfWorkContextProvider:
30
37
  self.container = container
31
38
  self.container_interceptor = ContainerInterceptor(container)
32
39
 
33
- # TODO: Guarantee that the context is closed whenever an exception is raised
34
40
  # TODO: Guarantee a unit of work workflow for the whole request, including all the interceptors
35
41
 
36
42
  def factory_app_interceptors(self) -> Sequence[AppInterceptor]:
@@ -49,18 +55,51 @@ class UnitOfWorkContextProvider:
49
55
  return interceptors
50
56
 
51
57
  @asynccontextmanager
52
- async def __call__(self, app_context: AppContext) -> AsyncGenerator[None, None]:
58
+ async def __call__(
59
+ self, app_context: AppTransactionContext
60
+ ) -> AsyncGenerator[None, None]:
53
61
 
54
62
  app_interceptors = self.factory_app_interceptors()
55
-
56
- ctxs = [self.container_interceptor.intercept(app_context)] + [
57
- interceptor.intercept(app_context) for interceptor in app_interceptors
58
- ]
59
-
60
- for ctx in ctxs:
61
- await ctx.__aenter__()
62
-
63
- yield None
64
-
65
- for ctx in reversed(ctxs):
66
- await ctx.__aexit__(None, None, None)
63
+ with start_transaction_metadata_context(
64
+ app_context.controller_member_reflect.metadata
65
+ ):
66
+ ctxs = [self.container_interceptor.intercept(app_context)] + [
67
+ interceptor.intercept(app_context) for interceptor in app_interceptors
68
+ ]
69
+
70
+ for ctx in ctxs:
71
+ await ctx.__aenter__()
72
+
73
+ exc_type = None
74
+ exc_value = None
75
+ exc_traceback = None
76
+
77
+ try:
78
+ yield None
79
+ except BaseException as e:
80
+ exc_type = type(e)
81
+ exc_value = e
82
+ exc_traceback = e.__traceback__
83
+ raise
84
+ finally:
85
+ # Exit interceptors in reverse order, propagating exception info
86
+ for ctx in reversed(ctxs):
87
+ try:
88
+ suppressed = await ctx.__aexit__(
89
+ exc_type, exc_value, exc_traceback
90
+ )
91
+ # If an interceptor returns True, it suppresses the exception
92
+ if suppressed and exc_type is not None:
93
+ exc_type = None
94
+ exc_value = None
95
+ exc_traceback = None
96
+ except BaseException as exit_exc:
97
+ # If an interceptor raises an exception during cleanup,
98
+ # replace the original exception with the new one
99
+ exc_type = type(exit_exc)
100
+ exc_value = exit_exc
101
+ exc_traceback = exit_exc.__traceback__
102
+
103
+ # Re-raise the exception if it wasn't suppressed
104
+ if exc_type is not None and exc_value is not None:
105
+ raise exc_value.with_traceback(exc_traceback)
jararaca/di.py CHANGED
@@ -1,3 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
1
5
  from jararaca.microservice import Container
2
6
 
3
7
  __all__ = [
@@ -1,3 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
1
5
  from datetime import datetime
2
6
  from typing import Annotated
3
7
  from uuid import UUID, uuid4
jararaca/lifecycle.py CHANGED
@@ -1,3 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
1
5
  import logging
2
6
  from contextlib import asynccontextmanager
3
7
  from typing import AsyncContextManager, AsyncGenerator, Sequence
@@ -33,7 +37,7 @@ class AppLifecycle:
33
37
  self.container.fill_providers(False)
34
38
  lifecycle_ctxs: list[AsyncContextManager[None]] = []
35
39
 
36
- logger.info("Initializing interceptors lifecycle")
40
+ logger.debug("Initializing interceptors lifecycle")
37
41
  for interceptor_dep in self.app.interceptors:
38
42
  interceptor: AppInterceptor
39
43
  if not isinstance(interceptor_dep, AppInterceptor):
@@ -57,6 +61,6 @@ class AppLifecycle:
57
61
 
58
62
  yield
59
63
 
60
- logger.info("Finalizing interceptors lifecycle")
64
+ logger.debug("Finalizing interceptors lifecycle")
61
65
  for ctx in lifecycle_ctxs:
62
66
  await ctx.__aexit__(None, None, None)
@@ -1,3 +1,7 @@
1
- from .types import MessageOf
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ from .message import MessageOf
2
6
 
3
7
  __all__ = ["MessageOf"]
@@ -1,3 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
1
5
  from contextlib import contextmanager, suppress
2
6
  from contextvars import ContextVar
3
7
  from typing import Any, Generator, Protocol
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
@@ -1,16 +1,22 @@
1
- import inspect
2
- from dataclasses import dataclass
3
- from typing import Any, Awaitable, Callable, Generic, TypeVar, cast
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
- from jararaca.messagebus.types import INHERITS_MESSAGE_CO, Message, MessageOf
6
5
 
7
- DECORATED_FUNC = TypeVar("DECORATED_FUNC", bound=Callable[..., Any])
8
- DECORATED_CLASS = TypeVar("DECORATED_CLASS", bound=Any)
6
+ import inspect
7
+ from dataclasses import dataclass
8
+ from typing import Any, Awaitable, Callable
9
9
 
10
+ from jararaca.messagebus.message import INHERITS_MESSAGE_CO
11
+ from jararaca.reflect.controller_inspect import (
12
+ ControllerMemberReflect,
13
+ inspect_controller,
14
+ )
15
+ from jararaca.reflect.decorators import DECORATED_T, StackableDecorator
16
+ from jararaca.scheduler.decorators import ScheduledAction, ScheduledActionData
10
17
 
11
- class MessageHandler(Generic[INHERITS_MESSAGE_CO]):
12
18
 
13
- MESSAGE_INCOMING_ATTR = "__message_incoming__"
19
+ class MessageHandler(StackableDecorator):
14
20
 
15
21
  def __init__(
16
22
  self,
@@ -18,117 +24,116 @@ class MessageHandler(Generic[INHERITS_MESSAGE_CO]):
18
24
  timeout: int | None = None,
19
25
  exception_handler: Callable[[BaseException], None] | None = None,
20
26
  nack_on_exception: bool = False,
21
- auto_ack: bool = True,
27
+ auto_ack: bool = False,
28
+ name: str | None = None,
22
29
  ) -> None:
23
30
  self.message_type = message
24
31
 
25
32
  self.timeout = timeout
26
33
  self.exception_handler = exception_handler
27
- self.requeue_on_exception = nack_on_exception
34
+ self.nack_on_exception = nack_on_exception
28
35
  self.auto_ack = auto_ack
29
-
30
- def __call__(
31
- self, func: Callable[[Any, MessageOf[INHERITS_MESSAGE_CO]], Awaitable[None]]
32
- ) -> Callable[[Any, MessageOf[INHERITS_MESSAGE_CO]], Awaitable[None]]:
33
-
34
- MessageHandler[Any].register(func, self)
35
-
36
- return func
37
-
38
- @staticmethod
39
- def register(
40
- func: Callable[[Any, MessageOf[INHERITS_MESSAGE_CO]], Awaitable[None]],
41
- message_incoming: "MessageHandler[Any]",
42
- ) -> None:
43
-
44
- setattr(func, MessageHandler.MESSAGE_INCOMING_ATTR, message_incoming)
45
-
46
- @staticmethod
47
- def get_message_incoming(
48
- func: Callable[[MessageOf[Any]], Awaitable[Any]]
49
- ) -> "MessageHandler[Message] | None":
50
- if not hasattr(func, MessageHandler.MESSAGE_INCOMING_ATTR):
51
- return None
52
-
53
- return cast(
54
- MessageHandler[Message], getattr(func, MessageHandler.MESSAGE_INCOMING_ATTR)
55
- )
36
+ self.name = name
56
37
 
57
38
 
58
39
  @dataclass(frozen=True)
59
40
  class MessageHandlerData:
60
41
  message_type: type[Any]
61
- spec: MessageHandler[Message]
62
- callable: Callable[[MessageOf[Any]], Awaitable[None]]
42
+ spec: MessageHandler
43
+ instance_callable: Callable[..., Awaitable[None]]
44
+ controller_member: ControllerMemberReflect
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class ScheduleDispatchData:
49
+ timestamp: float
50
+
63
51
 
52
+ SCHEDULED_ACTION_DATA_SET = set[ScheduledActionData]
64
53
 
65
54
  MESSAGE_HANDLER_DATA_SET = set[MessageHandlerData]
66
55
 
67
56
 
68
- class MessageBusController:
57
+ class MessageBusController(StackableDecorator):
69
58
 
70
- MESSAGEBUS_ATTR = "__messagebus__"
59
+ def __init__(
60
+ self,
61
+ *,
62
+ inherit_class_decorators: bool = True,
63
+ inherit_methods_decorators: bool = True,
64
+ ) -> None:
65
+ self.messagebus_factory: (
66
+ Callable[[Any], tuple[MESSAGE_HANDLER_DATA_SET, SCHEDULED_ACTION_DATA_SET]]
67
+ | None
68
+ ) = None
71
69
 
72
- def __init__(self) -> None:
73
- self.messagebus_factory: Callable[[Any], MESSAGE_HANDLER_DATA_SET] | None = None
70
+ self.inherit_class_decorators = inherit_class_decorators
71
+ self.inherit_methods_decorators = inherit_methods_decorators
74
72
 
75
73
  def get_messagebus_factory(
76
74
  self,
77
- ) -> Callable[[DECORATED_CLASS], MESSAGE_HANDLER_DATA_SET]:
75
+ ) -> Callable[
76
+ [DECORATED_T], tuple[MESSAGE_HANDLER_DATA_SET, SCHEDULED_ACTION_DATA_SET]
77
+ ]:
78
78
  if self.messagebus_factory is None:
79
79
  raise Exception("MessageBus factory is not set")
80
80
  return self.messagebus_factory
81
81
 
82
- def __call__(self, func: type[DECORATED_CLASS]) -> type[DECORATED_CLASS]:
82
+ def post_decorated(self, subject: DECORATED_T) -> None:
83
83
 
84
84
  def messagebus_factory(
85
- instance: DECORATED_CLASS,
86
- ) -> MESSAGE_HANDLER_DATA_SET:
85
+ instance: DECORATED_T,
86
+ ) -> tuple[MESSAGE_HANDLER_DATA_SET, SCHEDULED_ACTION_DATA_SET]:
87
87
  handlers: MESSAGE_HANDLER_DATA_SET = set()
88
- inspect.signature(func)
89
88
 
90
- members = inspect.getmembers(func, predicate=inspect.isfunction)
89
+ schedulers: SCHEDULED_ACTION_DATA_SET = set()
91
90
 
92
- for name, member in members:
93
- message_incoming = MessageHandler.get_message_incoming(member)
91
+ assert inspect.isclass(
92
+ subject
93
+ ), "MessageBusController can only be applied to classes"
94
94
 
95
- if message_incoming is None:
96
- continue
95
+ _, members = inspect_controller(subject)
97
96
 
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
- )
97
+ for name, member in members.items():
98
+ message_handler_decoration = MessageHandler.get_last(
99
+ member.member_function
100
+ )
101
+ scheduled_action_decoration = ScheduledAction.get_last(
102
+ member.member_function
103
+ )
103
104
 
104
- handlers.add(
105
- MessageHandlerData(
106
- message_type=message_incoming.message_type,
107
- spec=message_incoming,
108
- callable=getattr(instance, name),
105
+ if message_handler_decoration is not None:
106
+
107
+ if not inspect.iscoroutinefunction(member.member_function):
108
+ raise Exception(
109
+ "Message incoming handler '%s' from '%s.%s' must be a coroutine function"
110
+ % (name, subject.__module__, subject.__qualname__)
111
+ )
112
+
113
+ handlers.add(
114
+ MessageHandlerData(
115
+ message_type=message_handler_decoration.message_type,
116
+ spec=message_handler_decoration,
117
+ instance_callable=getattr(instance, name),
118
+ controller_member=member,
119
+ )
120
+ )
121
+ elif scheduled_action_decoration is not None:
122
+ if not inspect.iscoroutinefunction(member.member_function):
123
+ raise Exception(
124
+ "Scheduled action handler '%s' from '%s.%s' must be a coroutine function"
125
+ % (name, subject.__module__, subject.__qualname__)
126
+ )
127
+ instance_callable = getattr(instance, name)
128
+
129
+ schedulers.add(
130
+ ScheduledActionData(
131
+ controller_member=member,
132
+ spec=scheduled_action_decoration,
133
+ callable=instance_callable,
134
+ )
109
135
  )
110
- )
111
136
 
112
- return handlers
137
+ return handlers, schedulers
113
138
 
114
139
  self.messagebus_factory = messagebus_factory
115
-
116
- MessageBusController.register(func, self)
117
-
118
- return func
119
-
120
- @staticmethod
121
- def register(
122
- func: type[DECORATED_CLASS], messagebus: "MessageBusController"
123
- ) -> None:
124
-
125
- setattr(func, MessageBusController.MESSAGEBUS_ATTR, messagebus)
126
-
127
- @staticmethod
128
- def get_messagebus(func: type[DECORATED_CLASS]) -> "MessageBusController | None":
129
- if not hasattr(func, MessageBusController.MESSAGEBUS_ATTR):
130
- return None
131
-
132
- return cast(
133
- MessageBusController, getattr(func, MessageBusController.MESSAGEBUS_ATTR)
134
- )
@@ -0,0 +1,49 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import datetime
6
+ import decimal
7
+ import typing
8
+ from contextlib import contextmanager
9
+ from contextvars import ContextVar
10
+ from typing import Any, Dict, Generator
11
+
12
+ FieldArray = list["FieldValue"]
13
+ """A data structure for holding an array of field values."""
14
+
15
+ FieldTable = typing.Dict[str, "FieldValue"]
16
+ FieldValue = (
17
+ bool
18
+ | bytes
19
+ | bytearray
20
+ | decimal.Decimal
21
+ | FieldArray
22
+ | FieldTable
23
+ | float
24
+ | int
25
+ | None
26
+ | str
27
+ | datetime.datetime
28
+ )
29
+
30
+ ImplicitHeaders = Dict[str, FieldValue]
31
+
32
+ implicit_headers_ctx = ContextVar[ImplicitHeaders | None](
33
+ "implicit_headers_ctx", default=None
34
+ )
35
+
36
+
37
+ def use_implicit_headers() -> ImplicitHeaders | None:
38
+ return implicit_headers_ctx.get()
39
+
40
+
41
+ @contextmanager
42
+ def provide_implicit_headers(
43
+ implicit_headers: ImplicitHeaders,
44
+ ) -> Generator[None, Any, None]:
45
+ token = implicit_headers_ctx.set(implicit_headers)
46
+ try:
47
+ yield
48
+ finally:
49
+ implicit_headers_ctx.reset(token)
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
@@ -1,57 +1,110 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import logging
1
6
  from contextlib import asynccontextmanager
2
- from typing import Any, AsyncContextManager, AsyncGenerator, Protocol
7
+ from datetime import datetime, timedelta
8
+ from datetime import tzinfo as _TzInfo
9
+ from typing import Any, AsyncGenerator
3
10
 
4
11
  import aio_pika
5
12
  from aio_pika.abc import AbstractConnection
6
13
  from pydantic import BaseModel
7
14
 
8
- from jararaca.messagebus.publisher import MessagePublisher, provide_message_publisher
9
- from jararaca.microservice import AppContext, AppInterceptor
10
-
15
+ from jararaca.broker_backend import MessageBrokerBackend
16
+ from jararaca.messagebus import implicit_headers
17
+ from jararaca.messagebus.interceptors.publisher_interceptor import (
18
+ MessageBusConnectionFactory,
19
+ )
20
+ from jararaca.messagebus.publisher import IMessage, MessagePublisher
21
+ from jararaca.scheduler.types import DelayedMessageData
11
22
 
12
- class MessageBusConnectionFactory(Protocol):
23
+ logger = logging.getLogger(__name__)
13
24
 
14
- def provide_connection(self) -> AsyncContextManager[MessagePublisher]: ...
15
25
 
16
-
17
- class MessageBusPublisherInterceptor(AppInterceptor):
26
+ class AIOPikaMessagePublisher(MessagePublisher):
18
27
 
19
28
  def __init__(
20
29
  self,
21
- connection_factory: MessageBusConnectionFactory,
22
- connection_name: str = "default",
30
+ channel: aio_pika.abc.AbstractChannel,
31
+ exchange_name: str,
32
+ message_broker_backend: MessageBrokerBackend | None = None,
23
33
  ):
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
34
 
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
-
40
- def __init__(self, channel: aio_pika.abc.AbstractChannel, exchange_name: str):
41
35
  self.channel = channel
42
36
  self.exchange_name = exchange_name
37
+ self.message_broker_backend = message_broker_backend
38
+ self.staged_delayed_messages: list[DelayedMessageData] = []
39
+ self.staged_messages: list[IMessage] = []
43
40
 
44
- async def publish(self, message: BaseModel, topic: str) -> None:
45
- exchange = await self.channel.declare_exchange(
46
- self.exchange_name,
47
- type=aio_pika.ExchangeType.TOPIC,
48
- )
49
- routing_key = f"{self.exchange_name}.{topic}."
41
+ async def publish(self, message: IMessage, topic: str) -> None:
42
+ self.staged_messages.append(message)
43
+
44
+ async def _publish(self, message: IMessage, topic: str) -> None:
45
+ exchange = await self.channel.get_exchange(self.exchange_name, ensure=False)
46
+ if not exchange:
47
+ logging.warning(f"Exchange {self.exchange_name} not found")
48
+ return
49
+ routing_key = f"{topic}.#"
50
+
51
+ implicit_headers_data = implicit_headers.use_implicit_headers()
50
52
  await exchange.publish(
51
- aio_pika.Message(body=message.model_dump_json().encode()),
53
+ aio_pika.Message(
54
+ body=message.model_dump_json().encode(), headers=implicit_headers_data
55
+ ),
52
56
  routing_key=routing_key,
53
57
  )
54
58
 
59
+ async def delay(self, message: IMessage, seconds: int) -> None:
60
+ if not self.message_broker_backend:
61
+ raise NotImplementedError(
62
+ "Delay is not implemented for AIOPikaMessagePublisher"
63
+ )
64
+ self.staged_delayed_messages.append(
65
+ DelayedMessageData(
66
+ message_topic=message.MESSAGE_TOPIC,
67
+ payload=message.model_dump_json().encode(),
68
+ dispatch_time=int(
69
+ (datetime.now(tz=None) + timedelta(seconds=seconds)).timestamp()
70
+ ),
71
+ )
72
+ )
73
+
74
+ async def schedule(self, message: IMessage, when: datetime, tz: _TzInfo) -> None:
75
+ if not self.message_broker_backend:
76
+ raise NotImplementedError(
77
+ "Schedule is not implemented for AIOPikaMessagePublisher"
78
+ )
79
+ self.staged_delayed_messages.append(
80
+ DelayedMessageData(
81
+ message_topic=message.MESSAGE_TOPIC,
82
+ payload=message.model_dump_json().encode(),
83
+ dispatch_time=int(when.timestamp()),
84
+ )
85
+ )
86
+
87
+ async def flush(self) -> None:
88
+ for message in self.staged_messages:
89
+ logger.debug(
90
+ f"Publishing message {message.MESSAGE_TOPIC} with payload: {message.model_dump_json()}"
91
+ )
92
+ await self._publish(message, message.MESSAGE_TOPIC)
93
+
94
+ if len(self.staged_delayed_messages) > 0:
95
+ if not self.message_broker_backend:
96
+ raise NotImplementedError(
97
+ "MessageBrokerBackend is required to publish delayed messages"
98
+ )
99
+
100
+ for delayed_message in self.staged_delayed_messages:
101
+ logger.debug(
102
+ f"Scheduling delayed message {delayed_message.message_topic} with payload: {delayed_message.payload.decode()}"
103
+ )
104
+ await self.message_broker_backend.enqueue_delayed_message(
105
+ delayed_message
106
+ )
107
+
55
108
 
56
109
  class GenericPoolConfig(BaseModel):
57
110
  max_size: int
@@ -65,10 +118,11 @@ class AIOPikaConnectionFactory(MessageBusConnectionFactory):
65
118
  exchange: str,
66
119
  connection_pool_config: GenericPoolConfig | None = None,
67
120
  channel_pool_config: GenericPoolConfig | None = None,
121
+ message_broker_backend: MessageBrokerBackend | None = None,
68
122
  ):
69
123
  self.url = url
70
124
  self.exchange = exchange
71
-
125
+ self.message_broker_backend = message_broker_backend
72
126
  self.connection_pool: aio_pika.pool.Pool[AbstractConnection] | None = None
73
127
  self.channel_pool: aio_pika.pool.Pool[aio_pika.abc.AbstractChannel] | None = (
74
128
  None
@@ -77,7 +131,7 @@ class AIOPikaConnectionFactory(MessageBusConnectionFactory):
77
131
  if connection_pool_config:
78
132
 
79
133
  async def get_connection() -> AbstractConnection:
80
- return await aio_pika.connect_robust(self.url)
134
+ return await aio_pika.connect(self.url)
81
135
 
82
136
  self.connection_pool = aio_pika.pool.Pool[AbstractConnection](
83
137
  get_connection,
@@ -97,7 +151,7 @@ class AIOPikaConnectionFactory(MessageBusConnectionFactory):
97
151
  @asynccontextmanager
98
152
  async def acquire_connection(self) -> AsyncGenerator[AbstractConnection, Any]:
99
153
  if not self.connection_pool:
100
- async with await aio_pika.connect_robust(self.url) as connection:
154
+ async with await aio_pika.connect(self.url) as connection:
101
155
  yield connection
102
156
  else:
103
157
 
@@ -124,7 +178,11 @@ class AIOPikaConnectionFactory(MessageBusConnectionFactory):
124
178
  await tx.select()
125
179
 
126
180
  try:
127
- yield AIOPikaMessagePublisher(channel, exchange_name=self.exchange)
181
+ yield AIOPikaMessagePublisher(
182
+ channel,
183
+ exchange_name=self.exchange,
184
+ message_broker_backend=self.message_broker_backend,
185
+ )
128
186
  await tx.commit()
129
187
  except Exception as e:
130
188
  await tx.rollback()