jararaca 0.3.11a16__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 (88) hide show
  1. README.md +121 -0
  2. jararaca/__init__.py +184 -12
  3. jararaca/__main__.py +4 -0
  4. jararaca/broker_backend/__init__.py +4 -0
  5. jararaca/broker_backend/mapper.py +4 -0
  6. jararaca/broker_backend/redis_broker_backend.py +9 -3
  7. jararaca/cli.py +272 -47
  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 +41 -7
  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 +4 -0
  16. jararaca/messagebus/bus_message_controller.py +4 -0
  17. jararaca/messagebus/consumers/__init__.py +3 -0
  18. jararaca/messagebus/decorators.py +33 -67
  19. jararaca/messagebus/implicit_headers.py +49 -0
  20. jararaca/messagebus/interceptors/__init__.py +3 -0
  21. jararaca/messagebus/interceptors/aiopika_publisher_interceptor.py +13 -4
  22. jararaca/messagebus/interceptors/publisher_interceptor.py +4 -0
  23. jararaca/messagebus/message.py +4 -0
  24. jararaca/messagebus/publisher.py +6 -0
  25. jararaca/messagebus/worker.py +850 -383
  26. jararaca/microservice.py +110 -1
  27. jararaca/observability/constants.py +7 -0
  28. jararaca/observability/decorators.py +170 -13
  29. jararaca/observability/fastapi_exception_handler.py +37 -0
  30. jararaca/observability/hooks.py +109 -0
  31. jararaca/observability/interceptor.py +4 -0
  32. jararaca/observability/providers/__init__.py +3 -0
  33. jararaca/observability/providers/otel.py +202 -11
  34. jararaca/persistence/base.py +38 -2
  35. jararaca/persistence/exports.py +4 -0
  36. jararaca/persistence/interceptors/__init__.py +3 -0
  37. jararaca/persistence/interceptors/aiosqa_interceptor.py +86 -73
  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 +50 -20
  43. jararaca/presentation/__init__.py +3 -0
  44. jararaca/presentation/decorators.py +88 -86
  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 +97 -45
  49. jararaca/presentation/websocket/__init__.py +3 -0
  50. jararaca/presentation/websocket/base_types.py +4 -0
  51. jararaca/presentation/websocket/context.py +4 -0
  52. jararaca/presentation/websocket/decorators.py +8 -41
  53. jararaca/presentation/websocket/redis.py +280 -53
  54. jararaca/presentation/websocket/types.py +4 -0
  55. jararaca/presentation/websocket/websocket_interceptor.py +46 -19
  56. jararaca/reflect/__init__.py +3 -0
  57. jararaca/reflect/controller_inspect.py +16 -10
  58. jararaca/reflect/decorators.py +238 -0
  59. jararaca/reflect/metadata.py +34 -25
  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 +521 -105
  69. jararaca/scheduler/decorators.py +15 -22
  70. jararaca/scheduler/types.py +4 -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 +6 -2
  74. jararaca/tools/typescript/__init__.py +3 -0
  75. jararaca/tools/typescript/decorators.py +120 -0
  76. jararaca/tools/typescript/interface_parser.py +1074 -173
  77. jararaca/utils/__init__.py +3 -0
  78. jararaca/utils/rabbitmq_utils.py +65 -39
  79. jararaca/utils/retry.py +10 -3
  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.3.11a16.dist-info → jararaca-0.4.0a5.dist-info}/METADATA +11 -7
  83. jararaca-0.4.0a5.dist-info/RECORD +88 -0
  84. {jararaca-0.3.11a16.dist-info → jararaca-0.4.0a5.dist-info}/WHEEL +1 -1
  85. pyproject.toml +131 -0
  86. jararaca-0.3.11a16.dist-info/RECORD +0 -74
  87. /jararaca-0.3.11a16.dist-info/LICENSE → /LICENSE +0 -0
  88. {jararaca-0.3.11a16.dist-info → jararaca-0.4.0a5.dist-info}/entry_points.txt +0 -0
