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
@@ -1,3 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
1
5
  import asyncio
2
6
  import inspect
3
7
  from contextlib import asynccontextmanager
@@ -13,9 +17,9 @@ from pydantic import BaseModel
13
17
  from jararaca.core.uow import UnitOfWorkContextProvider
14
18
  from jararaca.di import Container
15
19
  from jararaca.microservice import (
16
- AppContext,
17
20
  AppInterceptor,
18
21
  AppInterceptorWithLifecycle,
22
+ AppTransactionContext,
19
23
  Microservice,
20
24
  )
21
25
  from jararaca.presentation.decorators import (
@@ -25,7 +29,9 @@ from jararaca.presentation.decorators import (
25
29
  )
26
30
  from jararaca.presentation.websocket.context import (
27
31
  WebSocketConnectionManager,
32
+ WebSocketMessageSender,
28
33
  provide_ws_manager,
34
+ provide_ws_message_sender,
29
35
  )
30
36
  from jararaca.presentation.websocket.decorators import (
31
37
  INHERITS_WS_MESSAGE,
@@ -83,13 +89,24 @@ class WebSocketConnectionManagerImpl(WebSocketConnectionManager):
83
89
  await self.backend.broadcast(message)
84
90
 
85
91
  async def _broadcast_from_backend(self, message: bytes) -> None:
86
- for websocket in self.all_websockets:
92
+ # Create a copy of the websockets set to avoid modification during iteration
93
+ async with self.lock:
94
+ websockets_to_send = list(self.all_websockets)
95
+
96
+ disconnected_websockets: list[WebSocket] = []
97
+
98
+ for websocket in websockets_to_send:
87
99
  try:
88
100
  if websocket.client_state == WebSocketState.CONNECTED:
89
101
  await websocket.send_bytes(message)
90
102
  except WebSocketDisconnect:
91
- async with self.lock: # TODO: check if this can cause concurrency slowdown issues
92
- self.all_websockets.remove(websocket)
103
+ disconnected_websockets.append(websocket)
104
+
105
+ # Clean up disconnected websockets in a single lock acquisition
106
+ if disconnected_websockets:
107
+ async with self.lock:
108
+ for websocket in disconnected_websockets:
109
+ self.all_websockets.discard(websocket)
93
110
 
94
111
  async def send(self, rooms: list[str], message: WebSocketMessageBase) -> None:
95
112
 
@@ -101,16 +118,28 @@ class WebSocketConnectionManagerImpl(WebSocketConnectionManager):
101
118
  )
102
119
 
103
120
  async def _send_from_backend(self, rooms: list[str], message: bytes) -> None:
121
+ # Create a copy of room memberships to avoid modification during iteration
104
122
  async with self.lock:
105
- for room in rooms:
106
- for websocket in self.rooms.get(room, set()):
107
- try:
108
- if websocket.client_state == WebSocketState.CONNECTED:
109
- await websocket.send_bytes(message)
110
- except WebSocketDisconnect:
111
- async with self.lock:
112
- if websocket in self.rooms[room]:
113
- self.rooms[room].remove(websocket)
123
+ room_websockets: dict[str, list[WebSocket]] = {
124
+ room: list(self.rooms.get(room, set())) for room in rooms
125
+ }
126
+
127
+ disconnected_by_room: dict[str, list[WebSocket]] = {room: [] for room in rooms}
128
+
129
+ for room, websockets in room_websockets.items():
130
+ for websocket in websockets:
131
+ try:
132
+ if websocket.client_state == WebSocketState.CONNECTED:
133
+ await websocket.send_bytes(message)
134
+ except WebSocketDisconnect:
135
+ disconnected_by_room[room].append(websocket)
136
+
137
+ # Clean up disconnected websockets in a single lock acquisition
138
+ async with self.lock:
139
+ for room, disconnected_websockets in disconnected_by_room.items():
140
+ if room in self.rooms:
141
+ for websocket in disconnected_websockets:
142
+ self.rooms[room].discard(websocket)
114
143
 
115
144
  async def join(self, rooms: list[str], websocket: WebSocket) -> None:
116
145
  for room in rooms:
@@ -127,9 +156,23 @@ class WebSocketConnectionManagerImpl(WebSocketConnectionManager):
127
156
  # async def setup_consumer(self, websocket: WebSocket) -> None: ...
128
157
 
129
158
 
159
+ class StagingWebSocketMessageSender(WebSocketMessageSender):
160
+
161
+ def __init__(self, connection: WebSocketConnectionManager) -> None:
162
+ self.connection = connection
163
+ self.staged_messages: list[tuple[list[str], WebSocketMessageBase]] = []
164
+
165
+ async def send(self, rooms: list[str], message: WebSocketMessageBase) -> None:
166
+ self.staged_messages.append((rooms, message))
167
+
168
+ async def flush(self) -> None:
169
+ for rooms, message in self.staged_messages:
170
+ await self.connection.send(rooms, message)
171
+ self.staged_messages.clear()
172
+
173
+
130
174
  class WebSocketInterceptor(AppInterceptor, AppInterceptorWithLifecycle):
131
175
  """
132
- @Deprecated
133
176
  WebSocketInterceptor is responsible for managing WebSocket connections and
134
177
  intercepting WebSocket requests within the application. It integrates with
135
178
  the application's lifecycle and provides a router for WebSocket endpoints.
@@ -169,10 +212,18 @@ class WebSocketInterceptor(AppInterceptor, AppInterceptorWithLifecycle):
169
212
  self.shutdown_event.set()
170
213
 
171
214
  @asynccontextmanager
172
- async def intercept(self, app_context: AppContext) -> AsyncGenerator[None, None]:
215
+ async def intercept(
216
+ self, app_context: AppTransactionContext
217
+ ) -> AsyncGenerator[None, None]:
173
218
 
174
- with provide_ws_manager(self.connection_manager):
219
+ staging_ws_messages_sender = StagingWebSocketMessageSender(
220
+ self.connection_manager
221
+ )
222
+ with provide_ws_manager(self.connection_manager), provide_ws_message_sender(
223
+ staging_ws_messages_sender
224
+ ):
175
225
  yield
226
+ await staging_ws_messages_sender.flush()
176
227
 
177
228
  # def __wrap_with_uow_context_provider(
178
229
  # self, uow: UnitOfWorkContextProvider, func: Callable[..., Any]
@@ -198,13 +249,15 @@ class WebSocketInterceptor(AppInterceptor, AppInterceptorWithLifecycle):
198
249
 
199
250
  for controller_type in app.controllers:
200
251
 
201
- rest_controller = RestController.get_controller(controller_type)
252
+ rest_controller = RestController.get_last(controller_type)
202
253
  controller: Any = container.get_by_type(controller_type)
203
254
 
204
- members = inspect.getmembers(controller_type, predicate=inspect.isfunction)
255
+ members = inspect.getmembers_static(
256
+ controller_type, predicate=inspect.isfunction
257
+ )
205
258
 
206
259
  for name, member in members:
207
- if (ws_endpoint := WebSocketEndpoint.get(member)) is not None:
260
+ if (ws_endpoint := WebSocketEndpoint.get_last(member)) is not None:
208
261
  final_path = (
209
262
  rest_controller.path + ws_endpoint.path
210
263
  if rest_controller
@@ -212,7 +265,7 @@ class WebSocketInterceptor(AppInterceptor, AppInterceptorWithLifecycle):
212
265
  )
213
266
 
214
267
  route_dependencies: list[Depends] = []
215
- for middlewares_by_hook in UseMiddleware.get_middlewares(
268
+ for middlewares_by_hook in UseMiddleware.get(
216
269
  getattr(controller_type, name)
217
270
  ):
218
271
  middleware_instance = container.get_by_type(
@@ -222,9 +275,7 @@ class WebSocketInterceptor(AppInterceptor, AppInterceptorWithLifecycle):
222
275
  Depends(middleware_instance.intercept)
223
276
  )
224
277
 
225
- for dependency in UseDependency.get_dependencies(
226
- getattr(controller_type, name)
227
- ):
278
+ for dependency in UseDependency.get(getattr(controller_type, name)):
228
279
  route_dependencies.append(DependsF(dependency.dependency))
229
280
 
230
281
  api_router.add_api_websocket_route(
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
@@ -0,0 +1,81 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import inspect
6
+ from dataclasses import dataclass
7
+ from typing import Any, Callable, Mapping, Tuple, Type
8
+
9
+ from frozendict import frozendict
10
+
11
+ from jararaca.reflect.metadata import SetMetadata, TransactionMetadata
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class ControllerReflect:
16
+
17
+ controller_class: Type[Any]
18
+ metadata: Mapping[str, TransactionMetadata]
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ControllerMemberReflect:
23
+ controller_reflect: ControllerReflect
24
+ member_function: Callable[..., Any]
25
+ metadata: Mapping[str, TransactionMetadata]
26
+
27
+
28
+ def inspect_controller(
29
+ controller: Type[Any],
30
+ ) -> Tuple[ControllerReflect, Mapping[str, ControllerMemberReflect]]:
31
+ """
32
+ Inspect a controller class to extract its metadata and member functions.
33
+
34
+ Args:
35
+ controller (Type[Any]): The controller class to inspect.
36
+
37
+ Returns:
38
+ Tuple[ControllerReflect, list[ControllerMemberReflect]]: A tuple containing the controller reflect and a list of member reflects.
39
+ """
40
+ controller_metadata_list = SetMetadata.get(controller)
41
+
42
+ controller_metadata_map = frozendict(
43
+ {
44
+ metadata.key: TransactionMetadata(
45
+ value=metadata.value, inherited_from_controller=False
46
+ )
47
+ for metadata in controller_metadata_list
48
+ }
49
+ )
50
+
51
+ controller_reflect = ControllerReflect(
52
+ controller_class=controller, metadata=controller_metadata_map
53
+ )
54
+
55
+ members = {
56
+ name: ControllerMemberReflect(
57
+ controller_reflect=controller_reflect,
58
+ member_function=member,
59
+ metadata=frozendict(
60
+ {
61
+ **{
62
+ key: TransactionMetadata(
63
+ value=value.value, inherited_from_controller=True
64
+ )
65
+ for key, value in controller_metadata_map.items()
66
+ },
67
+ **{
68
+ metadata.key: TransactionMetadata(
69
+ value=metadata.value, inherited_from_controller=False
70
+ )
71
+ for metadata in SetMetadata.get(member)
72
+ },
73
+ }
74
+ ),
75
+ )
76
+ for name, member in inspect.getmembers_static(
77
+ controller, predicate=inspect.isfunction
78
+ )
79
+ }
80
+
81
+ return controller_reflect, members
@@ -0,0 +1,238 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ from typing import Any, Callable, Self, TypedDict, TypeVar, cast
6
+
7
+ DECORATED_T = TypeVar("DECORATED_T", bound="Callable[..., Any] | type")
8
+
9
+
10
+ S = TypeVar("S", bound="StackableDecorator")
11
+
12
+
13
+ class DecoratorMetadata(TypedDict):
14
+ decorators: "list[StackableDecorator]"
15
+ decorators_by_type: "dict[Any, list[StackableDecorator]]"
16
+
17
+
18
+ class StackableDecorator:
19
+ _ATTR_NAME: str = "__jararaca_stackable_decorator__"
20
+
21
+ def __call__(self, subject: DECORATED_T) -> DECORATED_T:
22
+ self.pre_decorated(subject)
23
+ self.register(subject, self)
24
+ self.post_decorated(subject)
25
+ return subject
26
+
27
+ @classmethod
28
+ def decorator_key(cls) -> Any:
29
+ return cls
30
+
31
+ @classmethod
32
+ def get_or_set_metadata(cls, subject: Any) -> DecoratorMetadata:
33
+ if cls._ATTR_NAME not in subject.__dict__:
34
+ setattr(
35
+ subject,
36
+ cls._ATTR_NAME,
37
+ DecoratorMetadata(decorators=[], decorators_by_type={}),
38
+ )
39
+ return cast(DecoratorMetadata, getattr(subject, cls._ATTR_NAME))
40
+
41
+ @classmethod
42
+ def get_metadata(cls, subject: Any) -> DecoratorMetadata | None:
43
+ if hasattr(subject, cls._ATTR_NAME):
44
+ return cast(DecoratorMetadata, getattr(subject, cls._ATTR_NAME))
45
+ return None
46
+
47
+ @classmethod
48
+ def register(cls, subject: Any, decorator: "StackableDecorator") -> None:
49
+ if not cls._ATTR_NAME:
50
+ raise NotImplementedError("Subclasses must define _ATTR_NAME")
51
+
52
+ metadata = cls.get_or_set_metadata(subject)
53
+ metadata["decorators"].append(decorator)
54
+ metadata["decorators_by_type"].setdefault(cls.decorator_key(), []).append(
55
+ decorator
56
+ )
57
+
58
+ @classmethod
59
+ def get(cls, subject: Any) -> list[Self]:
60
+ metadata = cls.get_metadata(subject)
61
+ if metadata is None:
62
+ return []
63
+
64
+ if cls is StackableDecorator:
65
+ return cast(list[Self], metadata["decorators"])
66
+ else:
67
+ return cast(
68
+ list[Self], metadata["decorators_by_type"].get(cls.decorator_key(), [])
69
+ )
70
+
71
+ @classmethod
72
+ def extract_list(cls, subject: Any) -> list[Self]:
73
+ metadata = cls.get_metadata(subject)
74
+ if metadata is None:
75
+ return []
76
+
77
+ if cls is StackableDecorator:
78
+ return cast(list[Self], metadata["decorators"])
79
+ else:
80
+ return cast(
81
+ list[Self], metadata["decorators_by_type"].get(cls.decorator_key(), [])
82
+ )
83
+
84
+ @classmethod
85
+ def get_fisrt(cls, subject: Any) -> Self | None:
86
+ decorators = cls.get(subject)
87
+ if decorators:
88
+ return decorators[0]
89
+ return None
90
+
91
+ @classmethod
92
+ def get_last(cls, subject: Any) -> Self | None:
93
+ decorators = cls.get(subject)
94
+ if decorators:
95
+ return decorators[-1]
96
+ return None
97
+
98
+ def pre_decorated(self, subject: DECORATED_T) -> None:
99
+ """
100
+ Hook method called before the subject is decorated.
101
+ Can be overridden by subclasses to perform additional setup.
102
+ """
103
+
104
+ def post_decorated(self, subject: DECORATED_T) -> None:
105
+ """
106
+ Hook method called after the subject has been decorated.
107
+ Can be overridden by subclasses to perform additional setup.
108
+ """
109
+
110
+ @classmethod
111
+ def get_all_from_type(cls, subject_type: type, inherit: bool = True) -> list[Self]:
112
+ """
113
+ Retrieve all decorators of this type from the given class type.
114
+ """
115
+ return resolve_class_decorators(subject_type, cls, inherit)
116
+
117
+ @classmethod
118
+ def get_bound_from_type(
119
+ cls, subject_type: type, inherit: bool = True, last: bool = False
120
+ ) -> Self | None:
121
+ """
122
+ Retrieve the first or last decorator of this type from the given class type.
123
+ """
124
+ return resolve_bound_class_decorators(subject_type, cls, inherit, last=last)
125
+
126
+ @classmethod
127
+ def get_all_from_method(
128
+ cls, cls_subject_type: type, method_name: str, inherit: bool = True
129
+ ) -> list[Self]:
130
+ """
131
+ Retrieve all decorators of this type from the given method.
132
+ """
133
+ return resolve_method_decorators(cls_subject_type, method_name, cls, inherit)
134
+
135
+ @classmethod
136
+ def get_bound_from_method(
137
+ cls,
138
+ cls_subject_type: type,
139
+ method_name: str,
140
+ inherit: bool = True,
141
+ last: bool = True,
142
+ ) -> Self | None:
143
+ """
144
+ Retrieve the first or last decorator of this type from the given method.
145
+ """
146
+ return resolve_bound_method_decorator(
147
+ cls_subject_type, method_name, cls, inherit, last=last
148
+ )
149
+
150
+
151
+ def resolve_class_decorators(
152
+ subject: Any, decorator_cls: type[S], inherit: bool = True
153
+ ) -> list[S]:
154
+ """
155
+ Resolve decorators for a class or instance, optionally inheriting from base classes.
156
+ """
157
+ if not inherit:
158
+ return decorator_cls.get(subject)
159
+
160
+ # If subject is an instance, get its class
161
+ cls = subject if isinstance(subject, type) else type(subject)
162
+
163
+ collected: list[S] = []
164
+ # Iterate MRO in reverse to apply base class decorators first
165
+ for base in reversed(cls.mro()):
166
+ collected.extend(decorator_cls.get(base))
167
+
168
+ return collected
169
+
170
+
171
+ def resolve_bound_class_decorators(
172
+ subject: Any, decorator_cls: type[S], inherit: bool = True, last: bool = False
173
+ ) -> S | None:
174
+ """
175
+ Retrieve the first or last decorator of a given type from a class or instance,
176
+ optionally inheriting from base classes.
177
+ """
178
+ decorators = resolve_class_decorators(subject, decorator_cls, inherit)
179
+ if not decorators:
180
+ return None
181
+ return decorators[-1] if last else decorators[0]
182
+
183
+
184
+ def resolve_method_decorators(
185
+ cls: type,
186
+ method_name: str,
187
+ decorator_cls: type[S],
188
+ inherit: bool = True,
189
+ ) -> list[S]:
190
+ """
191
+ Resolve decorators for a method, optionally inheriting from base classes.
192
+ """
193
+ if not inherit:
194
+ method = getattr(cls, method_name, None)
195
+ if method:
196
+ return decorator_cls.get(method)
197
+ return []
198
+
199
+ collected: list[S] = []
200
+ # Iterate MRO in reverse to apply base class decorators first
201
+ for base in reversed(cls.mro()):
202
+ if method_name in base.__dict__:
203
+ method = base.__dict__[method_name]
204
+ # Handle staticmethod/classmethod wrappers if necessary?
205
+ # Usually decorators are on the underlying function or the wrapper.
206
+ # getattr(cls, name) returns the bound/unbound method.
207
+ # base.__dict__[name] returns the raw object (function or descriptor).
208
+
209
+ # If it's a staticmethod object, it has no __dict__ usually, but we attach attributes to it?
210
+ # The decorator runs on the function BEFORE it becomes a staticmethod.
211
+ # So the attribute should be on the function object.
212
+
213
+ # However, when we access it via base.__dict__[method_name], we might get the staticmethod object.
214
+ # We need to unwrap it if possible.
215
+
216
+ if isinstance(method, (staticmethod, classmethod)):
217
+ method = method.__func__
218
+
219
+ collected.extend(decorator_cls.get(method))
220
+
221
+ return collected
222
+
223
+
224
+ def resolve_bound_method_decorator(
225
+ cls: type,
226
+ method_name: str,
227
+ decorator_cls: type[S],
228
+ inherit: bool = True,
229
+ last: bool = False,
230
+ ) -> S | None:
231
+ """
232
+ Retrieve the first or last decorator of a given type from a method,
233
+ optionally inheriting from base classes.
234
+ """
235
+ decorators = resolve_method_decorators(cls, method_name, decorator_cls, inherit)
236
+ if not decorators:
237
+ return None
238
+ return decorators[-1] if last else decorators[0]
@@ -0,0 +1,76 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ from contextlib import contextmanager, suppress
6
+ from contextvars import ContextVar
7
+ from dataclasses import dataclass
8
+ from typing import Any, Awaitable, Callable, Generator, Mapping, TypeVar, Union
9
+
10
+ from jararaca.reflect.decorators import StackableDecorator
11
+
12
+ DECORATED = TypeVar("DECORATED", bound=Union[Callable[..., Awaitable[Any]], type])
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class TransactionMetadata:
17
+ value: Any
18
+ """The value of the metadata."""
19
+
20
+ inherited_from_controller: bool
21
+ """Whether the metadata was inherited from a parent class."""
22
+
23
+
24
+ metadata_context: ContextVar[Mapping[str, TransactionMetadata]] = ContextVar(
25
+ "metadata_context"
26
+ )
27
+
28
+
29
+ def get_metadata(key: str) -> TransactionMetadata | None:
30
+ return metadata_context.get({}).get(key)
31
+
32
+
33
+ def get_metadata_value(key: str, default: Any | None = None) -> Any:
34
+ metadata = get_metadata(key)
35
+ if metadata is None:
36
+ return default
37
+ return metadata.value
38
+
39
+
40
+ def get_all_metadata() -> Mapping[str, TransactionMetadata]:
41
+ return metadata_context.get({})
42
+
43
+
44
+ @contextmanager
45
+ def start_transaction_metadata_context(
46
+ metadata: Mapping[str, TransactionMetadata],
47
+ ) -> Generator[None, Any, None]:
48
+
49
+ current_metadata = metadata_context.get({})
50
+
51
+ token = metadata_context.set({**current_metadata, **metadata})
52
+ try:
53
+ yield
54
+ finally:
55
+ with suppress(ValueError):
56
+ metadata_context.reset(token)
57
+
58
+
59
+ @contextmanager
60
+ def start_providing_metadata(
61
+ **metadata: Any,
62
+ ) -> Generator[None, Any, None]:
63
+
64
+ with start_transaction_metadata_context(
65
+ {
66
+ key: TransactionMetadata(value=value, inherited_from_controller=False)
67
+ for key, value in metadata.items()
68
+ }
69
+ ):
70
+ yield
71
+
72
+
73
+ class SetMetadata(StackableDecorator):
74
+ def __init__(self, key: str, value: Any) -> None:
75
+ self.key = key
76
+ self.value = value
jararaca/rpc/__init__.py CHANGED
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
@@ -0,0 +1,101 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ # HTTP RPC Module - Complete REST Client Implementation
6
+ """
7
+ This module provides a complete REST client implementation with support for:
8
+ - HTTP method decorators (@Get, @Post, @Put, @Patch, @Delete)
9
+ - Request parameter decorators (@Query, @Header, @PathParam, @Body, @FormData, @File)
10
+ - Configuration decorators (@Timeout, @Retry, @ContentType)
11
+ - Authentication middleware (BearerTokenAuth, BasicAuth, ApiKeyAuth)
12
+ - Caching and response middleware
13
+ - Request/response hooks for customization
14
+ """
15
+
16
+ from .backends.httpx import HTTPXHttpRPCAsyncBackend
17
+ from .decorators import ( # HTTP Method decorators; Request parameter decorators; Configuration decorators; Client builder and core classes; Authentication classes; Middleware and hooks; Configuration classes; Data structures; Error handlers; Exceptions
18
+ ApiKeyAuth,
19
+ AuthenticationMiddleware,
20
+ BasicAuth,
21
+ BearerTokenAuth,
22
+ Body,
23
+ CacheMiddleware,
24
+ ContentType,
25
+ Delete,
26
+ File,
27
+ FormData,
28
+ Get,
29
+ GlobalHttpErrorHandler,
30
+ Header,
31
+ HttpMapping,
32
+ HttpRpcClientBuilder,
33
+ HttpRPCRequest,
34
+ HttpRPCResponse,
35
+ Patch,
36
+ PathParam,
37
+ Post,
38
+ Put,
39
+ Query,
40
+ RequestAttribute,
41
+ RequestHook,
42
+ ResponseHook,
43
+ ResponseMiddleware,
44
+ RestClient,
45
+ Retry,
46
+ RetryConfig,
47
+ RouteHttpErrorHandler,
48
+ RPCRequestNetworkError,
49
+ RPCUnhandleError,
50
+ Timeout,
51
+ TimeoutException,
52
+ )
53
+
54
+ __all__ = [
55
+ # HTTP Method decorators
56
+ "Get",
57
+ "Post",
58
+ "Put",
59
+ "Patch",
60
+ "Delete",
61
+ # Request parameter decorators
62
+ "Query",
63
+ "Header",
64
+ "PathParam",
65
+ "Body",
66
+ "FormData",
67
+ "File",
68
+ # Configuration decorators
69
+ "Timeout",
70
+ "Retry",
71
+ "ContentType",
72
+ # Client builder and core classes
73
+ "RestClient",
74
+ "HttpRpcClientBuilder",
75
+ "HttpMapping",
76
+ "RequestAttribute",
77
+ # Authentication classes
78
+ "BearerTokenAuth",
79
+ "BasicAuth",
80
+ "ApiKeyAuth",
81
+ "AuthenticationMiddleware",
82
+ # Middleware and hooks
83
+ "CacheMiddleware",
84
+ "ResponseMiddleware",
85
+ "RequestHook",
86
+ "ResponseHook",
87
+ # Configuration classes
88
+ "RetryConfig",
89
+ # Data structures
90
+ "HttpRPCRequest",
91
+ "HttpRPCResponse",
92
+ # Error handlers
93
+ "GlobalHttpErrorHandler",
94
+ "RouteHttpErrorHandler",
95
+ # Exceptions
96
+ "RPCRequestNetworkError",
97
+ "RPCUnhandleError",
98
+ "TimeoutException",
99
+ # Backend
100
+ "HTTPXHttpRPCAsyncBackend",
101
+ ]
@@ -0,0 +1,14 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ # HTTP RPC Backends
6
+ """
7
+ Backend implementations for HTTP RPC clients.
8
+ """
9
+
10
+ from .httpx import HTTPXHttpRPCAsyncBackend
11
+
12
+ __all__ = [
13
+ "HTTPXHttpRPCAsyncBackend",
14
+ ]