jararaca 0.3.11a16__py3-none-any.whl → 0.4.0a19__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 (96) hide show
  1. README.md +121 -0
  2. jararaca/__init__.py +189 -17
  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 +915 -51
  8. jararaca/common/__init__.py +3 -0
  9. jararaca/core/__init__.py +3 -0
  10. jararaca/core/providers.py +8 -0
  11. jararaca/core/uow.py +41 -7
  12. jararaca/di.py +4 -0
  13. jararaca/files/entity.py.mako +4 -0
  14. jararaca/helpers/__init__.py +3 -0
  15. jararaca/helpers/global_scheduler/__init__.py +3 -0
  16. jararaca/helpers/global_scheduler/config.py +21 -0
  17. jararaca/helpers/global_scheduler/controller.py +42 -0
  18. jararaca/helpers/global_scheduler/registry.py +32 -0
  19. jararaca/lifecycle.py +6 -2
  20. jararaca/messagebus/__init__.py +4 -0
  21. jararaca/messagebus/bus_message_controller.py +4 -0
  22. jararaca/messagebus/consumers/__init__.py +3 -0
  23. jararaca/messagebus/decorators.py +121 -61
  24. jararaca/messagebus/implicit_headers.py +49 -0
  25. jararaca/messagebus/interceptors/__init__.py +3 -0
  26. jararaca/messagebus/interceptors/aiopika_publisher_interceptor.py +62 -11
  27. jararaca/messagebus/interceptors/message_publisher_collector.py +62 -0
  28. jararaca/messagebus/interceptors/publisher_interceptor.py +29 -3
  29. jararaca/messagebus/message.py +4 -0
  30. jararaca/messagebus/publisher.py +6 -0
  31. jararaca/messagebus/worker.py +1002 -459
  32. jararaca/microservice.py +113 -2
  33. jararaca/observability/constants.py +7 -0
  34. jararaca/observability/decorators.py +170 -13
  35. jararaca/observability/fastapi_exception_handler.py +37 -0
  36. jararaca/observability/hooks.py +109 -0
  37. jararaca/observability/interceptor.py +4 -0
  38. jararaca/observability/providers/__init__.py +3 -0
  39. jararaca/observability/providers/otel.py +225 -16
  40. jararaca/persistence/base.py +39 -3
  41. jararaca/persistence/exports.py +4 -0
  42. jararaca/persistence/interceptors/__init__.py +3 -0
  43. jararaca/persistence/interceptors/aiosqa_interceptor.py +86 -73
  44. jararaca/persistence/interceptors/constants.py +5 -0
  45. jararaca/persistence/interceptors/decorators.py +50 -0
  46. jararaca/persistence/session.py +3 -0
  47. jararaca/persistence/sort_filter.py +4 -0
  48. jararaca/persistence/utilities.py +73 -20
  49. jararaca/presentation/__init__.py +3 -0
  50. jararaca/presentation/decorators.py +88 -86
  51. jararaca/presentation/exceptions.py +23 -0
  52. jararaca/presentation/hooks.py +4 -0
  53. jararaca/presentation/http_microservice.py +4 -0
  54. jararaca/presentation/server.py +97 -45
  55. jararaca/presentation/websocket/__init__.py +3 -0
  56. jararaca/presentation/websocket/base_types.py +4 -0
  57. jararaca/presentation/websocket/context.py +4 -0
  58. jararaca/presentation/websocket/decorators.py +8 -41
  59. jararaca/presentation/websocket/redis.py +280 -53
  60. jararaca/presentation/websocket/types.py +4 -0
  61. jararaca/presentation/websocket/websocket_interceptor.py +46 -19
  62. jararaca/reflect/__init__.py +3 -0
  63. jararaca/reflect/controller_inspect.py +16 -10
  64. jararaca/reflect/decorators.py +252 -0
  65. jararaca/reflect/helpers.py +18 -0
  66. jararaca/reflect/metadata.py +34 -25
  67. jararaca/rpc/__init__.py +3 -0
  68. jararaca/rpc/http/__init__.py +101 -0
  69. jararaca/rpc/http/backends/__init__.py +14 -0
  70. jararaca/rpc/http/backends/httpx.py +43 -9
  71. jararaca/rpc/http/backends/otel.py +4 -0
  72. jararaca/rpc/http/decorators.py +380 -115
  73. jararaca/rpc/http/httpx.py +3 -0
  74. jararaca/scheduler/__init__.py +3 -0
  75. jararaca/scheduler/beat_worker.py +521 -105
  76. jararaca/scheduler/decorators.py +15 -22
  77. jararaca/scheduler/types.py +4 -0
  78. jararaca/tools/app_config/__init__.py +3 -0
  79. jararaca/tools/app_config/decorators.py +7 -19
  80. jararaca/tools/app_config/interceptor.py +6 -2
  81. jararaca/tools/typescript/__init__.py +3 -0
  82. jararaca/tools/typescript/decorators.py +120 -0
  83. jararaca/tools/typescript/interface_parser.py +1077 -174
  84. jararaca/utils/__init__.py +3 -0
  85. jararaca/utils/env_parse_utils.py +133 -0
  86. jararaca/utils/rabbitmq_utils.py +112 -39
  87. jararaca/utils/retry.py +19 -14
  88. jararaca-0.4.0a19.dist-info/LICENSE +674 -0
  89. jararaca-0.4.0a19.dist-info/LICENSES/GPL-3.0-or-later.txt +232 -0
  90. {jararaca-0.3.11a16.dist-info → jararaca-0.4.0a19.dist-info}/METADATA +12 -7
  91. jararaca-0.4.0a19.dist-info/RECORD +96 -0
  92. {jararaca-0.3.11a16.dist-info → jararaca-0.4.0a19.dist-info}/WHEEL +1 -1
  93. pyproject.toml +132 -0
  94. jararaca-0.3.11a16.dist-info/RECORD +0 -74
  95. /jararaca-0.3.11a16.dist-info/LICENSE → /LICENSE +0 -0
  96. {jararaca-0.3.11a16.dist-info → jararaca-0.4.0a19.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,20 @@ 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,
324
+ final_listing_statement: (
325
+ Callable[[Select[Tuple[QUERY_ENTITY_T]]], Select[Tuple[QUERY_ENTITY_T]]]
326
+ | None
327
+ ) = None,
328
+ total_type: Literal["total_over", "count_subquery"] = "total_over",
309
329
  ) -> "Paginated[QUERY_ENTITY_T]":