@@ -1,5 +1,10 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
1
5
  import asyncio
2
6
  import logging
7
+ import math
3
8
  from datetime import UTC, date, datetime
4
9
  from functools import reduce
5
10
  from typing import (
@@ -29,6 +34,7 @@ from jararaca.persistence.base import (
29
34
  BaseEntity,
30
35
  recursive_get_dict,
31
36
  )
37
+ from jararaca.persistence.interceptors.aiosqa_interceptor import use_session
32
38
  from jararaca.persistence.sort_filter import (
33
39
  FilterModel,
34
40
  FilterRuleApplier,
@@ -61,7 +67,7 @@ class DatedEntity(BaseEntity):
61
67
  DateTime(timezone=True), nullable=False, default=nowutc
62
68
  )
63
69
  updated_at: Mapped[datetime] = mapped_column(
64
- DateTime(timezone=True), nullable=False, default=nowutc
70
+ DateTime(timezone=True), nullable=False, default=nowutc, onupdate=nowutc
65
71
  )
66
72
 
67
73
 
@@ -283,11 +289,13 @@ class QueryOperations(Generic[QUERY_FILTER_T, QUERY_ENTITY_T]):
283
289
  def __init__(
284
290
  self,
285
291
  entity_type: Type[QUERY_ENTITY_T],
286
- session_provider: Callable[[], AsyncSession],
287
- filters_functions: list[QueryInjector],
292
+ session_provider: Callable[[], AsyncSession] = use_session,
293
+ filters_functions: list[QueryInjector] = [],
294
+ *,
288
295
  unique: bool = False,
289
296
  sort_rule_applier: SortRuleApplier | None = None,
290
297
  filter_rule_applier: FilterRuleApplier | None = None,
298
+ base_statement: Select[Tuple[QUERY_ENTITY_T]] | None = None,
291
299
  ) -> None:
292
300
  self.entity_type: type[QUERY_ENTITY_T] = entity_type
293
301
  self.session_provider = session_provider
@@ -295,6 +303,7 @@ class QueryOperations(Generic[QUERY_FILTER_T, QUERY_ENTITY_T]):
295
303
  self.unique = unique
296
304
  self.sort_rule_applier = sort_rule_applier
297
305
  self.filter_rule_applier = filter_rule_applier
306
+ self.base_statement = base_statement
298
307
 
299
308
  @property
300
309
  def session(self) -> AsyncSession:
@@ -303,9 +312,15 @@ class QueryOperations(Generic[QUERY_FILTER_T, QUERY_ENTITY_T]):
303
312
  async def query(
304
313
  self,
305
314
  filter: QUERY_FILTER_T,
315
+ *,
306
316
  interceptors: list[
307
317
  Callable[[Select[Tuple[QUERY_ENTITY_T]]], Select[Tuple[QUERY_ENTITY_T]]]
308
318
  ] = [],
319
+ base_statement: (
320
+ Callable[[Select[Tuple[QUERY_ENTITY_T]]], Select[Tuple[QUERY_ENTITY_T]]]
321
+ | Select[Tuple[QUERY_ENTITY_T]]
322
+ | None
323
+ ) = None,
309
324
  ) -> "Paginated[QUERY_ENTITY_T]":
310
325
  """
311
326
  Executes a query with the provided filter and interceptors.
@@ -316,8 +331,15 @@ class QueryOperations(Generic[QUERY_FILTER_T, QUERY_ENTITY_T]):
316
331
  Paginated[QUERY_ENTITY_T]: A paginated result containing the items and metadata.
317
332
  """
318
333
 
334
+ initial_statement = self.base_statement or select(self.entity_type)
335
+
336
+ if base_statement and callable(base_statement):
337
+ initial_statement = base_statement(initial_statement)
338
+ elif base_statement and isinstance(base_statement, Select):
339
+ initial_statement = base_statement
340
+
319
341
  tier_one_filtered_query = self.generate_filtered_query(
320
- filter, select(self.entity_type)
342
+ filter, initial_statement
321
343
  )
322
344
 
323
345
  tier_two_filtered_query = reduce(
@@ -340,30 +362,38 @@ class QueryOperations(Generic[QUERY_FILTER_T, QUERY_ENTITY_T]):
340
362
  )
341
363
  )
