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.
- README.md +121 -0
- jararaca/__init__.py +267 -15
- jararaca/__main__.py +4 -0
- jararaca/broker_backend/__init__.py +106 -0
- jararaca/broker_backend/mapper.py +25 -0
- jararaca/broker_backend/redis_broker_backend.py +168 -0
- jararaca/cli.py +840 -103
- jararaca/common/__init__.py +3 -0
- jararaca/core/__init__.py +3 -0
- jararaca/core/providers.py +4 -0
- jararaca/core/uow.py +55 -16
- jararaca/di.py +4 -0
- jararaca/files/entity.py.mako +4 -0
- jararaca/lifecycle.py +6 -2
- jararaca/messagebus/__init__.py +5 -1
- jararaca/messagebus/bus_message_controller.py +4 -0
- jararaca/messagebus/consumers/__init__.py +3 -0
- jararaca/messagebus/decorators.py +90 -85
- jararaca/messagebus/implicit_headers.py +49 -0
- jararaca/messagebus/interceptors/__init__.py +3 -0
- jararaca/messagebus/interceptors/aiopika_publisher_interceptor.py +95 -37
- jararaca/messagebus/interceptors/publisher_interceptor.py +42 -0
- jararaca/messagebus/message.py +31 -0
- jararaca/messagebus/publisher.py +47 -4
- jararaca/messagebus/worker.py +1615 -135
- jararaca/microservice.py +248 -36
- jararaca/observability/constants.py +7 -0
- jararaca/observability/decorators.py +177 -16
- jararaca/observability/fastapi_exception_handler.py +37 -0
- jararaca/observability/hooks.py +109 -0
- jararaca/observability/interceptor.py +8 -2
- jararaca/observability/providers/__init__.py +3 -0
- jararaca/observability/providers/otel.py +213 -18
- jararaca/persistence/base.py +40 -3
- jararaca/persistence/exports.py +4 -0
- jararaca/persistence/interceptors/__init__.py +3 -0
- jararaca/persistence/interceptors/aiosqa_interceptor.py +187 -23
- jararaca/persistence/interceptors/constants.py +5 -0
- jararaca/persistence/interceptors/decorators.py +50 -0
- jararaca/persistence/session.py +3 -0
- jararaca/persistence/sort_filter.py +4 -0
- jararaca/persistence/utilities.py +74 -32
- jararaca/presentation/__init__.py +3 -0
- jararaca/presentation/decorators.py +170 -82
- jararaca/presentation/exceptions.py +23 -0
- jararaca/presentation/hooks.py +4 -0
- jararaca/presentation/http_microservice.py +4 -0
- jararaca/presentation/server.py +120 -41
- jararaca/presentation/websocket/__init__.py +3 -0
- jararaca/presentation/websocket/base_types.py +4 -0
- jararaca/presentation/websocket/context.py +34 -4
- jararaca/presentation/websocket/decorators.py +8 -41
- jararaca/presentation/websocket/redis.py +280 -53
- jararaca/presentation/websocket/types.py +6 -2
- jararaca/presentation/websocket/websocket_interceptor.py +74 -23
- jararaca/reflect/__init__.py +3 -0
- jararaca/reflect/controller_inspect.py +81 -0
- jararaca/reflect/decorators.py +238 -0
- jararaca/reflect/metadata.py +76 -0
- jararaca/rpc/__init__.py +3 -0
- jararaca/rpc/http/__init__.py +101 -0
- jararaca/rpc/http/backends/__init__.py +14 -0
- jararaca/rpc/http/backends/httpx.py +43 -9
- jararaca/rpc/http/backends/otel.py +4 -0
- jararaca/rpc/http/decorators.py +378 -113
- jararaca/rpc/http/httpx.py +3 -0
- jararaca/scheduler/__init__.py +3 -0
- jararaca/scheduler/beat_worker.py +758 -0
- jararaca/scheduler/decorators.py +89 -28
- jararaca/scheduler/types.py +11 -0
- jararaca/tools/app_config/__init__.py +3 -0
- jararaca/tools/app_config/decorators.py +7 -19
- jararaca/tools/app_config/interceptor.py +10 -4
- jararaca/tools/typescript/__init__.py +3 -0
- jararaca/tools/typescript/decorators.py +120 -0
- jararaca/tools/typescript/interface_parser.py +1126 -189
- jararaca/utils/__init__.py +3 -0
- jararaca/utils/rabbitmq_utils.py +372 -0
- jararaca/utils/retry.py +148 -0
- jararaca-0.4.0a5.dist-info/LICENSE +674 -0
- jararaca-0.4.0a5.dist-info/LICENSES/GPL-3.0-or-later.txt +232 -0
- {jararaca-0.2.37a12.dist-info → jararaca-0.4.0a5.dist-info}/METADATA +14 -7
- jararaca-0.4.0a5.dist-info/RECORD +88 -0
- {jararaca-0.2.37a12.dist-info → jararaca-0.4.0a5.dist-info}/WHEEL +1 -1
- pyproject.toml +131 -0
- jararaca/messagebus/types.py +0 -30
- jararaca/scheduler/scheduler.py +0 -154
- jararaca/tools/metadata.py +0 -47
- jararaca-0.2.37a12.dist-info/RECORD +0 -63
- /jararaca-0.2.37a12.dist-info/LICENSE → /LICENSE +0 -0
- {jararaca-0.2.37a12.dist-info → jararaca-0.4.0a5.dist-info}/entry_points.txt +0 -0
jararaca/common/__init__.py
CHANGED
jararaca/core/__init__.py
CHANGED
jararaca/core/providers.py
CHANGED
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(
|
|
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__(
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
jararaca/files/entity.py.mako
CHANGED
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.
|
|
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.
|
|
64
|
+
logger.debug("Finalizing interceptors lifecycle")
|
|
61
65
|
for ctx in lifecycle_ctxs:
|
|
62
66
|
await ctx.__aexit__(None, None, None)
|
jararaca/messagebus/__init__.py
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
self.
|
|
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[
|
|
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
|
|
82
|
+
def post_decorated(self, subject: DECORATED_T) -> None:
|
|
83
83
|
|
|
84
84
|
def messagebus_factory(
|
|
85
|
-
instance:
|
|
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
|
-
|
|
89
|
+
schedulers: SCHEDULED_ACTION_DATA_SET = set()
|
|
91
90
|
|
|
92
|
-
|
|
93
|
-
|
|
91
|
+
assert inspect.isclass(
|
|
92
|
+
subject
|
|
93
|
+
), "MessageBusController can only be applied to classes"
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
continue
|
|
95
|
+
_, members = inspect_controller(subject)
|
|
97
96
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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)
|
|
@@ -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
|
|
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.
|
|
9
|
-
from jararaca.
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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:
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
)
|
|
49
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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(
|
|
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()
|