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,23 +1,97 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
1
5
  from contextlib import asynccontextmanager, contextmanager, suppress
2
6
  from contextvars import ContextVar
3
7
  from dataclasses import dataclass
4
- from typing import Any, AsyncGenerator, Generator
5
-
6
- from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
8
+ from typing import Any, AsyncGenerator, Generator, Protocol
9
+
10
+ from sqlalchemy.ext.asyncio import (
11
+ AsyncSession,
12
+ AsyncSessionTransaction,
13
+ async_sessionmaker,
14
+ create_async_engine,
15
+ )
7
16
  from sqlalchemy.ext.asyncio.engine import AsyncEngine
8
17
 
9
- from jararaca.microservice import AppContext, AppInterceptor
18
+ from jararaca.microservice import AppInterceptor, AppTransactionContext
19
+ from jararaca.persistence.interceptors.constants import DEFAULT_CONNECTION_NAME
20
+ from jararaca.persistence.interceptors.decorators import (
21
+ INJECT_PERSISTENCE_SESSION_METADATA_TEMPLATE,
22
+ )
23
+ from jararaca.reflect.metadata import get_metadata_value
24
+
25
+
26
+ class SessionManager(Protocol):
27
+ def spawn_session(self, connection_name: str | None = None) -> AsyncSession: ...
10
28
 
11
- ctx_session_map = ContextVar[dict[str, AsyncSession]]("ctx_session_map", default={})
29
+
30
+ ctx_session_manager: ContextVar[SessionManager | None] = ContextVar(
31
+ "ctx_session_manager", default=None
32
+ )
12
33
 
13
34
 
14
35
  @contextmanager