342
364
 
343
- unpaginated_total = (
344
- await self.session.execute(
345
- select(func.count()).select_from(tier_two_filtered_query.subquery())
346
- )
347
- ).scalar_one()
348
-
349
- paginated_query = tier_two_filtered_query.limit(filter.page_size).offset(
365
+ paginated_query = tier_two_filtered_query.add_columns(
366
+ func.count().over().label("total_count")
367
+ )
368
+ paginated_query = paginated_query.limit(filter.page_size).offset(
350
369
  (filter.page) * filter.page_size
351
370
  )
352
371
 
353
- paginated_total = (
354
- await self.session.execute(
355
- select(func.count()).select_from(paginated_query.subquery())
356
- )
357
- ).scalar_one()
358
-
359
372
  result = await self.session.execute(paginated_query)
360
- result_scalars = list(self.judge_unique(result).scalars().all())
373
+ result = self.judge_unique(result)
374
+ rows = result.all()
375
+
376
+ if rows:
377
+ unpaginated_total = rows[0].total_count
378
+ result_scalars = [row[0] for row in rows]
379
+ else:
380
+ result_scalars = []
381
+ if filter.page == 0:
382
+ unpaginated_total = 0
383
+ else:
384
+ unpaginated_total = (
385
+ await self.session.execute(
386
+ select(func.count()).select_from(
387
+ tier_two_filtered_query.subquery()
388
+ )
389
+ )
390
+ ).scalar_one()
361
391
 
362
392
  return Paginated(
363
393
  items=result_scalars,
364
- total=paginated_total,
394
+ total=len(result_scalars),
365
395
  unpaginated_total=unpaginated_total,
366
- total_pages=int(unpaginated_total / filter.page_size) + 1,
396
+ total_pages=math.ceil(unpaginated_total / filter.page_size),
367
397
  )
368
398
 
369
399
  def judge_unique(
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
@@ -1,7 +1,12 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import inspect
1
6
  from contextlib import contextmanager
2
7
  from contextvars import ContextVar
3
8
  from functools import wraps
4
- from typing import Any, Awaitable, Callable, Literal, Protocol, TypeVar, cast
9
+ from typing import Any, Awaitable, Callable, Literal, Mapping, Protocol, TypeVar
5
10
 
6
11
  from fastapi import APIRouter
7
12
  from fastapi import Depends as DependsF
@@ -15,12 +20,13 @@ from jararaca.reflect.controller_inspect import (
15
20
  ControllerMemberReflect,
16
21
  inspect_controller,
17
22
  )
23
+ from jararaca.reflect.decorators import DECORATED_T, StackableDecorator
18
24
 
25
+ DECORATED_TYPE = TypeVar("DECORATED_TYPE", bound=Any)
19
26
  DECORATED_FUNC = TypeVar("DECORATED_FUNC", bound=Callable[..., Any])
20
- DECORATED_CLASS = TypeVar("DECORATED_CLASS", bound=Any)
21
27
 
22
28
 
23
- ControllerOptions = dict[str, Any]
29
+ ControllerOptions = Mapping[str, Any]
24
30
 
25
31
 
26
32
  class RouterFactory(Protocol):
@@ -28,20 +34,25 @@ class RouterFactory(Protocol):
28
34
  def __call__(self, lifecycle: AppLifecycle, instance: Any) -> APIRouter: ...
29
35
 
30
36
 
31
- class RestController:
37
+ class RestController(StackableDecorator):
32
38
  REST_CONTROLLER_ATTR = "__rest_controller__"
33
39
 
34
40
  def __init__(
35
41
  self,
36
42
  path: str = "",
43
+ *,
37
44
  options: ControllerOptions | None = None,
38
45
  middlewares: list[type[HttpMiddleware]] = [],
39
46
  router_factory: RouterFactory | None = None,
47
+ class_inherits_decorators: bool = True,
48
+ methods_inherit_decorators: bool = True,
40
49
  ) -> None:
41
50
  self.path = path
42
51
  self.options = options
43
52
  self.router_factory = router_factory
44
53
  self.middlewares = middlewares
54
+ self.class_inherits_decorators = class_inherits_decorators
55
+ self.methods_inherit_decorators = methods_inherit_decorators
45
56
 
46
57
  def get_router_factory(
47
58
  self,
@@ -50,12 +61,15 @@ class RestController:
50
61
  raise Exception("Router factory is not set")
51
62
  return self.router_factory
52
63
 
53
- def __call__(self, cls: type[DECORATED_CLASS]) -> type[DECORATED_CLASS]:
64
+ def post_decorated(self, subject: DECORATED_T) -> None:
54
65
 
55
66
  def router_factory(
56
67
  lifecycle: AppLifecycle,
57
- instance: DECORATED_CLASS,
68
+ instance: DECORATED_T,
58
69
  ) -> APIRouter:
70
+ assert inspect.isclass(
71
+ subject
72
+ ), "RestController can only be applied to classes"
59
73
  dependencies: list[Depends] = []
60
74
 
61
75
  for self_middleware_type in self.middlewares:
@@ -64,13 +78,19 @@ class RestController:
64
78
  )
65
79
  dependencies.append(Depends(middleware_instance.intercept))
66
80
 
67
- for middlewares_by_hook in UseMiddleware.get_middlewares(instance):
81
+ class_middlewares = UseMiddleware.get_all_from_type(
82
+ subject, self.class_inherits_decorators
83
+ )
84
+ for middlewares_by_hook in class_middlewares:
68
85
  middleware_instance = lifecycle.container.get_by_type(
69
86
  middlewares_by_hook.middleware
70
87
  )
71
88
  dependencies.append(Depends(middleware_instance.intercept))
72
89
 
73
- for dependency in UseDependency.get_dependencies(instance):
90
+ class_dependencies = UseDependency.get_all_from_type(
91
+ subject, self.class_inherits_decorators
92
+ )
93
+ for dependency in class_dependencies:
74
94
  dependencies.append(DependsF(dependency.dependency))
75
95
 
76
96
  router = APIRouter(
@@ -79,15 +99,23 @@ class RestController:
79
99
  **(self.options or {}),
80
100
  )
81
101
 
82
- controller, members = inspect_controller(cls)
102
+ controller, members = inspect_controller(subject)
83
103
 
84
104
  router_members = [
85
105
  (name, mapping, member)
86
106
  for name, member in members.items()
87
107
  if (
88
- mapping := (
89
- HttpMapping.get_http_mapping(member.member_function)
90
- or WebSocketEndpoint.get(member.member_function)
108
+ mapping := HttpMapping.get_bound_from_method(
109
+ subject,
110
+ name,
111
+ self.methods_inherit_decorators,
112
+ last=True,
113
+ )
114
+ or WebSocketEndpoint.get_bound_from_method(
115
+ subject,
116
+ name,
117
+ self.methods_inherit_decorators,
118
+ last=True,
91
119
  )
92
120
  )
93
121
  is not None
@@ -97,17 +125,22 @@ class RestController:
97
125
 
98
126
  for name, mapping, member in router_members:
99
127
  route_dependencies: list[Depends] = []
100
- for middlewares_by_hook in UseMiddleware.get_middlewares(
101
- getattr(instance, name)
102
- ):
128
+
129
+ method_middlewares = UseMiddleware.get_all_from_method(
130
+ subject, name, self.methods_inherit_decorators
131
+ )
132
+ for middlewares_by_hook in method_middlewares:
103
133
  middleware_instance = lifecycle.container.get_by_type(
104
134
  middlewares_by_hook.middleware
105
135
  )
106
136
  route_dependencies.append(Depends(middleware_instance.intercept))
107
137
 
108
- for dependency in UseDependency.get_dependencies(
109
- getattr(instance, name)
110
- ):
138
+ method_dependencies = UseDependency.get_all_from_method(
139
+ subject,
140
+ name,
141
+ self.methods_inherit_decorators,
142
+ )
143
+ for dependency in method_dependencies:
111
144
  route_dependencies.append(DependsF(dependency.dependency))
112
145
 
113
146
  instance_method = getattr(instance, name)
@@ -141,21 +174,6 @@ class RestController:
141
174
 
142
175
  self.router_factory = router_factory
143
176
 
144
- RestController.register(cls, self)
145
-
146
- return cls
147
-
148
- @staticmethod
149
- def register(cls: type[DECORATED_CLASS], controller: "RestController") -> None:
150
- setattr(cls, RestController.REST_CONTROLLER_ATTR, controller)
151
-
152
- @staticmethod
153
- def get_controller(cls: type[DECORATED_CLASS]) -> "RestController | None":
154
- if not hasattr(cls, RestController.REST_CONTROLLER_ATTR):
155
- return None
156
-
157
- return cast(RestController, getattr(cls, RestController.REST_CONTROLLER_ATTR))
158
-
159
177
 
160
178
  Options = dict[str, Any]
161
179
 
@@ -170,9 +188,8 @@ ResponseType = Literal[
170
188
  ]
171
189
 
172
190
 
173
- class HttpMapping:
191
+ class HttpMapping(StackableDecorator):
174
192
 
175
- HTTP_MAPPING_ATTR = "__http_mapping__"
176
193
  ORDER_COUNTER = 0
177
194
 
178
195
  def __init__(
@@ -190,24 +207,20 @@ class HttpMapping:
190
207
  HttpMapping.ORDER_COUNTER += 1
191
208
  self.order = HttpMapping.ORDER_COUNTER
192
209
 
193
- def __call__(self, func: DECORATED_FUNC) -> DECORATED_FUNC:
194
-
195
- HttpMapping.register(func, self)
196
-
197
- return func
210
+ @classmethod
211
+ def decorator_key(cls) -> "type[HttpMapping]":
212
+ return HttpMapping
198
213
 
199
- @staticmethod
200
- def register(func: DECORATED_FUNC, mapping: "HttpMapping") -> None:
214
+ # def __call__(self, func: DECORATED_FUNC) -> DECORATED_FUNC:
201
215
 
202
- setattr(func, HttpMapping.HTTP_MAPPING_ATTR, mapping)
216
+ # HttpMapping.register(func, self)
203
217
 
204
- @staticmethod
205
- def get_http_mapping(func: DECORATED_FUNC) -> "HttpMapping | None":
218
+ # return func
206
219
 
207
- if not hasattr(func, HttpMapping.HTTP_MAPPING_ATTR):
208
- return None
220
+ # @staticmethod
221
+ # def register(func: DECORATED_FUNC, mapping: "HttpMapping") -> None:
209
222
 
210
- return cast(HttpMapping, getattr(func, HttpMapping.HTTP_MAPPING_ATTR))
223
+ # setattr(func, HttpMapping.HTTP_MAPPING_ATTR, mapping)
211
224
 
212
225
 
213
226
  class Post(HttpMapping):
@@ -265,52 +278,46 @@ class Patch(HttpMapping):
265
278
  super().__init__("PATCH", path, options, response_type)
266
279
 
267
280
 
268
- class UseMiddleware:
269
-
270
- __MIDDLEWARES_ATTR__ = "__middlewares__"
281
+ class UseMiddleware(StackableDecorator):
271
282
 
272
283
  def __init__(self, middleware: type[HttpMiddleware]) -> None:
273
284
  self.middleware = middleware
274
285
 
275
- def __call__(self, subject: DECORATED_FUNC) -> DECORATED_FUNC:
276
-
277
- UseMiddleware.register(subject, self)
278
-
279
- return subject
280
-
281
- @staticmethod
282
- def register(subject: DECORATED_FUNC, middleware: "UseMiddleware") -> None:
283
- middlewares = getattr(subject, UseMiddleware.__MIDDLEWARES_ATTR__, [])
284
- middlewares.append(middleware)
285
- setattr(subject, UseMiddleware.__MIDDLEWARES_ATTR__, middlewares)
286
-
287
- @staticmethod
288
- def get_middlewares(subject: DECORATED_FUNC) -> list["UseMiddleware"]:
289
- return getattr(subject, UseMiddleware.__MIDDLEWARES_ATTR__, [])
290
-
291
286
 
292
- class UseDependency:
293
-
294
- __DEPENDENCY_ATTR__ = "__dependencies__"
287
+ class UseDependency(StackableDecorator):
295
288
 
296
289
  def __init__(self, dependency: Any) -> None:
297
290
  self.dependency = dependency
298
291
 
299
- def __call__(self, subject: DECORATED_FUNC) -> DECORATED_FUNC:
300
292
 
301
- UseDependency.register(subject, self)
293
+ def compose_route_decorators(
294
+ *decorators: UseMiddleware | UseDependency,
295
+ reverse: bool = False,
296
+ ) -> Callable[[DECORATED_TYPE], DECORATED_TYPE]:
297
+ """
298
+ Compose multiple route decorators (middlewares/dependencies) into a single decorator.
302
299
 
303
- return subject
300
+ Args:
301
+ *decorators (UseMiddleware | UseDependency): The decorators to compose.
302
+ reverse (bool): Whether to apply the decorators in reverse order. Warning: This may affect the order of middleware execution.
304
303
 
305
- @staticmethod
306
- def register(subject: DECORATED_FUNC, dependency: "UseDependency") -> None:
307
- dependencies = getattr(subject, UseDependency.__DEPENDENCY_ATTR__, [])
308
- dependencies.append(dependency)
309
- setattr(subject, UseDependency.__DEPENDENCY_ATTR__, dependencies)
304
+ Returns:
305
+ Callable[[DECORATED_TYPE], DECORATED_TYPE]: A single decorator that applies all the given decorators.
310
306
 
311
- @staticmethod
312
- def get_dependencies(subject: DECORATED_FUNC) -> list["UseDependency"]:
313
- return getattr(subject, UseDependency.__DEPENDENCY_ATTR__, [])
307
+ Example:
308
+ IsWorkspaceAdmin = compose_route_decorators(
309
+ UseMiddleware(AuthMiddleware),
310
+ UseMiddleware(IsWorkspaceScoped),
311
+ UseMiddleware(RequiresAdminRole),
312
+ )
313
+ """
314
+
315
+ def composed_decorator(func: DECORATED_TYPE) -> DECORATED_TYPE:
316
+ for decorator in reversed(decorators) if reverse else decorators:
317
+ func = decorator(func)
318
+ return func
319
+
320
+ return composed_decorator
314
321
 
315
322
 
316
323
  def wraps_with_member_data(
@@ -330,11 +337,6 @@ def wraps_with_member_data(
330
337
 
331
338
  return await func(*args, **kwargs)
332
339
 
333
- # Copy metadata from the original function to the wrapper
334
- # for attr in dir(func):
335
- # if not attr.startswith("__"):
336
- # setattr(wrapper, attr, getattr(func, attr))
337
-
338
340
  return wrapper
339
341
 
340
342
 
@@ -0,0 +1,23 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ from fastapi import Request, Response, WebSocket
6
+
7
+
8
+ class PresentationException(Exception):
9
+ """Base exception for presentation layer errors."""
10
+
11
+ def __init__(
12
+ self,
13
+ *,
14
+ original_exception: Exception,
15
+ request: Request | None = None,
16
+ response: Response | None = None,
17
+ websocket: WebSocket | None = None,
18
+ ) -> None:
19
+ super().__init__(str(original_exception))
20
+ self.original_exception = original_exception
21
+ self.request = request
22
+ self.response = response
23
+ self.websocket = websocket
@@ -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
2
6
  from typing import Any, ContextManager, Generator, Type
3
7
 
@@ -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, field
2
6
  from typing import Any, AsyncGenerator, Callable, Protocol
3
7
 
@@ -1,7 +1,17 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ import signal
9
+ import threading
1
10
  from contextlib import asynccontextmanager
11
+ from signal import SIGINT, SIGTERM
2
12
  from typing import Any, AsyncGenerator
3
13
 
4
- from fastapi import Depends, FastAPI, Request, WebSocket
14
+ from fastapi import Depends, FastAPI, HTTPException, Request, Response, WebSocket
5
15
  from starlette.types import ASGIApp
6
16
 
7
17
  from jararaca.core.uow import UnitOfWorkContextProvider
@@ -10,12 +20,18 @@ from jararaca.lifecycle import AppLifecycle
10
20
  from jararaca.microservice import (
11
21
  AppTransactionContext,
12
22
  HttpTransactionData,
23
+ ShutdownState,
13
24
  WebSocketTransactionData,
25
+ provide_shutdown_state,
26
+ providing_app_type,
14
27
  )
15
28
  from jararaca.presentation.decorators import RestController
29
+ from jararaca.presentation.exceptions import PresentationException
16
30
  from jararaca.presentation.http_microservice import HttpMicroservice
17
31
  from jararaca.reflect.controller_inspect import ControllerMemberReflect
18
32
 
33
+ logger = logging.getLogger(__name__)
34
+
19
35
 
20
36
  class HttpAppLifecycle:
21
37
 
@@ -31,58 +47,83 @@ class HttpAppLifecycle:
31
47
 
32
48
  @asynccontextmanager
33
49
  async def __call__(self, api: FastAPI) -> AsyncGenerator[None, None]:
34
- async with self.lifecycle():
50
+ with providing_app_type("http"):
51
+ async with self.lifecycle():
35
52
 
36
- # websocket_interceptors = [
37
- # interceptor
38
- # for interceptor in self.lifecycle.initialized_interceptors
39
- # if isinstance(interceptor, WebSocketInterceptor)
40
- # ]
53
+ for controller_t in self.lifecycle.app.controllers:
54
+ controller = RestController.get_last(controller_t)
41
55
 
42
- # for interceptor in websocket_interceptors:
43
- # router = interceptor.get_ws_router(
44
- # self.lifecycle.app, self.lifecycle.container, self.uow_provider
45
- # )
56
+ if controller is None:
57
+ continue
46
58
 
47
- # api.include_router(router)
59
+ instance: Any = self.lifecycle.container.get_by_type(controller_t)
48
60
 
49
- for controller_t in self.lifecycle.app.controllers:
50
- controller = RestController.get_controller(controller_t)
61
+ router = controller.get_router_factory()(self.lifecycle, instance)
51
62
 
52
- if controller is None:
53
- continue
63
+ api.include_router(router)
54
64
 
55
- instance: Any = self.lifecycle.container.get_by_type(controller_t)
65
+ for middleware in self.http_app.middlewares:
66
+ middleware_instance = self.lifecycle.container.get_by_type(
67
+ middleware
68
+ )
69
+ api.router.dependencies.append(
70
+ Depends(middleware_instance.intercept)
71
+ )
56
72
 
57
- # dependencies: list[DependsCls] = []
58
- # for middleware in controller.middlewares:
59
- # middleware_instance = self.lifecycle.container.get_by_type(
60
- # middleware
61
- # )
62
- # dependencies.append(Depends(middleware_instance.intercept))
73
+ yield
63
74
 
64
- router = controller.get_router_factory()(self.lifecycle, instance)
65
75
 
66
- api.include_router(router)
76
+ class HttpShutdownState(ShutdownState):
77
+ def __init__(self) -> None:
78
+ self._requested = False
79
+ self.old_signal_handlers = {
80
+ SIGINT: signal.getsignal(SIGINT),
81
+ SIGTERM: signal.getsignal(SIGTERM),
82
+ }
83
+ self.thread_lock = threading.Lock()
84
+ self.aevent = asyncio.Event()
67
85
 
68
- for middleware in self.http_app.middlewares:
69
- middleware_instance = self.lifecycle.container.get_by_type(
70
- middleware
71
- )
72
- api.router.dependencies.append(
73
- Depends(middleware_instance.intercept)
74
- )
86
+ def request_shutdown(self) -> None:
87
+ if not self._requested:
88
+ self._requested = True
89
+ os.kill(os.getpid(), SIGINT)
90
+
91
+ def is_shutdown_requested(self) -> bool:
92
+ return self._requested
93
+
94
+ def handle_signal(self, signum: int, frame: Any) -> None:
95
+ logger.warning(f"Received signal {signum}, initiating shutdown...")
96
+ self.aevent.set()
97
+ if self._requested:
98
+ logger.warning("Shutdown already requested, ignoring signal.")
99
+ return
100
+ logger.warning("Requesting shutdown...")
101
+ self._requested = True
75
102
 
76
- yield
103
+ # remove the signal handler to prevent recursion
104
+ for sig in (SIGINT, SIGTERM):
105
+ if self.old_signal_handlers[sig] is not None:
106
+ signal.signal(sig, self.old_signal_handlers[sig])
107
+
108
+ signal.raise_signal(signum)
109
+
110
+ async def wait_for_shutdown(self) -> None:
111
+ await self.aevent.wait()
112
+
113
+ def setup_signal_handlers(self) -> None:
114
+ signal.signal(SIGINT, self.handle_signal)
115
+ signal.signal(SIGTERM, self.handle_signal)
77
116
 
78
117
 
79
118
  class HttpUowContextProviderDependency:
80
119
 
81
120
  def __init__(self, uow_provider: UnitOfWorkContextProvider) -> None:
82
121
  self.uow_provider = uow_provider
122
+ self.shutdown_state = HttpShutdownState()
123
+ self.shutdown_state.setup_signal_handlers()
83
124
 
84
125
  async def __call__(
85
- self, websocket: WebSocket = None, request: Request = None # type: ignore
126
+ self, websocket: WebSocket = None, request: Request = None, response: Response = None # type: ignore
86
127
  ) -> AsyncGenerator[None, None]:
87
128
  if request:
88
129
  endpoint = request.scope["endpoint"]
@@ -101,17 +142,28 @@ class HttpUowContextProviderDependency:
101
142
  "ControllerMemberReflect, but got: {}".format(type(member))
102
143
  )
103
144
 
104
- async with self.uow_provider(
105
- AppTransactionContext(
106
- controller_member_reflect=member,
107
- transaction_data=(
108
- HttpTransactionData(request=request)
109
- if request
110
- else WebSocketTransactionData(websocket=websocket)
111
- ),
112
- )
113
- ):
114
- yield
145
+ with provide_shutdown_state(self.shutdown_state):
146
+ async with self.uow_provider(
147
+ AppTransactionContext(
148
+ controller_member_reflect=member,
149
+ transaction_data=(
150
+ HttpTransactionData(request=request, response=response)
151
+ if request
152
+ else WebSocketTransactionData(websocket=websocket)
153
+ ),
154
+ )
155
+ ):
156
+ try:
157
+ yield
158
+ except HTTPException:
159
+ raise
160
+ except Exception as e:
161
+ raise PresentationException(
162
+ original_exception=e,
163
+ request=request,
164
+ response=response,
165
+ websocket=websocket,
166
+ )
115
167
 
116
168
 
117
169
  def create_http_server(