litestar-vite 0.1.1__py3-none-any.whl → 0.15.0rc2__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 (154) hide show
  1. litestar_vite/__init__.py +54 -4
  2. litestar_vite/__metadata__.py +12 -7
  3. litestar_vite/_codegen/__init__.py +26 -0
  4. litestar_vite/_codegen/inertia.py +407 -0
  5. litestar_vite/_codegen/openapi.py +233 -0
  6. litestar_vite/_codegen/routes.py +653 -0
  7. litestar_vite/_codegen/ts.py +235 -0
  8. litestar_vite/_handler/__init__.py +8 -0
  9. litestar_vite/_handler/app.py +524 -0
  10. litestar_vite/_handler/routing.py +130 -0
  11. litestar_vite/cli.py +1147 -10
  12. litestar_vite/codegen.py +39 -0
  13. litestar_vite/commands.py +79 -0
  14. litestar_vite/config.py +1594 -70
  15. litestar_vite/deploy.py +355 -0
  16. litestar_vite/doctor.py +1179 -0
  17. litestar_vite/exceptions.py +78 -0
  18. litestar_vite/executor.py +316 -0
  19. litestar_vite/handler.py +9 -0
  20. litestar_vite/html_transform.py +426 -0
  21. litestar_vite/inertia/__init__.py +53 -0
  22. litestar_vite/inertia/_utils.py +114 -0
  23. litestar_vite/inertia/exception_handler.py +172 -0
  24. litestar_vite/inertia/helpers.py +1043 -0
  25. litestar_vite/inertia/middleware.py +54 -0
  26. litestar_vite/inertia/plugin.py +133 -0
  27. litestar_vite/inertia/request.py +286 -0
  28. litestar_vite/inertia/response.py +706 -0
  29. litestar_vite/inertia/types.py +316 -0
  30. litestar_vite/loader.py +462 -121
  31. litestar_vite/plugin.py +2160 -21
  32. litestar_vite/py.typed +0 -0
  33. litestar_vite/scaffolding/__init__.py +20 -0
  34. litestar_vite/scaffolding/generator.py +270 -0
  35. litestar_vite/scaffolding/templates.py +437 -0
  36. litestar_vite/templates/__init__.py +0 -0
  37. litestar_vite/templates/addons/tailwindcss/tailwind.css.j2 +1 -0
  38. litestar_vite/templates/angular/index.html.j2 +12 -0
  39. litestar_vite/templates/angular/openapi-ts.config.ts.j2 +18 -0
  40. litestar_vite/templates/angular/package.json.j2 +35 -0
  41. litestar_vite/templates/angular/src/app/app.component.css.j2 +3 -0
  42. litestar_vite/templates/angular/src/app/app.component.html.j2 +1 -0
  43. litestar_vite/templates/angular/src/app/app.component.ts.j2 +9 -0
  44. litestar_vite/templates/angular/src/app/app.config.ts.j2 +5 -0
  45. litestar_vite/templates/angular/src/main.ts.j2 +9 -0
  46. litestar_vite/templates/angular/src/styles.css.j2 +9 -0
  47. litestar_vite/templates/angular/tsconfig.app.json.j2 +34 -0
  48. litestar_vite/templates/angular/tsconfig.json.j2 +20 -0
  49. litestar_vite/templates/angular/vite.config.ts.j2 +21 -0
  50. litestar_vite/templates/angular-cli/.postcssrc.json.j2 +5 -0
  51. litestar_vite/templates/angular-cli/angular.json.j2 +36 -0
  52. litestar_vite/templates/angular-cli/openapi-ts.config.ts.j2 +18 -0
  53. litestar_vite/templates/angular-cli/package.json.j2 +27 -0
  54. litestar_vite/templates/angular-cli/proxy.conf.json.j2 +18 -0
  55. litestar_vite/templates/angular-cli/src/app/app.component.css.j2 +3 -0
  56. litestar_vite/templates/angular-cli/src/app/app.component.html.j2 +1 -0
  57. litestar_vite/templates/angular-cli/src/app/app.component.ts.j2 +9 -0
  58. litestar_vite/templates/angular-cli/src/app/app.config.ts.j2 +5 -0
  59. litestar_vite/templates/angular-cli/src/index.html.j2 +13 -0
  60. litestar_vite/templates/angular-cli/src/main.ts.j2 +6 -0
  61. litestar_vite/templates/angular-cli/src/styles.css.j2 +10 -0
  62. litestar_vite/templates/angular-cli/tailwind.config.js.j2 +4 -0
  63. litestar_vite/templates/angular-cli/tsconfig.app.json.j2 +16 -0
  64. litestar_vite/templates/angular-cli/tsconfig.json.j2 +26 -0
  65. litestar_vite/templates/angular-cli/tsconfig.spec.json.j2 +9 -0
  66. litestar_vite/templates/astro/astro.config.mjs.j2 +28 -0
  67. litestar_vite/templates/astro/openapi-ts.config.ts.j2 +15 -0
  68. litestar_vite/templates/astro/src/layouts/Layout.astro.j2 +63 -0
  69. litestar_vite/templates/astro/src/pages/index.astro.j2 +36 -0
  70. litestar_vite/templates/astro/src/styles/global.css.j2 +1 -0
  71. litestar_vite/templates/base/.gitignore.j2 +42 -0
  72. litestar_vite/templates/base/openapi-ts.config.ts.j2 +15 -0
  73. litestar_vite/templates/base/package.json.j2 +38 -0
  74. litestar_vite/templates/base/resources/vite-env.d.ts.j2 +1 -0
  75. litestar_vite/templates/base/tsconfig.json.j2 +37 -0
  76. litestar_vite/templates/htmx/src/main.js.j2 +8 -0
  77. litestar_vite/templates/htmx/templates/base.html.j2.j2 +56 -0
  78. litestar_vite/templates/htmx/templates/index.html.j2.j2 +13 -0
  79. litestar_vite/templates/htmx/vite.config.ts.j2 +40 -0
  80. litestar_vite/templates/nuxt/app.vue.j2 +29 -0
  81. litestar_vite/templates/nuxt/composables/useApi.ts.j2 +33 -0
  82. litestar_vite/templates/nuxt/nuxt.config.ts.j2 +31 -0
  83. litestar_vite/templates/nuxt/openapi-ts.config.ts.j2 +15 -0
  84. litestar_vite/templates/nuxt/pages/index.vue.j2 +54 -0
  85. litestar_vite/templates/react/index.html.j2 +13 -0
  86. litestar_vite/templates/react/src/App.css.j2 +56 -0
  87. litestar_vite/templates/react/src/App.tsx.j2 +19 -0
  88. litestar_vite/templates/react/src/main.tsx.j2 +10 -0
  89. litestar_vite/templates/react/vite.config.ts.j2 +39 -0
  90. litestar_vite/templates/react-inertia/index.html.j2 +14 -0
  91. litestar_vite/templates/react-inertia/package.json.j2 +46 -0
  92. litestar_vite/templates/react-inertia/resources/App.css.j2 +68 -0
  93. litestar_vite/templates/react-inertia/resources/main.tsx.j2 +17 -0
  94. litestar_vite/templates/react-inertia/resources/pages/Home.tsx.j2 +18 -0
  95. litestar_vite/templates/react-inertia/resources/ssr.tsx.j2 +19 -0
  96. litestar_vite/templates/react-inertia/vite.config.ts.j2 +59 -0
  97. litestar_vite/templates/react-router/index.html.j2 +12 -0
  98. litestar_vite/templates/react-router/src/App.css.j2 +17 -0
  99. litestar_vite/templates/react-router/src/App.tsx.j2 +7 -0
  100. litestar_vite/templates/react-router/src/main.tsx.j2 +10 -0
  101. litestar_vite/templates/react-router/vite.config.ts.j2 +39 -0
  102. litestar_vite/templates/react-tanstack/index.html.j2 +12 -0
  103. litestar_vite/templates/react-tanstack/openapi-ts.config.ts.j2 +18 -0
  104. litestar_vite/templates/react-tanstack/src/App.css.j2 +17 -0
  105. litestar_vite/templates/react-tanstack/src/main.tsx.j2 +21 -0
  106. litestar_vite/templates/react-tanstack/src/routeTree.gen.ts.j2 +7 -0
  107. litestar_vite/templates/react-tanstack/src/routes/__root.tsx.j2 +9 -0
  108. litestar_vite/templates/react-tanstack/src/routes/books.tsx.j2 +9 -0
  109. litestar_vite/templates/react-tanstack/src/routes/index.tsx.j2 +9 -0
  110. litestar_vite/templates/react-tanstack/vite.config.ts.j2 +39 -0
  111. litestar_vite/templates/svelte/index.html.j2 +13 -0
  112. litestar_vite/templates/svelte/src/App.svelte.j2 +30 -0
  113. litestar_vite/templates/svelte/src/app.css.j2 +45 -0
  114. litestar_vite/templates/svelte/src/main.ts.j2 +8 -0
  115. litestar_vite/templates/svelte/src/vite-env.d.ts.j2 +2 -0
  116. litestar_vite/templates/svelte/svelte.config.js.j2 +5 -0
  117. litestar_vite/templates/svelte/vite.config.ts.j2 +39 -0
  118. litestar_vite/templates/svelte-inertia/index.html.j2 +14 -0
  119. litestar_vite/templates/svelte-inertia/resources/app.css.j2 +21 -0
  120. litestar_vite/templates/svelte-inertia/resources/main.ts.j2 +11 -0
  121. litestar_vite/templates/svelte-inertia/resources/pages/Home.svelte.j2 +43 -0
  122. litestar_vite/templates/svelte-inertia/resources/vite-env.d.ts.j2 +2 -0
  123. litestar_vite/templates/svelte-inertia/svelte.config.js.j2 +5 -0
  124. litestar_vite/templates/svelte-inertia/vite.config.ts.j2 +37 -0
  125. litestar_vite/templates/sveltekit/openapi-ts.config.ts.j2 +15 -0
  126. litestar_vite/templates/sveltekit/src/app.css.j2 +40 -0
  127. litestar_vite/templates/sveltekit/src/app.html.j2 +12 -0
  128. litestar_vite/templates/sveltekit/src/hooks.server.ts.j2 +55 -0
  129. litestar_vite/templates/sveltekit/src/routes/+layout.svelte.j2 +12 -0
  130. litestar_vite/templates/sveltekit/src/routes/+page.svelte.j2 +34 -0
  131. litestar_vite/templates/sveltekit/svelte.config.js.j2 +12 -0
  132. litestar_vite/templates/sveltekit/tsconfig.json.j2 +14 -0
  133. litestar_vite/templates/sveltekit/vite.config.ts.j2 +31 -0
  134. litestar_vite/templates/vue/env.d.ts.j2 +7 -0
  135. litestar_vite/templates/vue/index.html.j2 +13 -0
  136. litestar_vite/templates/vue/src/App.vue.j2 +28 -0
  137. litestar_vite/templates/vue/src/main.ts.j2 +5 -0
  138. litestar_vite/templates/vue/src/style.css.j2 +45 -0
  139. litestar_vite/templates/vue/vite.config.ts.j2 +39 -0
  140. litestar_vite/templates/vue-inertia/env.d.ts.j2 +7 -0
  141. litestar_vite/templates/vue-inertia/index.html.j2 +14 -0
  142. litestar_vite/templates/vue-inertia/package.json.j2 +49 -0
  143. litestar_vite/templates/vue-inertia/resources/main.ts.j2 +18 -0
  144. litestar_vite/templates/vue-inertia/resources/pages/Home.vue.j2 +22 -0
  145. litestar_vite/templates/vue-inertia/resources/ssr.ts.j2 +21 -0
  146. litestar_vite/templates/vue-inertia/resources/style.css.j2 +21 -0
  147. litestar_vite/templates/vue-inertia/vite.config.ts.j2 +59 -0
  148. litestar_vite-0.15.0rc2.dist-info/METADATA +230 -0
  149. litestar_vite-0.15.0rc2.dist-info/RECORD +151 -0
  150. {litestar_vite-0.1.1.dist-info → litestar_vite-0.15.0rc2.dist-info}/WHEEL +1 -1
  151. litestar_vite/template_engine.py +0 -103
  152. litestar_vite-0.1.1.dist-info/METADATA +0 -68
  153. litestar_vite-0.1.1.dist-info/RECORD +0 -11
  154. {litestar_vite-0.1.1.dist-info → litestar_vite-0.15.0rc2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1043 @@
1
+ import inspect
2
+ from collections import defaultdict
3
+ from collections.abc import Callable, Coroutine, Generator, Iterable, Mapping
4
+ from contextlib import contextmanager
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, Any, Generic, Literal, TypeGuard, TypeVar, cast, overload
7
+
8
+ from anyio.from_thread import BlockingPortal, start_blocking_portal
9
+ from litestar.exceptions import ImproperlyConfiguredException
10
+ from litestar.utils.empty import value_or_default
11
+ from litestar.utils.scope.state import ScopeState
12
+ from typing_extensions import ParamSpec
13
+
14
+ from litestar_vite.inertia.types import ScrollPropsConfig
15
+
16
+ if TYPE_CHECKING:
17
+ from litestar.connection import ASGIConnection
18
+
19
+ from litestar_vite.inertia.plugin import InertiaPlugin
20
+ T = TypeVar("T")
21
+ T_ParamSpec = ParamSpec("T_ParamSpec")
22
+ PropKeyT = TypeVar("PropKeyT", bound=str)
23
+ StaticT = TypeVar("StaticT", bound=object)
24
+
25
+ DEFAULT_DEFERRED_GROUP = "default"
26
+
27
+
28
+ @overload
29
+ def lazy(key: str, value_or_callable: "None") -> "StaticProp[str, None]": ...
30
+
31
+
32
+ @overload
33
+ def lazy(key: str, value_or_callable: "T") -> "StaticProp[str, T]": ...
34
+
35
+
36
+ @overload
37
+ def lazy(key: str, value_or_callable: "Callable[..., None]" = ...) -> "DeferredProp[str, None]": ...
38
+
39
+
40
+ @overload
41
+ def lazy(
42
+ key: str, value_or_callable: "Callable[..., Coroutine[Any, Any, None]]" = ...
43
+ ) -> "DeferredProp[str, None]": ...
44
+
45
+
46
+ @overload
47
+ def lazy(
48
+ key: str,
49
+ value_or_callable: "Callable[..., T | Coroutine[Any, Any, T]]" = ..., # pyright: ignore[reportInvalidTypeVarUse]
50
+ ) -> "DeferredProp[str, T]": ...
51
+
52
+
53
+ def lazy(
54
+ key: str,
55
+ value_or_callable: "T | Callable[..., Coroutine[Any, Any, None]] | Callable[..., T] | Callable[..., T | Coroutine[Any, Any, T]] | None" = None,
56
+ ) -> "StaticProp[str, None] | StaticProp[str, T] | DeferredProp[str, T] | DeferredProp[str, None]":
57
+ """Create a lazy prop only included during partial reloads.
58
+
59
+ Lazy props are excluded from the initial page load and only sent when
60
+ explicitly requested via partial reload (X-Inertia-Partial-Data header).
61
+ This optimizes initial page load by deferring non-critical data.
62
+
63
+ There are two use cases for lazy():
64
+
65
+ **1. Static Value (bandwidth optimization)**:
66
+ The value is computed eagerly but only sent during partial reloads.
67
+ Use when the value is cheap to compute but you want to reduce initial payload.
68
+
69
+ >>> lazy("user_count", len(users))
70
+
71
+ **2. Callable (bandwidth + CPU optimization)**:
72
+ The callable is only invoked during partial reloads.
73
+ Use when the value is expensive to compute.
74
+
75
+ >>> lazy("permissions", lambda: Permission.all())
76
+
77
+ .. warning:: **False Lazy Pitfall**
78
+
79
+ Be careful not to accidentally call the function when passing it.
80
+
81
+ Wrong::
82
+
83
+ lazy("data", expensive_fn())
84
+
85
+ Correct::
86
+
87
+ lazy("data", expensive_fn)
88
+
89
+ This is a Python evaluation order issue, not a framework limitation.
90
+
91
+ Args:
92
+ key: The key to store the value under in the props dict.
93
+ value_or_callable: Either a static value (computed eagerly, sent lazily)
94
+ or a callable (computed and sent lazily). If None, creates a lazy
95
+ prop with None value.
96
+
97
+ Returns:
98
+ StaticProp if value_or_callable is not callable, DeferredProp otherwise.
99
+
100
+ Example::
101
+
102
+ from litestar_vite.inertia import lazy, InertiaResponse
103
+
104
+ @get("/dashboard", component="Dashboard")
105
+ async def dashboard() -> InertiaResponse:
106
+ props = {
107
+ "user": current_user,
108
+ "user_count": lazy("user_count", 42),
109
+ "permissions": lazy("permissions", lambda: Permission.all()),
110
+ "notifications": lazy("notifications", fetch_notifications),
111
+ }
112
+ return InertiaResponse(props)
113
+
114
+ See Also:
115
+ - :func:`defer`: For v2 grouped deferred props loaded after page render
116
+ - Inertia.js partial reloads: https://inertiajs.com/partial-reloads
117
+ """
118
+ if value_or_callable is None:
119
+ return StaticProp[str, None](key=key, value=None)
120
+
121
+ if not callable(value_or_callable):
122
+ return StaticProp[str, T](key=key, value=value_or_callable)
123
+
124
+ return DeferredProp[str, T](key=key, value=cast("Callable[..., T | Coroutine[Any, Any, T]]", value_or_callable))
125
+
126
+
127
+ def defer(
128
+ key: str, callback: "Callable[..., T | Coroutine[Any, Any, T]]", group: str = DEFAULT_DEFERRED_GROUP
129
+ ) -> "DeferredProp[str, T]":
130
+ """Create a deferred prop with optional grouping (v2 feature).
131
+
132
+ Deferred props are loaded lazily after the initial page render.
133
+ Props in the same group are fetched together in a single request.
134
+
135
+ Args:
136
+ key: The key to store the value under.
137
+ callback: A callable (sync or async) that returns the value.
138
+ group: The group name for batched loading. Defaults to "default".
139
+
140
+ Returns:
141
+ A DeferredProp instance.
142
+
143
+ Example::
144
+
145
+ defer("permissions", lambda: Permission.all())
146
+
147
+ defer("teams", lambda: Team.all(), group="attributes")
148
+ defer("projects", lambda: Project.all(), group="attributes")
149
+ """
150
+ return DeferredProp[str, T](key=key, value=callback, group=group)
151
+
152
+
153
+ @dataclass
154
+ class PropFilter:
155
+ """Configuration for prop filtering during partial reloads.
156
+
157
+ Used with ``only()`` and ``except_()`` helpers to explicitly control
158
+ which props are sent during partial reload requests.
159
+
160
+ Attributes:
161
+ include: Set of prop keys to include (only send these).
162
+ exclude: Set of prop keys to exclude (send all except these).
163
+ """
164
+
165
+ include: "set[str] | None" = None
166
+ exclude: "set[str] | None" = None
167
+
168
+ def should_include(self, key: str) -> bool:
169
+ """Return True when a prop key should be included.
170
+
171
+ Returns:
172
+ True if the prop key should be included, otherwise False.
173
+ """
174
+ if self.exclude is not None:
175
+ return key not in self.exclude
176
+ if self.include is not None:
177
+ return key in self.include
178
+ return True
179
+
180
+
181
+ def only(*keys: str) -> PropFilter:
182
+ """Create a filter that only includes the specified prop keys.
183
+
184
+ Use this to explicitly limit which props are sent during partial reloads.
185
+ Only the specified props will be included in the response.
186
+
187
+ Args:
188
+ *keys: The prop keys to include.
189
+
190
+ Returns:
191
+ A PropFilter configured to include only the specified keys.
192
+
193
+ Example::
194
+
195
+ from litestar_vite.inertia import only, InertiaResponse
196
+
197
+ @get("/users", component="Users")
198
+ async def list_users(
199
+ request: InertiaRequest,
200
+ user_service: UserService,
201
+ ) -> InertiaResponse:
202
+ return InertiaResponse(
203
+ {
204
+ "users": user_service.list(),
205
+ "teams": team_service.list(),
206
+ "stats": stats_service.get(),
207
+ },
208
+ prop_filter=only("users"),
209
+ )
210
+
211
+ Note:
212
+ This is a server-side helper. The client should use Inertia's
213
+ ``router.reload({ only: ['users'] })`` for client-initiated filtering.
214
+ """
215
+ return PropFilter(include=set(keys))
216
+
217
+
218
+ def except_(*keys: str) -> PropFilter:
219
+ """Create a filter that excludes the specified prop keys.
220
+
221
+ Use this to explicitly exclude certain props during partial reloads.
222
+ All props except the specified ones will be included in the response.
223
+
224
+ Args:
225
+ *keys: The prop keys to exclude.
226
+
227
+ Returns:
228
+ A PropFilter configured to exclude the specified keys.
229
+
230
+ Example::
231
+
232
+ from litestar_vite.inertia import except_, InertiaResponse
233
+
234
+ @get("/users", component="Users")
235
+ async def list_users(
236
+ request: InertiaRequest,
237
+ user_service: UserService,
238
+ ) -> InertiaResponse:
239
+ return InertiaResponse(
240
+ {
241
+ "users": user_service.list(),
242
+ "teams": team_service.list(),
243
+ "stats": expensive_stats(),
244
+ },
245
+ prop_filter=except_("stats"),
246
+ )
247
+
248
+ Note:
249
+ The function is named ``except_`` with a trailing underscore to avoid
250
+ conflicting with Python's ``except`` keyword.
251
+ """
252
+ return PropFilter(exclude=set(keys))
253
+
254
+
255
+ class MergeProp(Generic[PropKeyT, T]):
256
+ """A wrapper for merge prop configuration (v2 feature).
257
+
258
+ Merge props allow data to be combined with existing props during
259
+ partial reloads instead of replacing them entirely.
260
+ """
261
+
262
+ def __init__(
263
+ self,
264
+ key: "PropKeyT",
265
+ value: "T",
266
+ strategy: "Literal['append', 'prepend', 'deep']" = "append",
267
+ match_on: "str | list[str] | None" = None,
268
+ ) -> None:
269
+ """Initialize a MergeProp.
270
+
271
+ Args:
272
+ key: The prop key.
273
+ value: The value to merge.
274
+ strategy: The merge strategy - 'append', 'prepend', or 'deep'.
275
+ match_on: Optional key(s) to match items on during merge.
276
+ """
277
+ self._key = key
278
+ self._value = value
279
+ self._strategy = strategy
280
+ self._match_on = [match_on] if isinstance(match_on, str) else match_on
281
+
282
+ @property
283
+ def key(self) -> "PropKeyT":
284
+ return self._key
285
+
286
+ @property
287
+ def value(self) -> "T":
288
+ return self._value
289
+
290
+ @property
291
+ def strategy(self) -> "Literal['append', 'prepend', 'deep']":
292
+ return self._strategy # pyright: ignore[reportReturnType]
293
+
294
+ @property
295
+ def match_on(self) -> "list[str] | None":
296
+ return self._match_on
297
+
298
+
299
+ def merge(
300
+ key: str,
301
+ value: "T",
302
+ strategy: "Literal['append', 'prepend', 'deep']" = "append",
303
+ match_on: "str | list[str] | None" = None,
304
+ ) -> "MergeProp[str, T]":
305
+ """Create a merge prop for combining data during partial reloads (v2 feature).
306
+
307
+ Merge props allow new data to be combined with existing props rather than
308
+ replacing them entirely. This is useful for infinite scroll, load more buttons,
309
+ and similar patterns.
310
+
311
+ Note: Prop merging only works during partial reloads. Full page visits
312
+ will always replace props entirely.
313
+
314
+ Args:
315
+ key: The prop key.
316
+ value: The value to merge.
317
+ strategy: How to merge the data:
318
+ - 'append': Add new items to the end (default)
319
+ - 'prepend': Add new items to the beginning
320
+ - 'deep': Recursively merge nested objects
321
+ match_on: Optional key(s) to match items on during merge,
322
+ useful for updating existing items instead of duplicating.
323
+
324
+ Returns:
325
+ A MergeProp instance.
326
+
327
+ Example::
328
+
329
+ merge("posts", new_posts)
330
+
331
+ merge("messages", new_messages, strategy="prepend")
332
+
333
+ merge("user_data", updates, strategy="deep")
334
+
335
+ merge("posts", updated_posts, match_on="id")
336
+ """
337
+ return MergeProp[str, T](key=key, value=value, strategy=strategy, match_on=match_on)
338
+
339
+
340
+ def scroll_props(
341
+ page_name: str = "page", current_page: int = 1, previous_page: "int | None" = None, next_page: "int | None" = None
342
+ ) -> "ScrollPropsConfig":
343
+ """Create scroll props configuration for infinite scroll (v2 feature).
344
+
345
+ Scroll props allow Inertia to manage pagination state for infinite scroll
346
+ patterns, providing next/previous page information to the client.
347
+
348
+ Args:
349
+ page_name: The query parameter name for pagination. Defaults to "page".
350
+ current_page: The current page number. Defaults to 1.
351
+ previous_page: The previous page number, or None if at first page.
352
+ next_page: The next page number, or None if at last page.
353
+
354
+ Returns:
355
+ A ScrollPropsConfig instance for use in InertiaResponse.
356
+
357
+ Example::
358
+
359
+ from litestar_vite.inertia import scroll_props, InertiaResponse
360
+
361
+ @get("/posts", component="Posts")
362
+ async def list_posts(page: int = 1) -> InertiaResponse:
363
+ posts = await Post.paginate(page=page, per_page=20)
364
+ return InertiaResponse(
365
+ {"posts": merge("posts", posts.items)},
366
+ scroll_props=scroll_props(
367
+ current_page=page,
368
+ previous_page=page - 1 if page > 1 else None,
369
+ next_page=page + 1 if posts.has_more else None,
370
+ ),
371
+ )
372
+ """
373
+ return ScrollPropsConfig(
374
+ page_name=page_name, current_page=current_page, previous_page=previous_page, next_page=next_page
375
+ )
376
+
377
+
378
+ def is_merge_prop(value: "Any") -> "TypeGuard[MergeProp[Any, Any]]":
379
+ """Check if value is a MergeProp.
380
+
381
+ Args:
382
+ value: Any value to check
383
+
384
+ Returns:
385
+ True if value is a MergeProp
386
+ """
387
+ return isinstance(value, MergeProp)
388
+
389
+
390
+ def extract_merge_props(props: "dict[str, Any]") -> "tuple[list[str], list[str], list[str], dict[str, list[str]]]":
391
+ """Extract merge props metadata for the Inertia v2 protocol.
392
+
393
+ This extracts all MergeProp instances from the props dict and categorizes them
394
+ by their merge strategy, returning the appropriate lists for the page response.
395
+
396
+ Args:
397
+ props: The props dictionary to scan.
398
+
399
+ Returns:
400
+ A tuple of (merge_props, prepend_props, deep_merge_props, match_props_on)
401
+ where each list contains the prop keys for that strategy, and match_props_on
402
+ is a dict mapping prop keys to the keys to match on.
403
+
404
+ Example::
405
+
406
+ props = {
407
+ "users": [...],
408
+ "posts": merge("posts", new_posts),
409
+ "messages": merge("messages", new_msgs, strategy="prepend"),
410
+ "data": merge("data", updates, strategy="deep"),
411
+ "items": merge("items", items, match_on="id"),
412
+ }
413
+ merge_props, prepend_props, deep_merge_props, match_props_on = extract_merge_props(props)
414
+
415
+ The returned values then contain:
416
+
417
+ - merge_props: ["posts", "items"]
418
+ - prepend_props: ["messages"]
419
+ - deep_merge_props: ["data"]
420
+ - match_props_on: {"items": ["id"]}
421
+ """
422
+ merge_list: "list[str]" = []
423
+ prepend_list: "list[str]" = []
424
+ deep_merge_list: "list[str]" = []
425
+ match_on_dict: "dict[str, list[str]]" = {}
426
+
427
+ for key, value in props.items():
428
+ if is_merge_prop(value):
429
+ match value.strategy:
430
+ case "append":
431
+ merge_list.append(key)
432
+ case "prepend":
433
+ prepend_list.append(key)
434
+ case "deep":
435
+ deep_merge_list.append(key)
436
+ case _:
437
+ pass
438
+
439
+ if value.match_on:
440
+ match_on_dict[key] = value.match_on
441
+
442
+ return merge_list, prepend_list, deep_merge_list, match_on_dict
443
+
444
+
445
+ class StaticProp(Generic[PropKeyT, StaticT]):
446
+ """A wrapper for static property evaluation."""
447
+
448
+ def __init__(self, key: "PropKeyT", value: "StaticT") -> None:
449
+ self._key = key
450
+ self._result = value
451
+
452
+ @property
453
+ def key(self) -> "PropKeyT":
454
+ return self._key
455
+
456
+ def render(self, portal: "BlockingPortal | None" = None) -> "StaticT": # pyright: ignore
457
+ return self._result
458
+
459
+
460
+ class DeferredProp(Generic[PropKeyT, T]):
461
+ """A wrapper for deferred property evaluation."""
462
+
463
+ def __init__(
464
+ self,
465
+ key: "PropKeyT",
466
+ value: "Callable[..., T | Coroutine[Any, Any, T] | None] | None" = None,
467
+ group: str = DEFAULT_DEFERRED_GROUP,
468
+ ) -> None:
469
+ self._key = key
470
+ self._value = value
471
+ self._group = group
472
+ self._evaluated = False
473
+ self._result: "T | None" = None
474
+
475
+ @property
476
+ def group(self) -> str:
477
+ """The deferred group this prop belongs to.
478
+
479
+ Returns:
480
+ The deferred group name.
481
+ """
482
+ return self._group
483
+
484
+ @property
485
+ def key(self) -> "PropKeyT":
486
+ return self._key
487
+
488
+ @staticmethod
489
+ @contextmanager
490
+ def with_portal(portal: "BlockingPortal | None" = None) -> "Generator[BlockingPortal, None, None]":
491
+ if portal is None:
492
+ with start_blocking_portal() as p:
493
+ yield p
494
+ else:
495
+ yield portal
496
+
497
+ @staticmethod
498
+ def _is_awaitable(v: "Callable[..., T | Coroutine[Any, Any, T]]") -> "TypeGuard[Coroutine[Any, Any, T]]":
499
+ return inspect.iscoroutinefunction(v)
500
+
501
+ def render(self, portal: "BlockingPortal | None" = None) -> "T | None":
502
+ if self._evaluated:
503
+ return self._result
504
+ if self._value is None or not callable(self._value):
505
+ self._result = self._value
506
+ self._evaluated = True
507
+ return self._result
508
+ if not self._is_awaitable(cast("Callable[..., T]", self._value)):
509
+ self._result = cast("T", self._value())
510
+ self._evaluated = True
511
+ return self._result
512
+ with self.with_portal(portal) as p:
513
+ self._result = p.call(cast("Callable[..., T]", self._value))
514
+ self._evaluated = True
515
+ return self._result
516
+
517
+
518
+ def is_lazy_prop(value: "Any") -> "TypeGuard[DeferredProp[Any, Any] | StaticProp[Any, Any]]":
519
+ """Check if value is a deferred property.
520
+
521
+ Args:
522
+ value: Any value to check
523
+
524
+ Returns:
525
+ True if value is a deferred property
526
+ """
527
+ return isinstance(value, (DeferredProp, StaticProp))
528
+
529
+
530
+ def is_deferred_prop(value: "Any") -> "TypeGuard[DeferredProp[Any, Any]]":
531
+ """Check if value is specifically a DeferredProp (not StaticProp).
532
+
533
+ Args:
534
+ value: Any value to check
535
+
536
+ Returns:
537
+ True if value is a DeferredProp
538
+ """
539
+ return isinstance(value, DeferredProp)
540
+
541
+
542
+ def extract_deferred_props(props: "dict[str, Any]") -> "dict[str, list[str]]":
543
+ """Extract deferred props metadata for the Inertia v2 protocol.
544
+
545
+ This extracts all DeferredProp instances from the props dict and groups them
546
+ by their group name, returning a dict mapping group -> list of prop keys.
547
+
548
+ Args:
549
+ props: The props dictionary to scan.
550
+
551
+ Returns:
552
+ A dict mapping group names to lists of prop keys in that group.
553
+ Empty dict if no deferred props found.
554
+
555
+ Example::
556
+
557
+ props = {
558
+ "users": [...],
559
+ "teams": defer("teams", get_teams, group="attributes"),
560
+ "projects": defer("projects", get_projects, group="attributes"),
561
+ "permissions": defer("permissions", get_permissions),
562
+ }
563
+ result = extract_deferred_props(props)
564
+
565
+ The result is {"default": ["permissions"], "attributes": ["teams", "projects"]}.
566
+ """
567
+ groups: "dict[str, list[str]]" = {}
568
+
569
+ for key, value in props.items():
570
+ if is_deferred_prop(value):
571
+ group = value.group
572
+ if group not in groups:
573
+ groups[group] = []
574
+ groups[group].append(key)
575
+
576
+ return groups
577
+
578
+
579
+ def should_render(
580
+ value: "Any",
581
+ partial_data: "set[str] | None" = None,
582
+ partial_except: "set[str] | None" = None,
583
+ key: "str | None" = None,
584
+ ) -> "bool":
585
+ """Check if value should be rendered based on partial reload filtering.
586
+
587
+ For v2 protocol, partial_except takes precedence over partial_data.
588
+ When a key is provided, filtering applies to all props (not just lazy props).
589
+
590
+ Args:
591
+ value: Any value to check
592
+ partial_data: Optional set of keys to include (X-Inertia-Partial-Data)
593
+ partial_except: Optional set of keys to exclude (X-Inertia-Partial-Except, v2)
594
+ key: Optional key name for this prop (enables key-based filtering for all props)
595
+
596
+ Returns:
597
+ bool: True if value should be rendered
598
+ """
599
+ if is_lazy_prop(value):
600
+ if partial_except:
601
+ return value.key not in partial_except
602
+ if partial_data:
603
+ return value.key in partial_data
604
+ return False
605
+
606
+ if key is not None:
607
+ if partial_except:
608
+ return key not in partial_except
609
+ if partial_data:
610
+ return key in partial_data
611
+
612
+ return True
613
+
614
+
615
+ def is_or_contains_lazy_prop(value: "Any") -> "bool":
616
+ """Check if value is or contains a deferred property.
617
+
618
+ Args:
619
+ value: Any value to check
620
+
621
+ Returns:
622
+ True if value is or contains a deferred property
623
+ """
624
+ if is_lazy_prop(value):
625
+ return True
626
+ if isinstance(value, str):
627
+ return False
628
+ if isinstance(value, Mapping):
629
+ return any(is_or_contains_lazy_prop(v) for v in cast("Mapping[str, Any]", value).values())
630
+ if isinstance(value, Iterable):
631
+ return any(is_or_contains_lazy_prop(v) for v in cast("Iterable[Any]", value))
632
+ return False
633
+
634
+
635
+ def lazy_render(
636
+ value: "T",
637
+ partial_data: "set[str] | None" = None,
638
+ portal: "BlockingPortal | None" = None,
639
+ partial_except: "set[str] | None" = None,
640
+ ) -> "T":
641
+ """Filter deferred properties from the value based on partial data.
642
+
643
+ For v2 protocol, partial_except takes precedence over partial_data.
644
+
645
+ Args:
646
+ value: The value to filter
647
+ partial_data: Keys to include (X-Inertia-Partial-Data)
648
+ portal: Optional portal to use for async rendering
649
+ partial_except: Keys to exclude (X-Inertia-Partial-Except, v2)
650
+
651
+ Returns:
652
+ The filtered value
653
+ """
654
+ if isinstance(value, str):
655
+ return cast("T", value)
656
+ if isinstance(value, Mapping):
657
+ return cast(
658
+ "T",
659
+ {
660
+ k: lazy_render(v, partial_data, portal, partial_except)
661
+ for k, v in cast("Mapping[str, Any]", value).items()
662
+ if should_render(v, partial_data, partial_except)
663
+ },
664
+ )
665
+
666
+ if isinstance(value, list):
667
+ return cast(
668
+ "T",
669
+ [
670
+ lazy_render(v, partial_data, portal, partial_except)
671
+ for v in cast("Iterable[Any]", value)
672
+ if should_render(v, partial_data, partial_except)
673
+ ],
674
+ )
675
+
676
+ if isinstance(value, tuple):
677
+ return cast(
678
+ "T",
679
+ tuple(
680
+ lazy_render(v, partial_data, portal, partial_except)
681
+ for v in cast("Iterable[Any]", value)
682
+ if should_render(v, partial_data, partial_except)
683
+ ),
684
+ )
685
+
686
+ if is_lazy_prop(value) and should_render(value, partial_data, partial_except):
687
+ return cast("T", value.render(portal))
688
+
689
+ return cast("T", value)
690
+
691
+
692
+ def get_shared_props(
693
+ request: "ASGIConnection[Any, Any, Any, Any]",
694
+ partial_data: "set[str] | None" = None,
695
+ partial_except: "set[str] | None" = None,
696
+ ) -> "dict[str, Any]":
697
+ """Return shared session props for a request.
698
+
699
+ For v2 protocol, partial_except takes precedence over partial_data.
700
+
701
+ Args:
702
+ request: The ASGI connection.
703
+ partial_data: Optional set of keys to include (X-Inertia-Partial-Data).
704
+ partial_except: Optional set of keys to exclude (X-Inertia-Partial-Except, v2).
705
+
706
+ Returns:
707
+ The shared props.
708
+
709
+ Note:
710
+ Be sure to call this before `self.create_template_context` if you would like to include the `flash` message details.
711
+ """
712
+ props: "dict[str, Any]" = {}
713
+ flash: "dict[str, list[str]]" = defaultdict(list)
714
+ errors: "dict[str, Any]" = {}
715
+ error_bag = request.headers.get("X-Inertia-Error-Bag", None)
716
+
717
+ try:
718
+ errors = request.session.pop("_errors", {})
719
+ shared_props = cast("dict[str,Any]", request.session.pop("_shared", {}))
720
+ inertia_plugin = cast("InertiaPlugin", request.app.plugins.get("InertiaPlugin"))
721
+
722
+ for key, value in shared_props.items():
723
+ if not should_render(value, partial_data, partial_except, key=key):
724
+ continue
725
+ if is_lazy_prop(value):
726
+ props[key] = value.render(inertia_plugin.portal)
727
+ else:
728
+ props[key] = value
729
+
730
+ for message in cast("list[dict[str,Any]]", request.session.pop("_messages", [])):
731
+ flash[message["category"]].append(message["message"])
732
+
733
+ for key, value in inertia_plugin.config.extra_static_page_props.items():
734
+ if should_render(value, partial_data, partial_except, key=key):
735
+ props[key] = value
736
+
737
+ for session_prop in inertia_plugin.config.extra_session_page_props:
738
+ if (
739
+ session_prop not in props
740
+ and session_prop in request.session
741
+ and should_render(None, partial_data, partial_except, key=session_prop)
742
+ ):
743
+ props[session_prop] = request.session.get(session_prop)
744
+
745
+ except (AttributeError, ImproperlyConfiguredException):
746
+ msg = "Unable to generate all shared props. A valid session was not found for this request."
747
+ request.logger.warning(msg)
748
+
749
+ props["flash"] = flash
750
+ props["errors"] = {error_bag: errors} if error_bag is not None else errors
751
+ props["csrf_token"] = value_or_default(ScopeState.from_scope(request.scope).csrf_token, "")
752
+ return props
753
+
754
+
755
+ def share(connection: "ASGIConnection[Any, Any, Any, Any]", key: "str", value: "Any") -> "None":
756
+ """Share a value in the session.
757
+
758
+ Args:
759
+ connection: The ASGI connection.
760
+ key: The key to store the value under.
761
+ value: The value to store.
762
+ """
763
+ try:
764
+ connection.session.setdefault("_shared", {}).update({key: value})
765
+ except (AttributeError, ImproperlyConfiguredException):
766
+ msg = "Unable to set `share` session state. A valid session was not found for this request."
767
+ connection.logger.warning(msg)
768
+
769
+
770
+ def error(connection: "ASGIConnection[Any, Any, Any, Any]", key: "str", message: "str") -> "None":
771
+ """Set an error message in the session.
772
+
773
+ Args:
774
+ connection: The ASGI connection.
775
+ key: The key to store the error under.
776
+ message: The error message.
777
+ """
778
+ try:
779
+ connection.session.setdefault("_errors", {}).update({key: message})
780
+ except (AttributeError, ImproperlyConfiguredException):
781
+ msg = "Unable to set `error` session state. A valid session was not found for this request."
782
+ connection.logger.warning(msg)
783
+
784
+
785
+ def flash(connection: "ASGIConnection[Any, Any, Any, Any]", message: "str", category: "str" = "info") -> "None":
786
+ """Add a flash message to the session.
787
+
788
+ Flash messages are stored in the session and passed to the frontend
789
+ via the `flash` prop in every Inertia response. They're automatically
790
+ cleared after being displayed (pop semantics).
791
+
792
+ This function works without requiring Litestar's FlashPlugin or
793
+ any Jinja2 template configuration, making it ideal for SPA-only
794
+ Inertia applications.
795
+
796
+ Args:
797
+ connection: The ASGI connection (Request or WebSocket).
798
+ message: The message text to display.
799
+ category: The message category (e.g., "success", "error", "warning", "info").
800
+ Defaults to "info".
801
+
802
+ Example::
803
+
804
+ from litestar_vite.inertia import flash
805
+
806
+ @post("/create")
807
+ async def create_item(request: Request) -> InertiaResponse:
808
+ flash(request, "Item created successfully!", "success")
809
+ return InertiaResponse(...)
810
+ """
811
+ try:
812
+ messages = connection.session.setdefault("_messages", [])
813
+ messages.append({"category": category, "message": message})
814
+ except (AttributeError, ImproperlyConfiguredException):
815
+ msg = "Unable to set flash message. A valid session was not found for this request."
816
+ connection.logger.warning(msg)
817
+
818
+
819
+ def clear_history(connection: "ASGIConnection[Any, Any, Any, Any]") -> None:
820
+ """Mark that the next response should clear client history encryption keys.
821
+
822
+ This function sets a session flag that will be consumed by the next
823
+ InertiaResponse, causing it to include `clearHistory: true` in the page
824
+ object. The Inertia client will then regenerate its encryption key,
825
+ invalidating all previously encrypted history entries.
826
+
827
+ This should typically be called during logout to ensure sensitive data
828
+ cannot be recovered from browser history after a user logs out.
829
+
830
+ Args:
831
+ connection: The ASGI connection (Request).
832
+
833
+ Note:
834
+ Requires session middleware to be configured.
835
+ See: https://inertiajs.com/history-encryption
836
+
837
+ Example::
838
+
839
+ from litestar_vite.inertia import clear_history
840
+
841
+ @post("/logout")
842
+ async def logout(request: Request) -> InertiaRedirect:
843
+ request.session.clear()
844
+ clear_history(request)
845
+ return InertiaRedirect(request, redirect_to="/login")
846
+ """
847
+ try:
848
+ connection.session["_inertia_clear_history"] = True
849
+ except (AttributeError, ImproperlyConfiguredException):
850
+ msg = "Unable to set clear_history flag. A valid session was not found for this request."
851
+ connection.logger.warning(msg)
852
+
853
+
854
+ def is_pagination_container(value: "Any") -> bool:
855
+ """Check if a value is a pagination container.
856
+
857
+ Detects common pagination types from Litestar and Advanced Alchemy:
858
+ - litestar.pagination.OffsetPagination (items, limit, offset, total)
859
+ - litestar.pagination.ClassicPagination (items, page_size, current_page, total_pages)
860
+ - advanced_alchemy.service.OffsetPagination
861
+
862
+ Also supports any object with an `items` attribute and pagination metadata.
863
+
864
+ Args:
865
+ value: The value to check.
866
+
867
+ Returns:
868
+ True if value appears to be a pagination container.
869
+ """
870
+ if value is None:
871
+ return False
872
+
873
+ try:
874
+ _ = value.items
875
+ except AttributeError:
876
+ return False
877
+
878
+ has_offset_style = _has_offset_pagination_attrs(value)
879
+ has_classic_style = _has_classic_pagination_attrs(value)
880
+
881
+ return has_offset_style or has_classic_style
882
+
883
+
884
+ def extract_pagination_scroll_props(value: "Any", page_param: str = "page") -> "tuple[Any, ScrollPropsConfig | None]":
885
+ """Extract items and scroll props from a pagination container.
886
+
887
+ For OffsetPagination, calculates page numbers from limit/offset/total.
888
+ For ClassicPagination, uses current_page/total_pages directly.
889
+
890
+ Args:
891
+ value: A pagination container (OffsetPagination, ClassicPagination, etc.).
892
+ page_param: The query parameter name for pagination (default: "page").
893
+
894
+ Returns:
895
+ A tuple of (items, scroll_props) where scroll_props is None if
896
+ value is not a pagination container.
897
+
898
+ Example::
899
+
900
+ items, scroll = extract_pagination_scroll_props(pagination)
901
+
902
+ For OffsetPagination with limit=10, offset=20, total=50 the resulting scroll
903
+ props are: ScrollPropsConfig(current_page=3, previous_page=2, next_page=4).
904
+ """
905
+ if not is_pagination_container(value):
906
+ return value, None
907
+
908
+ items = value.items
909
+
910
+ if meta := _extract_offset_pagination_meta(value):
911
+ current_page, previous_page, next_page = meta
912
+ scroll_props = ScrollPropsConfig(
913
+ page_name=page_param, current_page=current_page, previous_page=previous_page, next_page=next_page
914
+ )
915
+ return items, scroll_props
916
+
917
+ if meta := _extract_classic_pagination_meta(value):
918
+ current_page, previous_page, next_page = meta
919
+ scroll_props = ScrollPropsConfig(
920
+ page_name=page_param, current_page=current_page, previous_page=previous_page, next_page=next_page
921
+ )
922
+ return items, scroll_props
923
+
924
+ return items, None
925
+
926
+
927
+ PAGINATION_ATTRS: tuple[tuple[str, str], ...] = (
928
+ ("total", "total"),
929
+ ("limit", "limit"),
930
+ ("offset", "offset"),
931
+ ("page_size", "pageSize"),
932
+ ("current_page", "currentPage"),
933
+ ("total_pages", "totalPages"),
934
+ ("per_page", "perPage"),
935
+ ("last_page", "lastPage"),
936
+ ("has_more", "hasMore"),
937
+ ("has_next", "hasNext"),
938
+ ("has_previous", "hasPrevious"),
939
+ ("next_cursor", "nextCursor"),
940
+ ("previous_cursor", "previousCursor"),
941
+ )
942
+
943
+
944
+ def pagination_to_dict(value: "Any") -> dict[str, Any]:
945
+ """Convert a pagination container to a dict with items and all metadata.
946
+
947
+ Dynamically extracts known pagination attributes from any pagination
948
+ container class. This supports custom pagination implementations as
949
+ long as they have an ``items`` attribute and standard pagination metadata.
950
+
951
+ The function checks for common pagination attributes like ``total``,
952
+ ``limit``, ``offset`` (for offset pagination), ``page_size``, ``current_page``,
953
+ ``total_pages`` (for classic pagination), and cursor-based attributes.
954
+ Any found attributes are included in the result dict with camelCase keys.
955
+
956
+ Args:
957
+ value: A pagination container with ``items`` and metadata attributes.
958
+
959
+ Returns:
960
+ A dict with ``items`` and any found pagination metadata (camelCase keys).
961
+
962
+ Example::
963
+
964
+ from litestar.pagination import OffsetPagination
965
+
966
+ pagination = OffsetPagination(items=[1, 2, 3], limit=10, offset=0, total=50)
967
+ result = pagination_to_dict(pagination)
968
+
969
+ The result contains {"items": [1, 2, 3], "total": 50, "limit": 10, "offset": 0}.
970
+
971
+ Note:
972
+ This function is used internally by InertiaResponse to preserve
973
+ pagination metadata when returning pagination containers from routes.
974
+ """
975
+ result: dict[str, Any] = {"items": value.items}
976
+
977
+ for attr, camel_attr in PAGINATION_ATTRS:
978
+ try:
979
+ attr_value = value.__getattribute__(attr)
980
+ except AttributeError:
981
+ continue
982
+ result[camel_attr] = attr_value
983
+
984
+ return result
985
+
986
+
987
+ def _has_offset_pagination_attrs(value: Any) -> bool:
988
+ try:
989
+ _ = value.limit
990
+ _ = value.offset
991
+ _ = value.total
992
+ except AttributeError:
993
+ return False
994
+ return True
995
+
996
+
997
+ def _has_classic_pagination_attrs(value: Any) -> bool:
998
+ try:
999
+ _ = value.current_page
1000
+ _ = value.total_pages
1001
+ except AttributeError:
1002
+ return False
1003
+ return True
1004
+
1005
+
1006
+ def _extract_offset_pagination_meta(value: Any) -> tuple[int, int | None, int | None] | None:
1007
+ try:
1008
+ limit = value.limit
1009
+ offset = value.offset
1010
+ total = value.total
1011
+ except AttributeError:
1012
+ return None
1013
+
1014
+ if not (isinstance(limit, int) and isinstance(offset, int) and isinstance(total, int)):
1015
+ return None
1016
+
1017
+ if limit > 0:
1018
+ current_page = (offset // limit) + 1
1019
+ total_pages = (total + limit - 1) // limit
1020
+ else:
1021
+ current_page = 1
1022
+ total_pages = 1
1023
+
1024
+ previous_page = current_page - 1 if current_page > 1 else None
1025
+ next_page = current_page + 1 if current_page < total_pages else None
1026
+
1027
+ return current_page, previous_page, next_page
1028
+
1029
+
1030
+ def _extract_classic_pagination_meta(value: Any) -> tuple[int, int | None, int | None] | None:
1031
+ try:
1032
+ current_page = value.current_page
1033
+ total_pages = value.total_pages
1034
+ except AttributeError:
1035
+ return None
1036
+
1037
+ if not (isinstance(current_page, int) and isinstance(total_pages, int)):
1038
+ return None
1039
+
1040
+ previous_page = current_page - 1 if current_page > 1 else None
1041
+ next_page = current_page + 1 if current_page < total_pages else None
1042
+
1043
+ return current_page, previous_page, next_page