15
- def provide_session(
16
- connection_name: str, session: AsyncSession
36
+ def providing_session_manager(
37
+ session_manager: SessionManager,
17
38
  ) -> Generator[None, Any, None]:
39
+ """
40
+ Context manager to provide a session manager for the current context.
41
+ """
42
+ token = ctx_session_manager.set(session_manager)
43
+ try:
44
+ yield
45
+ finally:
46
+ with suppress(ValueError):
47
+ ctx_session_manager.reset(token)
48
+
49
+
50
+ def use_session_manager() -> SessionManager:
51
+ """
52
+ Retrieve the current session manager from the context variable.
53
+ Raises ValueError if no session manager is set.
54
+ """
55
+ session_manager = ctx_session_manager.get()
56
+ if session_manager is None:
57
+ raise ValueError("No session manager set in the context.")
58
+ return session_manager
59
+
60
+
61
+ ctx_default_connection_name: ContextVar[str] = ContextVar(
62
+ "ctx_default_connection_name", default=DEFAULT_CONNECTION_NAME
63
+ )
64
+
65
+
66
+ def ensure_name(name: str | None) -> str:
67
+ return ctx_default_connection_name.get()
68
+
69
+
70
+ @dataclass
71
+ class PersistenceCtx:
72
+ session: AsyncSession
73
+ tx: AsyncSessionTransaction
74
+
75
+
76
+ ctx_session_map = ContextVar[dict[str, PersistenceCtx]]("ctx_session_map", default={})
77
+
78
+
79
+ @contextmanager
80
+ def providing_session(
81
+ session: AsyncSession,
82
+ tx: AsyncSessionTransaction,
83
+ connection_name: str | None = None,
84
+ ) -> Generator[None, Any, None]:
85
+ """
86
+ Context manager to provide a session and transaction for a specific connection name.
87
+ If no connection name is provided, it uses the default connection name from the context variable.
88
+ """
89
+ connection_name = ensure_name(connection_name)
18
90
  current_map = ctx_session_map.get({})
19
91
 
20
- token = ctx_session_map.set({**current_map, connection_name: session})
92
+ token = ctx_session_map.set(
93
+ {**current_map, connection_name: PersistenceCtx(session, tx)}
94
+ )
21
95
 
22
96
  try:
23
97
  yield
@@ -26,21 +100,86 @@ def provide_session(
26
100
  ctx_session_map.reset(token)
27
101
 
28
102
 
29
- def use_session(connection_name: str = "default") -> AsyncSession:
103
+ provide_session = providing_session
104
+ """
105
+ Alias for `providing_session` to maintain backward compatibility.
106
+ """
107
+
108
+
109
+ @asynccontextmanager
110
+ async def providing_new_session(
111
+ connection_name: str | None = None,
112
+ ) -> AsyncGenerator[AsyncSession, None]:
113
+
114
+ session_manager = use_session_manager()
115
+ current_session = session_manager.spawn_session(connection_name)
116
+
117
+ async with AsyncSession(
118
+ current_session.bind,
119
+ ) as new_session, new_session.begin() as new_tx:
120
+ with providing_session(new_session, new_tx, connection_name):
121
+ try:
122
+ yield new_session
123
+ if new_tx.is_active:
124
+ await new_tx.commit()
125
+ except Exception:
126
+ if new_tx.is_active:
127
+ await new_tx.rollback()
128
+ raise
129
+
130
+
131
+ def use_session(connection_name: str | None = None) -> AsyncSession:
132
+ connection_name = ensure_name(connection_name)
30
133
  current_map = ctx_session_map.get({})
31
134
  if connection_name not in current_map:
32
- raise ValueError(f"Session not found for connection {connection_name}")
135
+ raise ValueError(
136
+ f'Session not found for connection "{connection_name}" in context. Check if your interceptor is correctly set up.'
137
+ )
33
138
 
34
- return current_map[connection_name]
139
+ return current_map[connection_name].session
140
+
141
+
142
+ @contextmanager
143
+ def providing_transaction(
144
+ tx: AsyncSessionTransaction,
145
+ connection_name: str | None = None,
146
+ ) -> Generator[None, Any, None]:
147
+ connection_name = ensure_name(connection_name)
148
+
149
+ current_map = ctx_session_map.get({})
150
+
151
+ if connection_name not in current_map:
152
+ raise ValueError(f"No session found for connection {connection_name}")
153
+
154
+ with providing_session(current_map[connection_name].session, tx, connection_name):
155
+ yield
156
+
157
+
158
+ def use_transaction(connection_name: str | None = None) -> AsyncSessionTransaction:
159
+ current_map = ctx_session_map.get({})
160
+ if connection_name not in current_map:
161
+ raise ValueError(f"Transaction not found for connection {connection_name}")
162
+
163
+ return current_map[connection_name].tx
35
164
 
36
165
 
37
- @dataclass
38
166
  class AIOSQAConfig:
39
- connection_name: str
40
167
  url: str | AsyncEngine
168
+ connection_name: str
169
+ inject_default: bool
170
+
171
+ def __init__(
172
+ self,
173
+ url: str | AsyncEngine,
174
+ connection_name: str = DEFAULT_CONNECTION_NAME,
175
+ inject_default: bool = True,
176
+ ):
177
+ self.url = url
178
+ self.connection_name = connection_name
179
+ self.inject_default = inject_default
41
180
 
42
181
 
43
- class AIOSqlAlchemySessionInterceptor(AppInterceptor):
182
+ class AIOSqlAlchemySessionInterceptor(AppInterceptor, SessionManager):
44
183
 
45
184
  def __init__(self, config: AIOSQAConfig):
46
185
  self.config = config
@@ -53,12 +192,37 @@ class AIOSqlAlchemySessionInterceptor(AppInterceptor):
53
192
  self.sessionmaker = async_sessionmaker(self.engine)
54
193
 
55
194
  @asynccontextmanager
56
- async def intercept(self, app_context: AppContext) -> AsyncGenerator[None, None]:
57
- async with self.sessionmaker() as session:
58
- with provide_session(self.config.connection_name, session):
59
- try:
60
- yield
61
- await session.commit()
62
- except Exception as e:
63
- await session.rollback()
64
- raise e
195
+ async def intercept(
196
+ self, app_context: AppTransactionContext
197
+ ) -> AsyncGenerator[None, None]:
198
+
199
+ with providing_session_manager(self):
200
+ uses_connection_metadata = get_metadata_value(
201
+ INJECT_PERSISTENCE_SESSION_METADATA_TEMPLATE.format(
202
+ connection_name=self.config.connection_name
203
+ ),
204
+ self.config.inject_default,
205
+ )
206
+
207
+ if not uses_connection_metadata:
208
+ yield
209
+ return
210
+
211
+ async with self.sessionmaker() as session, session.begin() as tx:
212
+ token = ctx_default_connection_name.set(self.config.connection_name)
213
+ with providing_session(session, tx, self.config.connection_name):
214
+ try:
215
+ yield
216
+ if tx.is_active:
217
+ await tx.commit()
218
+ except Exception as e:
219
+ await tx.rollback()
220
+ raise e
221
+ finally:
222
+ with suppress(ValueError):
223
+ ctx_default_connection_name.reset(token)
224
+
225
+ def spawn_session(self, connection_name: str | None = None) -> AsyncSession:
226
+ connection_name = ensure_name(connection_name)
227
+ session = self.sessionmaker()
228
+ return session
@@ -0,0 +1,5 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ DEFAULT_CONNECTION_NAME = "default"
@@ -0,0 +1,50 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+
6
+ from jararaca.persistence.interceptors.constants import DEFAULT_CONNECTION_NAME
7
+ from jararaca.reflect.metadata import SetMetadata
8
+
9
+ INJECT_PERSISTENCE_SESSION_METADATA_TEMPLATE = (
10
+ "inject_persistence_template_{connection_name}"
11
+ )
12
+
13
+
14
+ def set_use_persistence_session(
15
+ inject: bool, connection_name: str = DEFAULT_CONNECTION_NAME
16
+ ) -> SetMetadata:
17
+ """
18
+ Set whether to inject the connection metadata for the given connection name.
19
+ This is useful when you want to control whether the connection metadata
20
+ should be injected into the context or not.
21
+ """
22
+
23
+ return SetMetadata(
24
+ INJECT_PERSISTENCE_SESSION_METADATA_TEMPLATE.format(
25
+ connection_name=connection_name
26
+ ),
27
+ inject,
28
+ )
29
+
30
+
31
+ def uses_persistence_session(
32
+ connection_name: str = DEFAULT_CONNECTION_NAME,
33
+ ) -> SetMetadata:
34
+ """
35
+ Use connection metadata for the given connection name.
36
+ This is useful when you want to inject the connection metadata into the context,
37
+ for example, when you are using a specific connection for a specific operation.
38
+ """
39
+ return set_use_persistence_session(True, connection_name=connection_name)
40
+
41
+
42
+ def skip_persistence_session(
43
+ connection_name: str = DEFAULT_CONNECTION_NAME,
44
+ ) -> SetMetadata:
45
+ """
46
+ Decorator to skip using connection metadata for the given connection name.
47
+ This is useful when you want to ensure that the connection metadata is not injected
48
+ into the context, for example, when you are using a different connection for a specific operation.
49
+ """
50
+ return set_use_persistence_session(False, connection_name=connection_name)
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
@@ -1,3 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+
1
5
  import re
2
6
  from datetime import date, datetime
3
7
  from functools import reduce
@@ -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
 
@@ -213,7 +219,7 @@ class CRUDOperations(Generic[IDENTIFIABLE_T]):
213
219
  # region PaginatedFilter
214
220
  class PaginatedFilter(BaseModel):
215
221
  page: Annotated[int, Field(gt=-1)] = 0
216
- page_size: int = 10
222
+ page_size: Annotated[int, Field(gt=0)] = 10
217
223
  sort_models: list[SortModel] = []
218
224
  filter_models: list[FilterModel] = []
219
225
 
@@ -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,55 +312,88 @@ 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]":
325
+ """
326
+ Executes a query with the provided filter and interceptors.
327
+ Args:
328
+ filter: The filter to apply to the query.
329
+ interceptors: A list of functions that can modify the query before execution.
330
+ Returns:
331
+ Paginated[QUERY_ENTITY_T]: A paginated result containing the items and metadata.
332
+ """
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
+
341
+ tier_one_filtered_query = self.generate_filtered_query(
342
+ filter, initial_statement
343
+ )
310
344
 
311
- query = reduce(
345
+ tier_two_filtered_query = reduce(
312
346
  lambda query, interceptor: interceptor(query),
313
347
  interceptors,
314
- select(self.entity_type),
348
+ tier_one_filtered_query,
315
349
  )
316
350
 
317
351
  if self.sort_rule_applier:
318
- query = self.sort_rule_applier.create_query_for_sorting_list(
319
- query, filter.sort_models
352
+ tier_two_filtered_query = (
353
+ self.sort_rule_applier.create_query_for_sorting_list(
354
+ tier_two_filtered_query, filter.sort_models
355
+ )
320
356
  )
321
357
 
322
358
  if self.filter_rule_applier:
323
- query = self.filter_rule_applier.create_query_for_filter_list(
324
- query, filter.filter_models
325
- )
326
-
327
- unpaginated_total = (
328
- await self.session.execute(
329
- select(func.count()).select_from(query.subquery())
359
+ tier_two_filtered_query = (
360
+ self.filter_rule_applier.create_query_for_filter_list(
361
+ tier_two_filtered_query, filter.filter_models
362
+ )
330
363
  )
331
- ).scalar_one()
332
364
 
333
- filtered_query = self.generate_filtered_query(filter, query)
334
-
335
- paginated_query = 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(
336
369
  (filter.page) * filter.page_size
337
370
  )
338
371
 
339
- filtered_total = (
340
- await self.session.execute(
341
- select(func.count()).select_from(paginated_query.subquery())
342
- )
343
- ).scalar_one()
372
+ result = await self.session.execute(paginated_query)
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()
344
391
 
345
392
  return Paginated(
346
- items=[
347
- e
348
- for e in self.judge_unique(
349
- await self.session.execute(paginated_query)
350
- ).scalars()
351
- ],
352
- total=filtered_total,
393
+ items=result_scalars,
394
+ total=len(result_scalars),
353
395
  unpaginated_total=unpaginated_total,
354
- total_pages=int(filtered_total / filter.page_size) + 1,
396
+ total_pages=math.ceil(unpaginated_total / filter.page_size),
355
397
  )
356
398
 
357
399
  def judge_unique(
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2025 Lucas S
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later