litestar-vite 0.1.1__py3-none-any.whl → 0.15.0__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 (169) hide show
  1. litestar_vite/__init__.py +54 -4
  2. litestar_vite/__metadata__.py +12 -7
  3. litestar_vite/cli.py +1048 -10
  4. litestar_vite/codegen/__init__.py +48 -0
  5. litestar_vite/codegen/_export.py +229 -0
  6. litestar_vite/codegen/_inertia.py +619 -0
  7. litestar_vite/codegen/_openapi.py +280 -0
  8. litestar_vite/codegen/_routes.py +720 -0
  9. litestar_vite/codegen/_ts.py +235 -0
  10. litestar_vite/codegen/_utils.py +141 -0
  11. litestar_vite/commands.py +73 -0
  12. litestar_vite/config/__init__.py +997 -0
  13. litestar_vite/config/_constants.py +97 -0
  14. litestar_vite/config/_deploy.py +70 -0
  15. litestar_vite/config/_inertia.py +241 -0
  16. litestar_vite/config/_paths.py +63 -0
  17. litestar_vite/config/_runtime.py +235 -0
  18. litestar_vite/config/_spa.py +93 -0
  19. litestar_vite/config/_types.py +94 -0
  20. litestar_vite/deploy.py +366 -0
  21. litestar_vite/doctor.py +1181 -0
  22. litestar_vite/exceptions.py +78 -0
  23. litestar_vite/executor.py +360 -0
  24. litestar_vite/handler/__init__.py +9 -0
  25. litestar_vite/handler/_app.py +612 -0
  26. litestar_vite/handler/_routing.py +130 -0
  27. litestar_vite/html_transform.py +569 -0
  28. litestar_vite/inertia/__init__.py +77 -0
  29. litestar_vite/inertia/_utils.py +119 -0
  30. litestar_vite/inertia/exception_handler.py +178 -0
  31. litestar_vite/inertia/helpers.py +1571 -0
  32. litestar_vite/inertia/middleware.py +54 -0
  33. litestar_vite/inertia/plugin.py +199 -0
  34. litestar_vite/inertia/precognition.py +274 -0
  35. litestar_vite/inertia/request.py +334 -0
  36. litestar_vite/inertia/response.py +802 -0
  37. litestar_vite/inertia/types.py +335 -0
  38. litestar_vite/loader.py +464 -123
  39. litestar_vite/plugin/__init__.py +687 -0
  40. litestar_vite/plugin/_process.py +185 -0
  41. litestar_vite/plugin/_proxy.py +689 -0
  42. litestar_vite/plugin/_proxy_headers.py +244 -0
  43. litestar_vite/plugin/_static.py +37 -0
  44. litestar_vite/plugin/_utils.py +489 -0
  45. litestar_vite/py.typed +0 -0
  46. litestar_vite/scaffolding/__init__.py +20 -0
  47. litestar_vite/scaffolding/generator.py +270 -0
  48. litestar_vite/scaffolding/templates.py +437 -0
  49. litestar_vite/templates/__init__.py +0 -0
  50. litestar_vite/templates/addons/tailwindcss/tailwind.css.j2 +1 -0
  51. litestar_vite/templates/angular/index.html.j2 +12 -0
  52. litestar_vite/templates/angular/openapi-ts.config.ts.j2 +18 -0
  53. litestar_vite/templates/angular/package.json.j2 +36 -0
  54. litestar_vite/templates/angular/src/app/app.component.css.j2 +3 -0
  55. litestar_vite/templates/angular/src/app/app.component.html.j2 +1 -0
  56. litestar_vite/templates/angular/src/app/app.component.ts.j2 +9 -0
  57. litestar_vite/templates/angular/src/app/app.config.ts.j2 +5 -0
  58. litestar_vite/templates/angular/src/main.ts.j2 +9 -0
  59. litestar_vite/templates/angular/src/styles.css.j2 +9 -0
  60. litestar_vite/templates/angular/tsconfig.app.json.j2 +34 -0
  61. litestar_vite/templates/angular/tsconfig.json.j2 +20 -0
  62. litestar_vite/templates/angular/vite.config.ts.j2 +21 -0
  63. litestar_vite/templates/angular-cli/.postcssrc.json.j2 +5 -0
  64. litestar_vite/templates/angular-cli/angular.json.j2 +36 -0
  65. litestar_vite/templates/angular-cli/openapi-ts.config.ts.j2 +18 -0
  66. litestar_vite/templates/angular-cli/package.json.j2 +28 -0
  67. litestar_vite/templates/angular-cli/proxy.conf.json.j2 +18 -0
  68. litestar_vite/templates/angular-cli/src/app/app.component.css.j2 +3 -0
  69. litestar_vite/templates/angular-cli/src/app/app.component.html.j2 +1 -0
  70. litestar_vite/templates/angular-cli/src/app/app.component.ts.j2 +9 -0
  71. litestar_vite/templates/angular-cli/src/app/app.config.ts.j2 +5 -0
  72. litestar_vite/templates/angular-cli/src/index.html.j2 +13 -0
  73. litestar_vite/templates/angular-cli/src/main.ts.j2 +6 -0
  74. litestar_vite/templates/angular-cli/src/styles.css.j2 +10 -0
  75. litestar_vite/templates/angular-cli/tailwind.config.js.j2 +4 -0
  76. litestar_vite/templates/angular-cli/tsconfig.app.json.j2 +16 -0
  77. litestar_vite/templates/angular-cli/tsconfig.json.j2 +26 -0
  78. litestar_vite/templates/angular-cli/tsconfig.spec.json.j2 +9 -0
  79. litestar_vite/templates/astro/astro.config.mjs.j2 +28 -0
  80. litestar_vite/templates/astro/openapi-ts.config.ts.j2 +15 -0
  81. litestar_vite/templates/astro/src/layouts/Layout.astro.j2 +63 -0
  82. litestar_vite/templates/astro/src/pages/index.astro.j2 +36 -0
  83. litestar_vite/templates/astro/src/styles/global.css.j2 +1 -0
  84. litestar_vite/templates/base/.gitignore.j2 +42 -0
  85. litestar_vite/templates/base/openapi-ts.config.ts.j2 +15 -0
  86. litestar_vite/templates/base/package.json.j2 +39 -0
  87. litestar_vite/templates/base/resources/vite-env.d.ts.j2 +1 -0
  88. litestar_vite/templates/base/tsconfig.json.j2 +37 -0
  89. litestar_vite/templates/htmx/src/main.js.j2 +8 -0
  90. litestar_vite/templates/htmx/templates/base.html.j2.j2 +56 -0
  91. litestar_vite/templates/htmx/templates/index.html.j2.j2 +13 -0
  92. litestar_vite/templates/htmx/vite.config.ts.j2 +40 -0
  93. litestar_vite/templates/nuxt/app.vue.j2 +29 -0
  94. litestar_vite/templates/nuxt/composables/useApi.ts.j2 +33 -0
  95. litestar_vite/templates/nuxt/nuxt.config.ts.j2 +31 -0
  96. litestar_vite/templates/nuxt/openapi-ts.config.ts.j2 +15 -0
  97. litestar_vite/templates/nuxt/pages/index.vue.j2 +54 -0
  98. litestar_vite/templates/react/index.html.j2 +13 -0
  99. litestar_vite/templates/react/src/App.css.j2 +56 -0
  100. litestar_vite/templates/react/src/App.tsx.j2 +19 -0
  101. litestar_vite/templates/react/src/main.tsx.j2 +10 -0
  102. litestar_vite/templates/react/vite.config.ts.j2 +39 -0
  103. litestar_vite/templates/react-inertia/index.html.j2 +14 -0
  104. litestar_vite/templates/react-inertia/package.json.j2 +47 -0
  105. litestar_vite/templates/react-inertia/resources/App.css.j2 +68 -0
  106. litestar_vite/templates/react-inertia/resources/main.tsx.j2 +17 -0
  107. litestar_vite/templates/react-inertia/resources/pages/Home.tsx.j2 +18 -0
  108. litestar_vite/templates/react-inertia/resources/ssr.tsx.j2 +19 -0
  109. litestar_vite/templates/react-inertia/vite.config.ts.j2 +59 -0
  110. litestar_vite/templates/react-router/index.html.j2 +12 -0
  111. litestar_vite/templates/react-router/src/App.css.j2 +17 -0
  112. litestar_vite/templates/react-router/src/App.tsx.j2 +7 -0
  113. litestar_vite/templates/react-router/src/main.tsx.j2 +10 -0
  114. litestar_vite/templates/react-router/vite.config.ts.j2 +39 -0
  115. litestar_vite/templates/react-tanstack/index.html.j2 +12 -0
  116. litestar_vite/templates/react-tanstack/openapi-ts.config.ts.j2 +18 -0
  117. litestar_vite/templates/react-tanstack/src/App.css.j2 +17 -0
  118. litestar_vite/templates/react-tanstack/src/main.tsx.j2 +21 -0
  119. litestar_vite/templates/react-tanstack/src/routeTree.gen.ts.j2 +7 -0
  120. litestar_vite/templates/react-tanstack/src/routes/__root.tsx.j2 +9 -0
  121. litestar_vite/templates/react-tanstack/src/routes/books.tsx.j2 +9 -0
  122. litestar_vite/templates/react-tanstack/src/routes/index.tsx.j2 +9 -0
  123. litestar_vite/templates/react-tanstack/vite.config.ts.j2 +39 -0
  124. litestar_vite/templates/svelte/index.html.j2 +13 -0
  125. litestar_vite/templates/svelte/src/App.svelte.j2 +30 -0
  126. litestar_vite/templates/svelte/src/app.css.j2 +45 -0
  127. litestar_vite/templates/svelte/src/main.ts.j2 +8 -0
  128. litestar_vite/templates/svelte/src/vite-env.d.ts.j2 +2 -0
  129. litestar_vite/templates/svelte/svelte.config.js.j2 +5 -0
  130. litestar_vite/templates/svelte/vite.config.ts.j2 +39 -0
  131. litestar_vite/templates/svelte-inertia/index.html.j2 +14 -0
  132. litestar_vite/templates/svelte-inertia/resources/app.css.j2 +21 -0
  133. litestar_vite/templates/svelte-inertia/resources/main.ts.j2 +11 -0
  134. litestar_vite/templates/svelte-inertia/resources/pages/Home.svelte.j2 +43 -0
  135. litestar_vite/templates/svelte-inertia/resources/vite-env.d.ts.j2 +2 -0
  136. litestar_vite/templates/svelte-inertia/svelte.config.js.j2 +5 -0
  137. litestar_vite/templates/svelte-inertia/vite.config.ts.j2 +37 -0
  138. litestar_vite/templates/sveltekit/openapi-ts.config.ts.j2 +15 -0
  139. litestar_vite/templates/sveltekit/src/app.css.j2 +40 -0
  140. litestar_vite/templates/sveltekit/src/app.html.j2 +12 -0
  141. litestar_vite/templates/sveltekit/src/hooks.server.ts.j2 +55 -0
  142. litestar_vite/templates/sveltekit/src/routes/+layout.svelte.j2 +12 -0
  143. litestar_vite/templates/sveltekit/src/routes/+page.svelte.j2 +34 -0
  144. litestar_vite/templates/sveltekit/svelte.config.js.j2 +12 -0
  145. litestar_vite/templates/sveltekit/tsconfig.json.j2 +14 -0
  146. litestar_vite/templates/sveltekit/vite.config.ts.j2 +31 -0
  147. litestar_vite/templates/vue/env.d.ts.j2 +7 -0
  148. litestar_vite/templates/vue/index.html.j2 +13 -0
  149. litestar_vite/templates/vue/src/App.vue.j2 +28 -0
  150. litestar_vite/templates/vue/src/main.ts.j2 +5 -0
  151. litestar_vite/templates/vue/src/style.css.j2 +45 -0
  152. litestar_vite/templates/vue/vite.config.ts.j2 +39 -0
  153. litestar_vite/templates/vue-inertia/env.d.ts.j2 +7 -0
  154. litestar_vite/templates/vue-inertia/index.html.j2 +14 -0
  155. litestar_vite/templates/vue-inertia/package.json.j2 +50 -0
  156. litestar_vite/templates/vue-inertia/resources/main.ts.j2 +18 -0
  157. litestar_vite/templates/vue-inertia/resources/pages/Home.vue.j2 +22 -0
  158. litestar_vite/templates/vue-inertia/resources/ssr.ts.j2 +21 -0
  159. litestar_vite/templates/vue-inertia/resources/style.css.j2 +21 -0
  160. litestar_vite/templates/vue-inertia/vite.config.ts.j2 +59 -0
  161. litestar_vite-0.15.0.dist-info/METADATA +230 -0
  162. litestar_vite-0.15.0.dist-info/RECORD +164 -0
  163. {litestar_vite-0.1.1.dist-info → litestar_vite-0.15.0.dist-info}/WHEEL +1 -1
  164. litestar_vite/config.py +0 -100
  165. litestar_vite/plugin.py +0 -45
  166. litestar_vite/template_engine.py +0 -103
  167. litestar_vite-0.1.1.dist-info/METADATA +0 -68
  168. litestar_vite-0.1.1.dist-info/RECORD +0 -11
  169. {litestar_vite-0.1.1.dist-info → litestar_vite-0.15.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1571 @@
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
+
13
+ from litestar_vite.inertia.types import ScrollPropsConfig
14
+
15
+ if TYPE_CHECKING:
16
+ from litestar.connection import ASGIConnection
17
+
18
+ from litestar_vite.inertia.plugin import InertiaPlugin
19
+ T = TypeVar("T")
20
+ PropKeyT = TypeVar("PropKeyT", bound=str)
21
+ StaticT = TypeVar("StaticT", bound=object)
22
+
23
+ DEFAULT_DEFERRED_GROUP = "default"
24
+
25
+
26
+ @overload
27
+ def lazy(key: str, value_or_callable: "None") -> "StaticProp[str, None]": ...
28
+
29
+
30
+ @overload
31
+ def lazy(key: str, value_or_callable: "T") -> "StaticProp[str, T]": ...
32
+
33
+
34
+ @overload
35
+ def lazy(key: str, value_or_callable: "Callable[..., None]" = ...) -> "DeferredProp[str, None]": ...
36
+
37
+
38
+ @overload
39
+ def lazy(
40
+ key: str, value_or_callable: "Callable[..., Coroutine[Any, Any, None]]" = ...
41
+ ) -> "DeferredProp[str, None]": ...
42
+
43
+
44
+ @overload
45
+ def lazy(
46
+ key: str,
47
+ value_or_callable: "Callable[..., T | Coroutine[Any, Any, T]]" = ..., # pyright: ignore[reportInvalidTypeVarUse]
48
+ ) -> "DeferredProp[str, T]": ...
49
+
50
+
51
+ def lazy(
52
+ key: str,
53
+ value_or_callable: "T | Callable[..., Coroutine[Any, Any, None]] | Callable[..., T] | Callable[..., T | Coroutine[Any, Any, T]] | None" = None,
54
+ ) -> "StaticProp[str, None] | StaticProp[str, T] | DeferredProp[str, T] | DeferredProp[str, None]":
55
+ """Create a lazy prop only included during partial reloads.
56
+
57
+ Lazy props are excluded from the initial page load and only sent when
58
+ explicitly requested via partial reload (X-Inertia-Partial-Data header).
59
+ This optimizes initial page load by deferring non-critical data.
60
+
61
+ There are two use cases for lazy():
62
+
63
+ **1. Static Value (bandwidth optimization)**:
64
+ The value is computed eagerly but only sent during partial reloads.
65
+ Use when the value is cheap to compute but you want to reduce initial payload.
66
+
67
+ >>> lazy("user_count", len(users))
68
+
69
+ **2. Callable (bandwidth + CPU optimization)**:
70
+ The callable is only invoked during partial reloads.
71
+ Use when the value is expensive to compute.
72
+
73
+ >>> lazy("permissions", lambda: Permission.all())
74
+
75
+ .. warning:: **False Lazy Pitfall**
76
+
77
+ Be careful not to accidentally call the function when passing it.
78
+
79
+ Wrong::
80
+
81
+ lazy("data", expensive_fn())
82
+
83
+ Correct::
84
+
85
+ lazy("data", expensive_fn)
86
+
87
+ This is a Python evaluation order issue, not a framework limitation.
88
+
89
+ Args:
90
+ key: The key to store the value under in the props dict.
91
+ value_or_callable: Either a static value (computed eagerly, sent lazily)
92
+ or a callable (computed and sent lazily). If None, creates a lazy
93
+ prop with None value.
94
+
95
+ Returns:
96
+ StaticProp if value_or_callable is not callable, DeferredProp otherwise.
97
+
98
+ Example::
99
+
100
+ from litestar_vite.inertia import lazy, InertiaResponse
101
+
102
+ @get("/dashboard", component="Dashboard")
103
+ async def dashboard() -> InertiaResponse:
104
+ props = {
105
+ "user": current_user,
106
+ "user_count": lazy("user_count", 42),
107
+ "permissions": lazy("permissions", lambda: Permission.all()),
108
+ "notifications": lazy("notifications", fetch_notifications),
109
+ }
110
+ return InertiaResponse(props)
111
+
112
+ See Also:
113
+ - :func:`defer`: For v2 grouped deferred props loaded after page render
114
+ - Inertia.js partial reloads: https://inertiajs.com/partial-reloads
115
+ """
116
+ if value_or_callable is None:
117
+ return StaticProp[str, None](key=key, value=None)
118
+
119
+ if not callable(value_or_callable):
120
+ return StaticProp[str, T](key=key, value=value_or_callable)
121
+
122
+ return DeferredProp[str, T](key=key, value=cast("Callable[..., T | Coroutine[Any, Any, T]]", value_or_callable))
123
+
124
+
125
+ def defer(
126
+ key: str, callback: "Callable[..., T | Coroutine[Any, Any, T]]", group: str = DEFAULT_DEFERRED_GROUP
127
+ ) -> "DeferredProp[str, T]":
128
+ """Create a deferred prop with optional grouping (v2 feature).
129
+
130
+ Deferred props are loaded lazily after the initial page render.
131
+ Props in the same group are fetched together in a single request.
132
+
133
+ Args:
134
+ key: The key to store the value under.
135
+ callback: A callable (sync or async) that returns the value.
136
+ group: The group name for batched loading. Defaults to "default".
137
+
138
+ Returns:
139
+ A DeferredProp instance.
140
+
141
+ Example::
142
+
143
+ defer("permissions", lambda: Permission.all())
144
+
145
+ defer("teams", lambda: Team.all(), group="attributes")
146
+ defer("projects", lambda: Project.all(), group="attributes")
147
+
148
+ # Chain with .once() for lazy + cached behavior
149
+ defer("stats", lambda: compute_expensive_stats()).once()
150
+ """
151
+ return DeferredProp[str, T](key=key, value=callback, group=group)
152
+
153
+
154
+ def once(key: str, value_or_callable: "T | Callable[..., T | Coroutine[Any, Any, T]]") -> "OnceProp[str, T]":
155
+ """Create a prop that resolves once and is cached client-side (v2.2.20+ feature).
156
+
157
+ Once props are included in the initial page load and resolved immediately.
158
+ After resolution, the client caches the value and won't request it again
159
+ on subsequent page visits, unless explicitly requested via partial reload.
160
+
161
+ This is useful for:
162
+ - Expensive computations that rarely change
163
+ - User preferences or settings
164
+ - Feature flags
165
+ - Static configuration
166
+
167
+ Unlike lazy props, once props ARE included in initial loads.
168
+ The "once" behavior tells the client to cache the result.
169
+
170
+ Args:
171
+ key: The key to store the value under.
172
+ value_or_callable: Either a static value or a callable that returns the value.
173
+
174
+ Returns:
175
+ An OnceProp instance.
176
+
177
+ Example::
178
+
179
+ from litestar_vite.inertia import once, InertiaResponse
180
+
181
+ @get("/dashboard", component="Dashboard")
182
+ async def dashboard() -> InertiaResponse:
183
+ return InertiaResponse({
184
+ "user": current_user,
185
+ "settings": once("settings", lambda: Settings.for_user(user_id)),
186
+ "feature_flags": once("feature_flags", get_feature_flags()),
187
+ })
188
+
189
+ See Also:
190
+ - :func:`defer`: For deferred props that support ``.once()`` chaining
191
+ - Inertia.js once props: https://inertiajs.com/partial-reloads#once
192
+ """
193
+ return OnceProp[str, T](key=key, value=value_or_callable)
194
+
195
+
196
+ def optional(key: str, callback: "Callable[..., T | Coroutine[Any, Any, T]]") -> "OptionalProp[str, T]":
197
+ """Create a prop only included when explicitly requested (v2 feature).
198
+
199
+ Optional props are NEVER included in initial page loads or standard
200
+ partial reloads. They're only sent when the client explicitly requests
201
+ them via ``only: ['prop_name']`` in a partial reload.
202
+
203
+ This is designed for use with Inertia's WhenVisible component, which
204
+ triggers a partial reload requesting specific props when an element
205
+ becomes visible in the viewport.
206
+
207
+ The callback is only evaluated when requested, providing both
208
+ bandwidth and CPU optimization.
209
+
210
+ Args:
211
+ key: The key to store the value under.
212
+ callback: A callable (sync or async) that returns the value.
213
+
214
+ Returns:
215
+ An OptionalProp instance.
216
+
217
+ Example::
218
+
219
+ from litestar_vite.inertia import optional, InertiaResponse
220
+
221
+ @get("/posts/{post_id}", component="Posts/Show")
222
+ async def show_post(post_id: int) -> InertiaResponse:
223
+ post = await Post.get(post_id)
224
+ return InertiaResponse({
225
+ "post": post,
226
+ # Only loaded when WhenVisible triggers
227
+ "comments": optional("comments", lambda: Comment.for_post(post_id)),
228
+ "related_posts": optional("related_posts", lambda: Post.related(post_id)),
229
+ })
230
+
231
+ Frontend usage with WhenVisible::
232
+
233
+ <WhenVisible data="comments" :params="{ only: ['comments'] }">
234
+ <template #fallback>
235
+ <LoadingSpinner />
236
+ </template>
237
+ <CommentList :comments="comments" />
238
+ </WhenVisible>
239
+
240
+ See Also:
241
+ - Inertia.js WhenVisible: https://inertiajs.com/load-when-visible
242
+ """
243
+ return OptionalProp[str, T](key=key, callback=callback)
244
+
245
+
246
+ def always(key: str, value: "T") -> "AlwaysProp[str, T]":
247
+ """Create a prop always included, even during partial reloads (v2 feature).
248
+
249
+ Always props bypass partial reload filtering entirely. They're included
250
+ in every response regardless of what keys the client requests.
251
+
252
+ Use for critical data that must always be present:
253
+ - Authentication state
254
+ - Permission flags
255
+ - Feature toggles
256
+ - Error states
257
+
258
+ Args:
259
+ key: The key to store the value under.
260
+ value: The value (evaluated eagerly).
261
+
262
+ Returns:
263
+ An AlwaysProp instance.
264
+
265
+ Example::
266
+
267
+ from litestar_vite.inertia import always, lazy, InertiaResponse
268
+
269
+ @get("/dashboard", component="Dashboard")
270
+ async def dashboard(request: Request) -> InertiaResponse:
271
+ return InertiaResponse({
272
+ # Always sent, even during partial reloads for other props
273
+ "auth": always("auth", {"user": request.user, "can": permissions}),
274
+ # Only sent when explicitly requested
275
+ "analytics": lazy("analytics", get_analytics),
276
+ "reports": lazy("reports", get_reports),
277
+ })
278
+
279
+ See Also:
280
+ - :func:`lazy`: For props excluded from initial load
281
+ - :func:`optional`: For props only included when explicitly requested
282
+ """
283
+ return AlwaysProp[str, T](key=key, value=value)
284
+
285
+
286
+ @dataclass
287
+ class PropFilter:
288
+ """Configuration for prop filtering during partial reloads.
289
+
290
+ Used with ``only()`` and ``except_()`` helpers to explicitly control
291
+ which props are sent during partial reload requests.
292
+
293
+ Attributes:
294
+ include: Set of prop keys to include (only send these).
295
+ exclude: Set of prop keys to exclude (send all except these).
296
+ """
297
+
298
+ include: "set[str] | None" = None
299
+ exclude: "set[str] | None" = None
300
+
301
+ def should_include(self, key: str) -> bool:
302
+ """Return True when a prop key should be included.
303
+
304
+ Returns:
305
+ True if the prop key should be included, otherwise False.
306
+ """
307
+ if self.exclude is not None:
308
+ return key not in self.exclude
309
+ if self.include is not None:
310
+ return key in self.include
311
+ return True
312
+
313
+
314
+ def only(*keys: str) -> PropFilter:
315
+ """Create a filter that only includes the specified prop keys.
316
+
317
+ Use this to explicitly limit which props are sent during partial reloads.
318
+ Only the specified props will be included in the response.
319
+
320
+ Args:
321
+ *keys: The prop keys to include.
322
+
323
+ Returns:
324
+ A PropFilter configured to include only the specified keys.
325
+
326
+ Example::
327
+
328
+ from litestar_vite.inertia import only, InertiaResponse
329
+
330
+ @get("/users", component="Users")
331
+ async def list_users(
332
+ request: InertiaRequest,
333
+ user_service: UserService,
334
+ ) -> InertiaResponse:
335
+ return InertiaResponse(
336
+ {
337
+ "users": user_service.list(),
338
+ "teams": team_service.list(),
339
+ "stats": stats_service.get(),
340
+ },
341
+ prop_filter=only("users"),
342
+ )
343
+
344
+ Note:
345
+ This is a server-side helper. The client should use Inertia's
346
+ ``router.reload({ only: ['users'] })`` for client-initiated filtering.
347
+ """
348
+ return PropFilter(include=set(keys))
349
+
350
+
351
+ def except_(*keys: str) -> PropFilter:
352
+ """Create a filter that excludes the specified prop keys.
353
+
354
+ Use this to explicitly exclude certain props during partial reloads.
355
+ All props except the specified ones will be included in the response.
356
+
357
+ Args:
358
+ *keys: The prop keys to exclude.
359
+
360
+ Returns:
361
+ A PropFilter configured to exclude the specified keys.
362
+
363
+ Example::
364
+
365
+ from litestar_vite.inertia import except_, InertiaResponse
366
+
367
+ @get("/users", component="Users")
368
+ async def list_users(
369
+ request: InertiaRequest,
370
+ user_service: UserService,
371
+ ) -> InertiaResponse:
372
+ return InertiaResponse(
373
+ {
374
+ "users": user_service.list(),
375
+ "teams": team_service.list(),
376
+ "stats": expensive_stats(),
377
+ },
378
+ prop_filter=except_("stats"),
379
+ )
380
+
381
+ Note:
382
+ The function is named ``except_`` with a trailing underscore to avoid
383
+ conflicting with Python's ``except`` keyword.
384
+ """
385
+ return PropFilter(exclude=set(keys))
386
+
387
+
388
+ class MergeProp(Generic[PropKeyT, T]):
389
+ """A wrapper for merge prop configuration (v2 feature).
390
+
391
+ Merge props allow data to be combined with existing props during
392
+ partial reloads instead of replacing them entirely.
393
+ """
394
+
395
+ def __init__(
396
+ self,
397
+ key: "PropKeyT",
398
+ value: "T",
399
+ strategy: "Literal['append', 'prepend', 'deep']" = "append",
400
+ match_on: "str | list[str] | None" = None,
401
+ ) -> None:
402
+ """Initialize a MergeProp.
403
+
404
+ Args:
405
+ key: The prop key.
406
+ value: The value to merge.
407
+ strategy: The merge strategy - 'append', 'prepend', or 'deep'.
408
+ match_on: Optional key(s) to match items on during merge.
409
+ """
410
+ self._key = key
411
+ self._value = value
412
+ self._strategy = strategy
413
+ self._match_on = [match_on] if isinstance(match_on, str) else match_on
414
+
415
+ @property
416
+ def key(self) -> "PropKeyT":
417
+ return self._key
418
+
419
+ @property
420
+ def value(self) -> "T":
421
+ return self._value
422
+
423
+ @property
424
+ def strategy(self) -> "Literal['append', 'prepend', 'deep']":
425
+ return self._strategy # pyright: ignore[reportReturnType]
426
+
427
+ @property
428
+ def match_on(self) -> "list[str] | None":
429
+ return self._match_on
430
+
431
+
432
+ def merge(
433
+ key: str,
434
+ value: "T",
435
+ strategy: "Literal['append', 'prepend', 'deep']" = "append",
436
+ match_on: "str | list[str] | None" = None,
437
+ ) -> "MergeProp[str, T]":
438
+ """Create a merge prop for combining data during partial reloads (v2 feature).
439
+
440
+ Merge props allow new data to be combined with existing props rather than
441
+ replacing them entirely. This is useful for infinite scroll, load more buttons,
442
+ and similar patterns.
443
+
444
+ Note: Prop merging only works during partial reloads. Full page visits
445
+ will always replace props entirely.
446
+
447
+ Args:
448
+ key: The prop key.
449
+ value: The value to merge.
450
+ strategy: How to merge the data:
451
+ - 'append': Add new items to the end (default)
452
+ - 'prepend': Add new items to the beginning
453
+ - 'deep': Recursively merge nested objects
454
+ match_on: Optional key(s) to match items on during merge,
455
+ useful for updating existing items instead of duplicating.
456
+
457
+ Returns:
458
+ A MergeProp instance.
459
+
460
+ Example::
461
+
462
+ merge("posts", new_posts)
463
+
464
+ merge("messages", new_messages, strategy="prepend")
465
+
466
+ merge("user_data", updates, strategy="deep")
467
+
468
+ merge("posts", updated_posts, match_on="id")
469
+ """
470
+ return MergeProp[str, T](key=key, value=value, strategy=strategy, match_on=match_on)
471
+
472
+
473
+ def scroll_props(
474
+ page_name: str = "page", current_page: int = 1, previous_page: "int | None" = None, next_page: "int | None" = None
475
+ ) -> "ScrollPropsConfig":
476
+ """Create scroll props configuration for infinite scroll (v2 feature).
477
+
478
+ Scroll props allow Inertia to manage pagination state for infinite scroll
479
+ patterns, providing next/previous page information to the client.
480
+
481
+ Args:
482
+ page_name: The query parameter name for pagination. Defaults to "page".
483
+ current_page: The current page number. Defaults to 1.
484
+ previous_page: The previous page number, or None if at first page.
485
+ next_page: The next page number, or None if at last page.
486
+
487
+ Returns:
488
+ A ScrollPropsConfig instance for use in InertiaResponse.
489
+
490
+ Example::
491
+
492
+ from litestar_vite.inertia import scroll_props, InertiaResponse
493
+
494
+ @get("/posts", component="Posts")
495
+ async def list_posts(page: int = 1) -> InertiaResponse:
496
+ posts = await Post.paginate(page=page, per_page=20)
497
+ return InertiaResponse(
498
+ {"posts": merge("posts", posts.items)},
499
+ scroll_props=scroll_props(
500
+ current_page=page,
501
+ previous_page=page - 1 if page > 1 else None,
502
+ next_page=page + 1 if posts.has_more else None,
503
+ ),
504
+ )
505
+ """
506
+ return ScrollPropsConfig(
507
+ page_name=page_name, current_page=current_page, previous_page=previous_page, next_page=next_page
508
+ )
509
+
510
+
511
+ def is_merge_prop(value: "Any") -> "TypeGuard[MergeProp[Any, Any]]":
512
+ """Check if value is a MergeProp.
513
+
514
+ Args:
515
+ value: Any value to check
516
+
517
+ Returns:
518
+ True if value is a MergeProp
519
+ """
520
+ return isinstance(value, MergeProp)
521
+
522
+
523
+ def extract_merge_props(props: "dict[str, Any]") -> "tuple[list[str], list[str], list[str], dict[str, list[str]]]":
524
+ """Extract merge props metadata for the Inertia v2 protocol.
525
+
526
+ This extracts all MergeProp instances from the props dict and categorizes them
527
+ by their merge strategy, returning the appropriate lists for the page response.
528
+
529
+ Args:
530
+ props: The props dictionary to scan.
531
+
532
+ Returns:
533
+ A tuple of (merge_props, prepend_props, deep_merge_props, match_props_on)
534
+ where each list contains the prop keys for that strategy, and match_props_on
535
+ is a dict mapping prop keys to the keys to match on.
536
+
537
+ Example::
538
+
539
+ props = {
540
+ "users": [...],
541
+ "posts": merge("posts", new_posts),
542
+ "messages": merge("messages", new_msgs, strategy="prepend"),
543
+ "data": merge("data", updates, strategy="deep"),
544
+ "items": merge("items", items, match_on="id"),
545
+ }
546
+ merge_props, prepend_props, deep_merge_props, match_props_on = extract_merge_props(props)
547
+
548
+ The returned values then contain:
549
+
550
+ - merge_props: ["posts", "items"]
551
+ - prepend_props: ["messages"]
552
+ - deep_merge_props: ["data"]
553
+ - match_props_on: {"items": ["id"]}
554
+ """
555
+ merge_list: "list[str]" = []
556
+ prepend_list: "list[str]" = []
557
+ deep_merge_list: "list[str]" = []
558
+ match_on_dict: "dict[str, list[str]]" = {}
559
+
560
+ for key, value in props.items():
561
+ if is_merge_prop(value):
562
+ match value.strategy:
563
+ case "append":
564
+ merge_list.append(key)
565
+ case "prepend":
566
+ prepend_list.append(key)
567
+ case "deep":
568
+ deep_merge_list.append(key)
569
+ case _:
570
+ pass
571
+
572
+ if value.match_on:
573
+ match_on_dict[key] = value.match_on
574
+
575
+ return merge_list, prepend_list, deep_merge_list, match_on_dict
576
+
577
+
578
+ class StaticProp(Generic[PropKeyT, StaticT]):
579
+ """A wrapper for static property evaluation."""
580
+
581
+ def __init__(self, key: "PropKeyT", value: "StaticT") -> None:
582
+ self._key = key
583
+ self._result = value
584
+
585
+ @property
586
+ def key(self) -> "PropKeyT":
587
+ return self._key
588
+
589
+ def render(self, portal: "BlockingPortal | None" = None) -> "StaticT": # pyright: ignore
590
+ return self._result
591
+
592
+
593
+ class DeferredProp(Generic[PropKeyT, T]):
594
+ """A wrapper for deferred property evaluation."""
595
+
596
+ def __init__(
597
+ self,
598
+ key: "PropKeyT",
599
+ value: "Callable[..., T | Coroutine[Any, Any, T] | None] | None" = None,
600
+ group: str = DEFAULT_DEFERRED_GROUP,
601
+ is_once: bool = False,
602
+ ) -> None:
603
+ self._key = key
604
+ self._value = value
605
+ self._group = group
606
+ self._is_once = is_once
607
+ self._evaluated = False
608
+ self._result: "T | None" = None
609
+
610
+ @property
611
+ def group(self) -> str:
612
+ """The deferred group this prop belongs to.
613
+
614
+ Returns:
615
+ The deferred group name.
616
+ """
617
+ return self._group
618
+
619
+ @property
620
+ def key(self) -> "PropKeyT":
621
+ return self._key
622
+
623
+ @property
624
+ def is_once(self) -> bool:
625
+ """Whether this prop should only be resolved once and cached client-side.
626
+
627
+ Returns:
628
+ True if this is a once prop.
629
+ """
630
+ return self._is_once
631
+
632
+ def once(self) -> "DeferredProp[PropKeyT, T]":
633
+ """Return a new DeferredProp with once behavior enabled.
634
+
635
+ Once props are cached client-side after first resolution.
636
+ They won't be re-fetched on subsequent visits unless explicitly
637
+ requested via partial reload.
638
+
639
+ Returns:
640
+ A new DeferredProp with is_once=True.
641
+
642
+ Example::
643
+
644
+ # Combine defer with once for lazy + cached behavior
645
+ defer("stats", lambda: compute_expensive_stats()).once()
646
+ """
647
+ return DeferredProp[PropKeyT, T](key=self._key, value=self._value, group=self._group, is_once=True)
648
+
649
+ @staticmethod
650
+ @contextmanager
651
+ def with_portal(portal: "BlockingPortal | None" = None) -> "Generator[BlockingPortal, None, None]":
652
+ if portal is None:
653
+ with start_blocking_portal() as p:
654
+ yield p
655
+ else:
656
+ yield portal
657
+
658
+ @staticmethod
659
+ def _is_awaitable(v: "Callable[..., T | Coroutine[Any, Any, T]]") -> "TypeGuard[Coroutine[Any, Any, T]]":
660
+ return inspect.iscoroutinefunction(v)
661
+
662
+ def render(self, portal: "BlockingPortal | None" = None) -> "T | None":
663
+ if self._evaluated:
664
+ return self._result
665
+ if self._value is None or not callable(self._value):
666
+ self._result = self._value
667
+ self._evaluated = True
668
+ return self._result
669
+ if not self._is_awaitable(cast("Callable[..., T]", self._value)):
670
+ self._result = cast("T", self._value())
671
+ self._evaluated = True
672
+ return self._result
673
+ with self.with_portal(portal) as p:
674
+ self._result = p.call(cast("Callable[..., T]", self._value))
675
+ self._evaluated = True
676
+ return self._result
677
+
678
+
679
+ class OnceProp(Generic[PropKeyT, T]):
680
+ """A wrapper for once-only property evaluation (v2.2.20+ feature).
681
+
682
+ Once props are resolved once and cached client-side. They won't be
683
+ re-fetched on subsequent page visits unless explicitly requested
684
+ via partial reload with ``only: ['key']``.
685
+
686
+ This is useful for expensive computations that rarely change
687
+ (e.g., user preferences, feature flags, static configuration).
688
+
689
+ Unlike lazy props, once props ARE included in the initial page load.
690
+ The "once" behavior tells the client to cache the value and not
691
+ request it again on future visits.
692
+ """
693
+
694
+ def __init__(self, key: "PropKeyT", value: "T | Callable[..., T | Coroutine[Any, Any, T]]") -> None:
695
+ """Initialize a OnceProp.
696
+
697
+ Args:
698
+ key: The prop key.
699
+ value: Either a static value or a callable that returns the value.
700
+ """
701
+ self._key = key
702
+ self._value = value
703
+ self._evaluated = False
704
+ self._result: "T | None" = None
705
+
706
+ @property
707
+ def key(self) -> "PropKeyT":
708
+ return self._key
709
+
710
+ @staticmethod
711
+ @contextmanager
712
+ def with_portal(portal: "BlockingPortal | None" = None) -> "Generator[BlockingPortal, None, None]":
713
+ if portal is None:
714
+ with start_blocking_portal() as p:
715
+ yield p
716
+ else:
717
+ yield portal
718
+
719
+ @staticmethod
720
+ def _is_awaitable(v: "Callable[..., T | Coroutine[Any, Any, T]]") -> "TypeGuard[Coroutine[Any, Any, T]]":
721
+ return inspect.iscoroutinefunction(v)
722
+
723
+ def render(self, portal: "BlockingPortal | None" = None) -> "T | None":
724
+ """Render the prop value, caching the result.
725
+
726
+ Args:
727
+ portal: Optional blocking portal for async callbacks.
728
+
729
+ Returns:
730
+ The rendered value.
731
+ """
732
+ if self._evaluated:
733
+ return self._result
734
+ if not callable(self._value):
735
+ self._result = self._value
736
+ self._evaluated = True
737
+ return self._result
738
+ if not self._is_awaitable(cast("Callable[..., T]", self._value)):
739
+ self._result = cast("T", self._value())
740
+ self._evaluated = True
741
+ return self._result
742
+ with self.with_portal(portal) as p:
743
+ self._result = p.call(cast("Callable[..., T]", self._value))
744
+ self._evaluated = True
745
+ return self._result
746
+
747
+
748
+ class OptionalProp(Generic[PropKeyT, T]):
749
+ """A wrapper for optional property evaluation (v2 feature).
750
+
751
+ Optional props are NEVER included in initial page loads or standard
752
+ partial reloads. They're only sent when the client explicitly requests
753
+ them via ``only: ['prop_name']``.
754
+
755
+ This is designed for use with Inertia's WhenVisible component, which
756
+ loads data only when an element becomes visible in the viewport.
757
+
758
+ The callback is only evaluated when the prop is explicitly requested,
759
+ providing both bandwidth and CPU optimization.
760
+ """
761
+
762
+ def __init__(self, key: "PropKeyT", callback: "Callable[..., T | Coroutine[Any, Any, T]]") -> None:
763
+ """Initialize an OptionalProp.
764
+
765
+ Args:
766
+ key: The prop key.
767
+ callback: A callable that returns the value when requested.
768
+ """
769
+ self._key = key
770
+ self._callback = callback
771
+ self._evaluated = False
772
+ self._result: "T | None" = None
773
+
774
+ @property
775
+ def key(self) -> "PropKeyT":
776
+ return self._key
777
+
778
+ @staticmethod
779
+ @contextmanager
780
+ def with_portal(portal: "BlockingPortal | None" = None) -> "Generator[BlockingPortal, None, None]":
781
+ if portal is None:
782
+ with start_blocking_portal() as p:
783
+ yield p
784
+ else:
785
+ yield portal
786
+
787
+ @staticmethod
788
+ def _is_awaitable(v: "Callable[..., T | Coroutine[Any, Any, T]]") -> "TypeGuard[Coroutine[Any, Any, T]]":
789
+ return inspect.iscoroutinefunction(v)
790
+
791
+ def render(self, portal: "BlockingPortal | None" = None) -> "T | None":
792
+ """Render the prop value, caching the result.
793
+
794
+ Args:
795
+ portal: Optional blocking portal for async callbacks.
796
+
797
+ Returns:
798
+ The rendered value.
799
+ """
800
+ if self._evaluated:
801
+ return self._result
802
+ if not self._is_awaitable(cast("Callable[..., T]", self._callback)):
803
+ self._result = cast("T", self._callback())
804
+ self._evaluated = True
805
+ return self._result
806
+ with self.with_portal(portal) as p:
807
+ self._result = p.call(cast("Callable[..., T]", self._callback))
808
+ self._evaluated = True
809
+ return self._result
810
+
811
+
812
+ class AlwaysProp(Generic[PropKeyT, T]):
813
+ """A wrapper for always-included property evaluation (v2 feature).
814
+
815
+ Always props are ALWAYS included in responses, even during partial
816
+ reloads. This is the opposite of lazy props - they bypass any
817
+ partial reload filtering.
818
+
819
+ Use for critical data that must always be present, such as:
820
+ - Authentication state
821
+ - Permission flags
822
+ - Feature toggles
823
+ - Error states
824
+ """
825
+
826
+ def __init__(self, key: "PropKeyT", value: "T") -> None:
827
+ """Initialize an AlwaysProp.
828
+
829
+ Args:
830
+ key: The prop key.
831
+ value: The value (always evaluated eagerly).
832
+ """
833
+ self._key = key
834
+ self._value = value
835
+
836
+ @property
837
+ def key(self) -> "PropKeyT":
838
+ return self._key
839
+
840
+ @property
841
+ def value(self) -> "T":
842
+ return self._value
843
+
844
+ def render(self, portal: "BlockingPortal | None" = None) -> "T": # pyright: ignore
845
+ """Return the prop value.
846
+
847
+ Args:
848
+ portal: Unused, included for interface consistency.
849
+
850
+ Returns:
851
+ The prop value.
852
+ """
853
+ return self._value
854
+
855
+
856
+ def is_lazy_prop(value: "Any") -> "TypeGuard[DeferredProp[Any, Any] | StaticProp[Any, Any]]":
857
+ """Check if value is a lazy property (StaticProp or DeferredProp).
858
+
859
+ Lazy props are excluded from initial page loads and only sent when
860
+ explicitly requested via partial reload.
861
+
862
+ Args:
863
+ value: Any value to check
864
+
865
+ Returns:
866
+ True if value is a lazy property (StaticProp or DeferredProp)
867
+ """
868
+ return isinstance(value, (DeferredProp, StaticProp))
869
+
870
+
871
+ def is_once_prop(value: "Any") -> "TypeGuard[OnceProp[Any, Any]]":
872
+ """Check if value is a once prop.
873
+
874
+ Once props are included in initial loads but cached client-side.
875
+
876
+ Args:
877
+ value: Any value to check
878
+
879
+ Returns:
880
+ True if value is an OnceProp
881
+ """
882
+ return isinstance(value, OnceProp)
883
+
884
+
885
+ def is_optional_prop(value: "Any") -> "TypeGuard[OptionalProp[Any, Any]]":
886
+ """Check if value is an optional prop.
887
+
888
+ Optional props are only included when explicitly requested.
889
+
890
+ Args:
891
+ value: Any value to check
892
+
893
+ Returns:
894
+ True if value is an OptionalProp
895
+ """
896
+ return isinstance(value, OptionalProp)
897
+
898
+
899
+ def is_always_prop(value: "Any") -> "TypeGuard[AlwaysProp[Any, Any]]":
900
+ """Check if value is an always prop.
901
+
902
+ Always props bypass partial reload filtering.
903
+
904
+ Args:
905
+ value: Any value to check
906
+
907
+ Returns:
908
+ True if value is an AlwaysProp
909
+ """
910
+ return isinstance(value, AlwaysProp)
911
+
912
+
913
+ def is_special_prop(value: "Any") -> bool:
914
+ """Check if value is any special prop type (lazy, once, optional, always).
915
+
916
+ Args:
917
+ value: Any value to check
918
+
919
+ Returns:
920
+ True if value is a special prop wrapper
921
+ """
922
+ return isinstance(value, (DeferredProp, StaticProp, OnceProp, OptionalProp, AlwaysProp))
923
+
924
+
925
+ def is_deferred_prop(value: "Any") -> "TypeGuard[DeferredProp[Any, Any]]":
926
+ """Check if value is specifically a DeferredProp (not StaticProp).
927
+
928
+ Args:
929
+ value: Any value to check
930
+
931
+ Returns:
932
+ True if value is a DeferredProp
933
+ """
934
+ return isinstance(value, DeferredProp)
935
+
936
+
937
+ def extract_deferred_props(props: "dict[str, Any]") -> "dict[str, list[str]]":
938
+ """Extract deferred props metadata for the Inertia v2 protocol.
939
+
940
+ This extracts all DeferredProp instances from the props dict and groups them
941
+ by their group name, returning a dict mapping group -> list of prop keys.
942
+
943
+ Note: DeferredProp instances with is_once=True are excluded from the result
944
+ because once props should not be re-fetched after initial resolution.
945
+
946
+ Args:
947
+ props: The props dictionary to scan.
948
+
949
+ Returns:
950
+ A dict mapping group names to lists of prop keys in that group.
951
+ Empty dict if no deferred props found.
952
+
953
+ Example::
954
+
955
+ props = {
956
+ "users": [...],
957
+ "teams": defer("teams", get_teams, group="attributes"),
958
+ "projects": defer("projects", get_projects, group="attributes"),
959
+ "permissions": defer("permissions", get_permissions),
960
+ }
961
+ result = extract_deferred_props(props)
962
+
963
+ The result is {"default": ["permissions"], "attributes": ["teams", "projects"]}.
964
+ """
965
+ groups: "dict[str, list[str]]" = {}
966
+
967
+ for key, value in props.items():
968
+ if is_deferred_prop(value):
969
+ # Exclude once props from deferred metadata
970
+ if value.is_once:
971
+ continue
972
+ group = value.group
973
+ if group not in groups:
974
+ groups[group] = []
975
+ groups[group].append(key)
976
+
977
+ return groups
978
+
979
+
980
+ def extract_once_props(props: "dict[str, Any]") -> "list[str]":
981
+ """Extract once props for the Inertia v2.2.20+ protocol.
982
+
983
+ Once props are cached client-side after first resolution. This function
984
+ extracts all OnceProp instances and DeferredProp instances with is_once=True.
985
+
986
+ Args:
987
+ props: The props dictionary to scan.
988
+
989
+ Returns:
990
+ A list of prop keys that should be cached client-side.
991
+ Empty list if no once props found.
992
+
993
+ Example::
994
+
995
+ props = {
996
+ "user": current_user,
997
+ "settings": once("settings", get_settings),
998
+ "stats": defer("stats", get_stats).once(),
999
+ }
1000
+ result = extract_once_props(props)
1001
+
1002
+ The result is ["settings", "stats"].
1003
+ """
1004
+ once_keys: "list[str]" = []
1005
+
1006
+ for key, value in props.items():
1007
+ if is_once_prop(value) or (is_deferred_prop(value) and value.is_once):
1008
+ once_keys.append(key)
1009
+
1010
+ return once_keys
1011
+
1012
+
1013
+ def should_render( # noqa: PLR0911
1014
+ value: "Any",
1015
+ partial_data: "set[str] | None" = None,
1016
+ partial_except: "set[str] | None" = None,
1017
+ key: "str | None" = None,
1018
+ ) -> "bool":
1019
+ """Check if value should be rendered based on partial reload filtering.
1020
+
1021
+ For v2 protocol, partial_except takes precedence over partial_data.
1022
+ When a key is provided, filtering applies to all props (not just lazy props).
1023
+
1024
+ Prop types have different behaviors:
1025
+ - **AlwaysProp**: Always included, bypasses all filtering
1026
+ - **OptionalProp**: Only included when explicitly requested via partial_data
1027
+ - **LazyProp** (StaticProp/DeferredProp): Excluded from initial load, included on partial reload
1028
+ - **OnceProp**: Included in initial load, cached client-side
1029
+ - **Regular values**: Follow standard partial reload filtering
1030
+
1031
+ Args:
1032
+ value: Any value to check
1033
+ partial_data: Optional set of keys to include (X-Inertia-Partial-Data)
1034
+ partial_except: Optional set of keys to exclude (X-Inertia-Partial-Except, v2)
1035
+ key: Optional key name for this prop (enables key-based filtering for all props)
1036
+
1037
+ Returns:
1038
+ bool: True if value should be rendered
1039
+ """
1040
+ # AlwaysProp: Always render, bypass all filtering
1041
+ if is_always_prop(value):
1042
+ return True
1043
+
1044
+ # OptionalProp: Only render when explicitly requested
1045
+ if is_optional_prop(value):
1046
+ if partial_data:
1047
+ return value.key in partial_data
1048
+ # Never included in initial loads or standard partial reloads
1049
+ return False
1050
+
1051
+ # OnceProp: Always render (client handles caching)
1052
+ if is_once_prop(value):
1053
+ # Once props are always included - the client decides whether to use cached value
1054
+ # However, respect partial_except if specified
1055
+ if partial_except:
1056
+ return value.key not in partial_except
1057
+ return True
1058
+
1059
+ # LazyProp (StaticProp/DeferredProp): Only render on partial reload
1060
+ if is_lazy_prop(value):
1061
+ if partial_except:
1062
+ return value.key not in partial_except
1063
+ if partial_data:
1064
+ return value.key in partial_data
1065
+ return False
1066
+
1067
+ # Regular values: Apply standard filtering
1068
+ if key is not None:
1069
+ if partial_except:
1070
+ return key not in partial_except
1071
+ if partial_data:
1072
+ return key in partial_data
1073
+
1074
+ return True
1075
+
1076
+
1077
+ def is_or_contains_lazy_prop(value: "Any") -> "bool":
1078
+ """Check if value is or contains a deferred property.
1079
+
1080
+ Args:
1081
+ value: Any value to check
1082
+
1083
+ Returns:
1084
+ True if value is or contains a deferred property
1085
+ """
1086
+ if is_lazy_prop(value):
1087
+ return True
1088
+ if isinstance(value, str):
1089
+ return False
1090
+ if isinstance(value, Mapping):
1091
+ return any(is_or_contains_lazy_prop(v) for v in cast("Mapping[str, Any]", value).values())
1092
+ if isinstance(value, Iterable):
1093
+ return any(is_or_contains_lazy_prop(v) for v in cast("Iterable[Any]", value))
1094
+ return False
1095
+
1096
+
1097
+ def is_or_contains_special_prop(value: "Any") -> "bool":
1098
+ """Check if value is or contains any special prop type.
1099
+
1100
+ This includes lazy, once, optional, and always props.
1101
+
1102
+ Args:
1103
+ value: Any value to check
1104
+
1105
+ Returns:
1106
+ True if value is or contains a special prop
1107
+ """
1108
+ if is_special_prop(value):
1109
+ return True
1110
+ if isinstance(value, str):
1111
+ return False
1112
+ if isinstance(value, Mapping):
1113
+ return any(is_or_contains_special_prop(v) for v in cast("Mapping[str, Any]", value).values())
1114
+ if isinstance(value, Iterable):
1115
+ return any(is_or_contains_special_prop(v) for v in cast("Iterable[Any]", value))
1116
+ return False
1117
+
1118
+
1119
+ def lazy_render( # noqa: PLR0911
1120
+ value: "T",
1121
+ partial_data: "set[str] | None" = None,
1122
+ portal: "BlockingPortal | None" = None,
1123
+ partial_except: "set[str] | None" = None,
1124
+ ) -> "T":
1125
+ """Filter deferred properties from the value based on partial data.
1126
+
1127
+ For v2 protocol, partial_except takes precedence over partial_data.
1128
+
1129
+ Args:
1130
+ value: The value to filter
1131
+ partial_data: Keys to include (X-Inertia-Partial-Data)
1132
+ portal: Optional portal to use for async rendering
1133
+ partial_except: Keys to exclude (X-Inertia-Partial-Except, v2)
1134
+
1135
+ Returns:
1136
+ The filtered value
1137
+ """
1138
+ if isinstance(value, str):
1139
+ return cast("T", value)
1140
+ if isinstance(value, Mapping):
1141
+ return cast(
1142
+ "T",
1143
+ {
1144
+ k: lazy_render(v, partial_data, portal, partial_except)
1145
+ for k, v in cast("Mapping[str, Any]", value).items()
1146
+ if should_render(v, partial_data, partial_except)
1147
+ },
1148
+ )
1149
+
1150
+ if isinstance(value, list):
1151
+ return cast(
1152
+ "T",
1153
+ [
1154
+ lazy_render(v, partial_data, portal, partial_except)
1155
+ for v in cast("Iterable[Any]", value)
1156
+ if should_render(v, partial_data, partial_except)
1157
+ ],
1158
+ )
1159
+
1160
+ if isinstance(value, tuple):
1161
+ return cast(
1162
+ "T",
1163
+ tuple(
1164
+ lazy_render(v, partial_data, portal, partial_except)
1165
+ for v in cast("Iterable[Any]", value)
1166
+ if should_render(v, partial_data, partial_except)
1167
+ ),
1168
+ )
1169
+
1170
+ # Handle special prop types that need rendering
1171
+ if is_lazy_prop(value) and should_render(value, partial_data, partial_except):
1172
+ return cast("T", value.render(portal))
1173
+
1174
+ if is_once_prop(value) and should_render(value, partial_data, partial_except):
1175
+ return cast("T", value.render(portal))
1176
+
1177
+ if is_optional_prop(value) and should_render(value, partial_data, partial_except):
1178
+ return cast("T", value.render(portal))
1179
+
1180
+ if is_always_prop(value):
1181
+ return cast("T", value.render(portal))
1182
+
1183
+ return cast("T", value)
1184
+
1185
+
1186
+ def get_shared_props(
1187
+ request: "ASGIConnection[Any, Any, Any, Any]",
1188
+ partial_data: "set[str] | None" = None,
1189
+ partial_except: "set[str] | None" = None,
1190
+ ) -> "dict[str, Any]":
1191
+ """Return shared session props for a request.
1192
+
1193
+ For v2 protocol, partial_except takes precedence over partial_data.
1194
+
1195
+ Args:
1196
+ request: The ASGI connection.
1197
+ partial_data: Optional set of keys to include (X-Inertia-Partial-Data).
1198
+ partial_except: Optional set of keys to exclude (X-Inertia-Partial-Except, v2).
1199
+
1200
+ Returns:
1201
+ The shared props. Includes a special ``_once_props`` key (list of prop keys
1202
+ that were OnceProp instances) for protocol metadata generation.
1203
+
1204
+ Note:
1205
+ Be sure to call this before `self.create_template_context` if you would like to include the `flash` message details.
1206
+ """
1207
+ props: "dict[str, Any]" = {}
1208
+ flash: "dict[str, list[str]]" = defaultdict(list)
1209
+ errors: "dict[str, Any]" = {}
1210
+ once_props_keys: "list[str]" = []
1211
+ error_bag = request.headers.get("X-Inertia-Error-Bag", None)
1212
+
1213
+ try:
1214
+ errors = request.session.pop("_errors", {})
1215
+ shared_props = cast("dict[str,Any]", request.session.pop("_shared", {}))
1216
+ inertia_plugin = cast("InertiaPlugin", request.app.plugins.get("InertiaPlugin"))
1217
+
1218
+ for key, value in shared_props.items():
1219
+ if not should_render(value, partial_data, partial_except, key=key):
1220
+ continue
1221
+ # Track once props for protocol metadata
1222
+ if is_once_prop(value) or (is_deferred_prop(value) and value.is_once):
1223
+ once_props_keys.append(key)
1224
+ # Render all special prop types
1225
+ if is_special_prop(value):
1226
+ props[key] = value.render(inertia_plugin.portal)
1227
+ else:
1228
+ props[key] = value
1229
+
1230
+ for message in cast("list[dict[str,Any]]", request.session.pop("_messages", [])):
1231
+ flash[message["category"]].append(message["message"])
1232
+
1233
+ for key, value in inertia_plugin.config.extra_static_page_props.items():
1234
+ if should_render(value, partial_data, partial_except, key=key):
1235
+ props[key] = value
1236
+
1237
+ for session_prop in inertia_plugin.config.extra_session_page_props:
1238
+ if (
1239
+ session_prop not in props
1240
+ and session_prop in request.session
1241
+ and should_render(None, partial_data, partial_except, key=session_prop)
1242
+ ):
1243
+ props[session_prop] = request.session.get(session_prop)
1244
+
1245
+ except (AttributeError, ImproperlyConfiguredException):
1246
+ msg = "Unable to generate all shared props. A valid session was not found for this request."
1247
+ request.logger.warning(msg)
1248
+
1249
+ props["flash"] = flash
1250
+ props["errors"] = {error_bag: errors} if error_bag is not None else errors
1251
+ props["csrf_token"] = value_or_default(ScopeState.from_scope(request.scope).csrf_token, "")
1252
+ # Store once props keys for later extraction (removed before serialization)
1253
+ props["_once_props"] = once_props_keys
1254
+ return props
1255
+
1256
+
1257
+ def share(connection: "ASGIConnection[Any, Any, Any, Any]", key: "str", value: "Any") -> "bool":
1258
+ """Share a value in the session.
1259
+
1260
+ Shared values are included in the props of every Inertia response for
1261
+ the current request. This is useful for data that should be available
1262
+ to all components (e.g., authenticated user, permissions, settings).
1263
+
1264
+ Args:
1265
+ connection: The ASGI connection.
1266
+ key: The key to store the value under.
1267
+ value: The value to store.
1268
+
1269
+ Returns:
1270
+ True if the value was successfully shared, False otherwise.
1271
+ """
1272
+ try:
1273
+ connection.session.setdefault("_shared", {}).update({key: value})
1274
+ except (AttributeError, ImproperlyConfiguredException):
1275
+ msg = "Unable to share value: session not accessible (user may be unauthenticated)."
1276
+ connection.logger.debug(msg)
1277
+ return False
1278
+ else:
1279
+ return True
1280
+
1281
+
1282
+ def error(connection: "ASGIConnection[Any, Any, Any, Any]", key: "str", message: "str") -> "bool":
1283
+ """Set an error message in the session.
1284
+
1285
+ Error messages are included in the ``errors`` prop of Inertia responses,
1286
+ typically used for form validation errors. The key usually corresponds
1287
+ to a form field name.
1288
+
1289
+ Args:
1290
+ connection: The ASGI connection.
1291
+ key: The key to store the error under (usually a field name).
1292
+ message: The error message.
1293
+
1294
+ Returns:
1295
+ True if the error was successfully stored, False otherwise.
1296
+ """
1297
+ try:
1298
+ connection.session.setdefault("_errors", {}).update({key: message})
1299
+ except (AttributeError, ImproperlyConfiguredException):
1300
+ msg = "Unable to set error: session not accessible (user may be unauthenticated)."
1301
+ connection.logger.debug(msg)
1302
+ return False
1303
+ else:
1304
+ return True
1305
+
1306
+
1307
+ def flash(connection: "ASGIConnection[Any, Any, Any, Any]", message: "str", category: "str" = "info") -> "bool":
1308
+ """Add a flash message to the session.
1309
+
1310
+ Flash messages are stored in the session and passed to the frontend
1311
+ via the `flash` prop in every Inertia response. They're automatically
1312
+ cleared after being displayed (pop semantics).
1313
+
1314
+ This function works without requiring Litestar's FlashPlugin or
1315
+ any Jinja2 template configuration, making it ideal for SPA-only
1316
+ Inertia applications.
1317
+
1318
+ Args:
1319
+ connection: The ASGI connection (Request or WebSocket).
1320
+ message: The message text to display.
1321
+ category: The message category (e.g., "success", "error", "warning", "info").
1322
+ Defaults to "info".
1323
+
1324
+ Returns:
1325
+ True if the flash message was successfully stored, False otherwise.
1326
+
1327
+ Example::
1328
+
1329
+ from litestar_vite.inertia import flash
1330
+
1331
+ @post("/create")
1332
+ async def create_item(request: Request) -> InertiaResponse:
1333
+ flash(request, "Item created successfully!", "success")
1334
+ return InertiaResponse(...)
1335
+ """
1336
+ try:
1337
+ messages = connection.session.setdefault("_messages", [])
1338
+ messages.append({"category": category, "message": message})
1339
+ except (AttributeError, ImproperlyConfiguredException):
1340
+ msg = "Unable to flash message: session not accessible (user may be unauthenticated)."
1341
+ connection.logger.debug(msg)
1342
+ return False
1343
+ else:
1344
+ return True
1345
+
1346
+
1347
+ def clear_history(connection: "ASGIConnection[Any, Any, Any, Any]") -> None:
1348
+ """Mark that the next response should clear client history encryption keys.
1349
+
1350
+ This function sets a session flag that will be consumed by the next
1351
+ InertiaResponse, causing it to include `clearHistory: true` in the page
1352
+ object. The Inertia client will then regenerate its encryption key,
1353
+ invalidating all previously encrypted history entries.
1354
+
1355
+ This should typically be called during logout to ensure sensitive data
1356
+ cannot be recovered from browser history after a user logs out.
1357
+
1358
+ Args:
1359
+ connection: The ASGI connection (Request).
1360
+
1361
+ Note:
1362
+ Requires session middleware to be configured.
1363
+ See: https://inertiajs.com/history-encryption
1364
+
1365
+ Example::
1366
+
1367
+ from litestar_vite.inertia import clear_history
1368
+
1369
+ @post("/logout")
1370
+ async def logout(request: Request) -> InertiaRedirect:
1371
+ request.session.clear()
1372
+ clear_history(request)
1373
+ return InertiaRedirect(request, redirect_to="/login")
1374
+ """
1375
+ try:
1376
+ connection.session["_inertia_clear_history"] = True
1377
+ except (AttributeError, ImproperlyConfiguredException):
1378
+ msg = "Unable to set clear_history flag. A valid session was not found for this request."
1379
+ connection.logger.warning(msg)
1380
+
1381
+
1382
+ def is_pagination_container(value: "Any") -> bool:
1383
+ """Check if a value is a pagination container.
1384
+
1385
+ Detects common pagination types from Litestar and Advanced Alchemy:
1386
+ - litestar.pagination.OffsetPagination (items, limit, offset, total)
1387
+ - litestar.pagination.ClassicPagination (items, page_size, current_page, total_pages)
1388
+ - advanced_alchemy.service.OffsetPagination
1389
+
1390
+ Also supports any object with an `items` attribute and pagination metadata.
1391
+
1392
+ Args:
1393
+ value: The value to check.
1394
+
1395
+ Returns:
1396
+ True if value appears to be a pagination container.
1397
+ """
1398
+ if value is None:
1399
+ return False
1400
+
1401
+ try:
1402
+ _ = value.items
1403
+ except AttributeError:
1404
+ return False
1405
+
1406
+ has_offset_style = _has_offset_pagination_attrs(value)
1407
+ has_classic_style = _has_classic_pagination_attrs(value)
1408
+
1409
+ return has_offset_style or has_classic_style
1410
+
1411
+
1412
+ def extract_pagination_scroll_props(value: "Any", page_param: str = "page") -> "tuple[Any, ScrollPropsConfig | None]":
1413
+ """Extract items and scroll props from a pagination container.
1414
+
1415
+ For OffsetPagination, calculates page numbers from limit/offset/total.
1416
+ For ClassicPagination, uses current_page/total_pages directly.
1417
+
1418
+ Args:
1419
+ value: A pagination container (OffsetPagination, ClassicPagination, etc.).
1420
+ page_param: The query parameter name for pagination (default: "page").
1421
+
1422
+ Returns:
1423
+ A tuple of (items, scroll_props) where scroll_props is None if
1424
+ value is not a pagination container.
1425
+
1426
+ Example::
1427
+
1428
+ items, scroll = extract_pagination_scroll_props(pagination)
1429
+
1430
+ For OffsetPagination with limit=10, offset=20, total=50 the resulting scroll
1431
+ props are: ScrollPropsConfig(current_page=3, previous_page=2, next_page=4).
1432
+ """
1433
+ if not is_pagination_container(value):
1434
+ return value, None
1435
+
1436
+ items = value.items
1437
+
1438
+ if meta := _extract_offset_pagination_meta(value):
1439
+ current_page, previous_page, next_page = meta
1440
+ scroll_props = ScrollPropsConfig(
1441
+ page_name=page_param, current_page=current_page, previous_page=previous_page, next_page=next_page
1442
+ )
1443
+ return items, scroll_props
1444
+
1445
+ if meta := _extract_classic_pagination_meta(value):
1446
+ current_page, previous_page, next_page = meta
1447
+ scroll_props = ScrollPropsConfig(
1448
+ page_name=page_param, current_page=current_page, previous_page=previous_page, next_page=next_page
1449
+ )
1450
+ return items, scroll_props
1451
+
1452
+ return items, None
1453
+
1454
+
1455
+ PAGINATION_ATTRS: tuple[tuple[str, str], ...] = (
1456
+ ("total", "total"),
1457
+ ("limit", "limit"),
1458
+ ("offset", "offset"),
1459
+ ("page_size", "pageSize"),
1460
+ ("current_page", "currentPage"),
1461
+ ("total_pages", "totalPages"),
1462
+ ("per_page", "perPage"),
1463
+ ("last_page", "lastPage"),
1464
+ ("has_more", "hasMore"),
1465
+ ("has_next", "hasNext"),
1466
+ ("has_previous", "hasPrevious"),
1467
+ ("next_cursor", "nextCursor"),
1468
+ ("previous_cursor", "previousCursor"),
1469
+ )
1470
+
1471
+
1472
+ def pagination_to_dict(value: "Any") -> dict[str, Any]:
1473
+ """Convert a pagination container to a dict with items and all metadata.
1474
+
1475
+ Dynamically extracts known pagination attributes from any pagination
1476
+ container class. This supports custom pagination implementations as
1477
+ long as they have an ``items`` attribute and standard pagination metadata.
1478
+
1479
+ The function checks for common pagination attributes like ``total``,
1480
+ ``limit``, ``offset`` (for offset pagination), ``page_size``, ``current_page``,
1481
+ ``total_pages`` (for classic pagination), and cursor-based attributes.
1482
+ Any found attributes are included in the result dict with camelCase keys.
1483
+
1484
+ Args:
1485
+ value: A pagination container with ``items`` and metadata attributes.
1486
+
1487
+ Returns:
1488
+ A dict with ``items`` and any found pagination metadata (camelCase keys).
1489
+
1490
+ Example::
1491
+
1492
+ from litestar.pagination import OffsetPagination
1493
+
1494
+ pagination = OffsetPagination(items=[1, 2, 3], limit=10, offset=0, total=50)
1495
+ result = pagination_to_dict(pagination)
1496
+
1497
+ The result contains {"items": [1, 2, 3], "total": 50, "limit": 10, "offset": 0}.
1498
+
1499
+ Note:
1500
+ This function is used internally by InertiaResponse to preserve
1501
+ pagination metadata when returning pagination containers from routes.
1502
+ """
1503
+ result: dict[str, Any] = {"items": value.items}
1504
+
1505
+ for attr, camel_attr in PAGINATION_ATTRS:
1506
+ try:
1507
+ attr_value = value.__getattribute__(attr)
1508
+ except AttributeError:
1509
+ continue
1510
+ result[camel_attr] = attr_value
1511
+
1512
+ return result
1513
+
1514
+
1515
+ def _has_offset_pagination_attrs(value: Any) -> bool:
1516
+ try:
1517
+ _ = value.limit
1518
+ _ = value.offset
1519
+ _ = value.total
1520
+ except AttributeError:
1521
+ return False
1522
+ return True
1523
+
1524
+
1525
+ def _has_classic_pagination_attrs(value: Any) -> bool:
1526
+ try:
1527
+ _ = value.current_page
1528
+ _ = value.total_pages
1529
+ except AttributeError:
1530
+ return False
1531
+ return True
1532
+
1533
+
1534
+ def _extract_offset_pagination_meta(value: Any) -> tuple[int, int | None, int | None] | None:
1535
+ try:
1536
+ limit = value.limit
1537
+ offset = value.offset
1538
+ total = value.total
1539
+ except AttributeError:
1540
+ return None
1541
+
1542
+ if not (isinstance(limit, int) and isinstance(offset, int) and isinstance(total, int)):
1543
+ return None
1544
+
1545
+ if limit > 0:
1546
+ current_page = (offset // limit) + 1
1547
+ total_pages = (total + limit - 1) // limit
1548
+ else:
1549
+ current_page = 1
1550
+ total_pages = 1
1551
+
1552
+ previous_page = current_page - 1 if current_page > 1 else None
1553
+ next_page = current_page + 1 if current_page < total_pages else None
1554
+
1555
+ return current_page, previous_page, next_page
1556
+
1557
+
1558
+ def _extract_classic_pagination_meta(value: Any) -> tuple[int, int | None, int | None] | None:
1559
+ try:
1560
+ current_page = value.current_page
1561
+ total_pages = value.total_pages
1562
+ except AttributeError:
1563
+ return None
1564
+
1565
+ if not (isinstance(current_page, int) and isinstance(total_pages, int)):
1566
+ return None
1567
+
1568
+ previous_page = current_page - 1 if current_page > 1 else None
1569
+ next_page = current_page + 1 if current_page < total_pages else None
1570
+
1571
+ return current_page, previous_page, next_page