310
330
  """
311
331
  Executes a query with the provided filter and interceptors.
@@ -316,8 +336,15 @@ class QueryOperations(Generic[QUERY_FILTER_T, QUERY_ENTITY_T]):
316
336
  Paginated[QUERY_ENTITY_T]: A paginated result containing the items and metadata.
317
337
  """
318
338
 
339
+ initial_statement = self.base_statement or select(self.entity_type)
340
+
341
+ if base_statement is not None and callable(base_statement):
342
+ initial_statement = base_statement(initial_statement)
343
+ elif base_statement is not None and isinstance(base_statement, Select):
344
+ initial_statement = base_statement
345
+
319
346
  tier_one_filtered_query = self.generate_filtered_query(
320
- filter, select(self.entity_type)
347
+ filter, initial_statement
321
348
  )
322
349
 
323
350
  tier_two_filtered_query = reduce(
@@ -340,30 +367,56 @@ class QueryOperations(Generic[QUERY_FILTER_T, QUERY_ENTITY_T]):
340
367
  )
341
368
  )
342
369
 
343
- unpaginated_total = (
344
- await self.session.execute(
345
- select(func.count()).select_from(tier_two_filtered_query.subquery())
346
- )
347
- ).scalar_one()
370
+ if final_listing_statement is not None:
371
+ tier_two_filtered_query = final_listing_statement(tier_two_filtered_query)
348
372
 
349
- paginated_query = tier_two_filtered_query.limit(filter.page_size).offset(
350
- (filter.page) * filter.page_size
351
- )
373
+ if total_type == "total_over":
374
+ # Use window function for total count (single query)
375
+ paginated_query = tier_two_filtered_query.add_columns(
376
+ func.count().over().label("total_count")
377
+ )
378
+ paginated_query = paginated_query.limit(filter.page_size).offset(
379
+ (filter.page) * filter.page_size
380
+ )
352
381
 
353
- paginated_total = (
354
- await self.session.execute(
355
- select(func.count()).select_from(paginated_query.subquery())
382
+ result = await self.session.execute(paginated_query)
383
+ result = self.judge_unique(result)
384
+ rows = result.all()
385
+
386
+ if rows:
387
+ unpaginated_total = rows[0].total_count
388
+ result_scalars = [row[0] for row in rows]
389
+ else:
390
+ result_scalars = []
391
+ unpaginated_total = 0
392
+ else: # total_type == "count_subquery"
393
+ # Use separate count query (two queries)
394
+ paginated_query = tier_two_filtered_query.limit(filter.page_size).offset(
395
+ (filter.page) * filter.page_size
356
396
  )
357
- ).scalar_one()
358
397
 
359
- result = await self.session.execute(paginated_query)
360
- result_scalars = list(self.judge_unique(result).scalars().all())
398
+ result = await self.session.execute(paginated_query)
399
+ result = self.judge_unique(result)
400
+ result_scalars = list(result.scalars())
401
+
402
+ # Always fetch total with separate query
403
+ unpaginated_total = (
404
+ await self.session.execute(
405
+ tier_two_filtered_query.with_only_columns(
406
+ func.count(self.entity_type.id)
407
+ ).order_by(None)
408
+ if issubclass(self.entity_type, IdentifiableEntity)
409
+ else select(func.count()).select_from(
410
+ tier_two_filtered_query.subquery()
411
+ )
412
+ )
413
+ ).scalar_one()
361
414
 
362
415
  return Paginated(
363
416
  items=result_scalars,
364
- total=paginated_total,
417
+ total=len(result_scalars),
365
418
  unpaginated_total=unpaginated_total,
366
- total_pages=int(unpaginated_total / filter.page_size) + 1,
419
+ total_pages=math.ceil(unpaginated_total / filter.page_size),
367
420
  )
368
421
 
369
422
  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 FUNC_OR_TYPE_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: FUNC_OR_TYPE_T) -> None:
54
65
 
55
66
  def router_factory(
56
67
  lifecycle: AppLifecycle,
57
- instance: DECORATED_CLASS,
68
+ instance: FUNC_OR_TYPE_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