hxadmin 0.1.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 (61) hide show
  1. hxadmin/__init__.py +22 -0
  2. hxadmin/actions.py +81 -0
  3. hxadmin/admin.py +370 -0
  4. hxadmin/components.py +74 -0
  5. hxadmin/deps.py +11 -0
  6. hxadmin/export.py +176 -0
  7. hxadmin/fields.py +233 -0
  8. hxadmin/filters.py +341 -0
  9. hxadmin/forms.py +305 -0
  10. hxadmin/icons.py +59 -0
  11. hxadmin/nav.py +109 -0
  12. hxadmin/origin.py +36 -0
  13. hxadmin/pages.py +72 -0
  14. hxadmin/pk.py +102 -0
  15. hxadmin/py.typed +0 -0
  16. hxadmin/query.py +193 -0
  17. hxadmin/routes.py +655 -0
  18. hxadmin/static/hxadmin.css +2 -0
  19. hxadmin/static/vendor/air-datepicker.LICENSE +24 -0
  20. hxadmin/static/vendor/air-datepicker.css +7 -0
  21. hxadmin/static/vendor/air-datepicker.min.js +1 -0
  22. hxadmin/static/vendor/alpine-anchor.min.js +1 -0
  23. hxadmin/static/vendor/alpine-focus.min.js +18 -0
  24. hxadmin/static/vendor/alpine-plugins.LICENSE +98 -0
  25. hxadmin/static/vendor/alpine.min.js +21 -0
  26. hxadmin/static/vendor/htmx.min.js +1 -0
  27. hxadmin/static/vendor/lucide.LICENSE +43 -0
  28. hxadmin/static/vendor/lucide.json +1 -0
  29. hxadmin/templates/_macros.html +269 -0
  30. hxadmin/templates/_palette.html +32 -0
  31. hxadmin/templates/components/badge.html +27 -0
  32. hxadmin/templates/components/button.html +79 -0
  33. hxadmin/templates/components/card.html +34 -0
  34. hxadmin/templates/components/date.html +34 -0
  35. hxadmin/templates/components/dialog.html +62 -0
  36. hxadmin/templates/components/feedback.html +85 -0
  37. hxadmin/templates/components/icon.html +14 -0
  38. hxadmin/templates/components/menu.html +117 -0
  39. hxadmin/templates/components/misc.html +40 -0
  40. hxadmin/templates/dashboard.html +26 -0
  41. hxadmin/templates/detail/_panel.html +66 -0
  42. hxadmin/templates/detail/_related_plain.html +7 -0
  43. hxadmin/templates/detail.html +9 -0
  44. hxadmin/templates/error.html +17 -0
  45. hxadmin/templates/form/_field.html +65 -0
  46. hxadmin/templates/form/_fields.html +22 -0
  47. hxadmin/templates/form/_options.html +10 -0
  48. hxadmin/templates/form.html +15 -0
  49. hxadmin/templates/layout.html +527 -0
  50. hxadmin/templates/list/_pagination.html +25 -0
  51. hxadmin/templates/list/_table.html +110 -0
  52. hxadmin/templates/list/_toolbar.html +19 -0
  53. hxadmin/templates/list.html +14 -0
  54. hxadmin/templates/page.html +7 -0
  55. hxadmin/toasts.py +54 -0
  56. hxadmin/users.py +24 -0
  57. hxadmin/views.py +242 -0
  58. hxadmin-0.1.0.dist-info/METADATA +91 -0
  59. hxadmin-0.1.0.dist-info/RECORD +61 -0
  60. hxadmin-0.1.0.dist-info/WHEEL +4 -0
  61. hxadmin-0.1.0.dist-info/licenses/LICENSE +21 -0
hxadmin/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ from importlib.metadata import version
2
+
3
+ from hxadmin.actions import ActionResult, action
4
+ from hxadmin.admin import HxAdmin
5
+ from hxadmin.fields import Field, RelationField
6
+ from hxadmin.forms import FormError
7
+ from hxadmin.pages import Page
8
+ from hxadmin.views import BadgeTone, ModelView
9
+
10
+ __version__ = version("hxadmin")
11
+ __all__ = [
12
+ "ActionResult",
13
+ "BadgeTone",
14
+ "Field",
15
+ "FormError",
16
+ "HxAdmin",
17
+ "ModelView",
18
+ "Page",
19
+ "RelationField",
20
+ "__version__",
21
+ "action",
22
+ ]
hxadmin/actions.py ADDED
@@ -0,0 +1,81 @@
1
+ import inspect
2
+ import re
3
+ from collections.abc import Awaitable, Callable
4
+ from dataclasses import dataclass
5
+ from typing import Any, Literal, Self
6
+
7
+ from starlette.responses import Response
8
+
9
+ from hxadmin.fields import label_for
10
+ from hxadmin.toasts import Toast, ToastLevel
11
+
12
+ type ActionMethod = Literal["GET", "POST"]
13
+ type ActionHandler = Callable[..., Awaitable[Any]]
14
+
15
+ ACTION_ATTR = "__hxadmin_action__"
16
+ _NAME = re.compile(r"[A-Za-z0-9_-]+")
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class Action:
21
+ """Metadata declared by one `@action` method."""
22
+
23
+ name: str
24
+ label: str
25
+ bulk: bool
26
+ confirm: str | None
27
+ method: ActionMethod
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class ActionResult:
32
+ """Outcome of an action: a toast over the re-rendered view, a redirect, or a raw response."""
33
+
34
+ toast: Toast | None = None
35
+ url: str | None = None
36
+ raw: Response | None = None
37
+
38
+ @classmethod
39
+ def message(cls, text: str, level: ToastLevel = "success") -> Self:
40
+ return cls(toast=Toast(text, level))
41
+
42
+ @classmethod
43
+ def redirect(cls, url: str) -> Self:
44
+ return cls(url=url)
45
+
46
+ @classmethod
47
+ def response(cls, response: Response) -> Self:
48
+ return cls(raw=response)
49
+
50
+
51
+ def _require_async(name: str, func: object) -> None:
52
+ if not inspect.iscoroutinefunction(func):
53
+ raise TypeError(f"Action {name!r} must be an async function")
54
+
55
+
56
+ def action[F: ActionHandler](
57
+ name: str,
58
+ *,
59
+ label: str | None = None,
60
+ bulk: bool = False,
61
+ confirm: str | None = None,
62
+ method: ActionMethod = "POST",
63
+ ) -> Callable[[F], F]:
64
+ """Declare a `ModelView` coroutine method as a row action, or a bulk action with `bulk=True`.
65
+
66
+ Row handlers receive `(request, session, obj)`, bulk handlers `(request, session, objs)`.
67
+ POST actions run over htmx; `method="GET"` actions are plain links / form submits so a
68
+ returned download reaches the browser.
69
+ """
70
+ if not _NAME.fullmatch(name):
71
+ raise ValueError(f"Invalid action name {name!r}: use letters, digits, '_' and '-'")
72
+ if method not in ("GET", "POST"):
73
+ raise ValueError(f"Action {name!r}: method must be 'GET' or 'POST', not {method!r}")
74
+ spec = Action(name, label or label_for(name.replace("-", "_")), bulk, confirm, method)
75
+
76
+ def decorate(func: F) -> F:
77
+ _require_async(name, func)
78
+ setattr(func, ACTION_ATTR, spec)
79
+ return func
80
+
81
+ return decorate
hxadmin/admin.py ADDED
@@ -0,0 +1,370 @@
1
+ import inspect
2
+ import json
3
+ import logging
4
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
5
+ from pathlib import Path
6
+ from typing import Any, cast
7
+
8
+ from fastapi import Depends, FastAPI
9
+ from fastapi.staticfiles import StaticFiles
10
+ from jinja2 import (
11
+ BaseLoader,
12
+ ChoiceLoader,
13
+ Environment,
14
+ FileSystemLoader,
15
+ PackageLoader,
16
+ select_autoescape,
17
+ )
18
+ from sqlalchemy import Select, select
19
+ from sqlalchemy.ext.asyncio import AsyncSession
20
+ from starlette.exceptions import HTTPException as StarletteHTTPException
21
+ from starlette.requests import Request
22
+ from starlette.responses import HTMLResponse, RedirectResponse, Response
23
+ from starlette.routing import BaseRoute
24
+
25
+ from hxadmin.components import date_format, html_attrs, pick
26
+ from hxadmin.deps import AuthDependency, SessionDependency
27
+ from hxadmin.fields import default_widget, field_widget, field_width
28
+ from hxadmin.icons import check_icon, icon
29
+ from hxadmin.nav import build_nav
30
+ from hxadmin.origin import is_cross_origin, normalize_origin
31
+ from hxadmin.pages import AdminPage, PageEndpoint, PageHandler
32
+ from hxadmin.toasts import FLASH_COOKIE, Toast, encode_flash, hx_trigger, read_flash
33
+ from hxadmin.users import user_initials, user_label
34
+ from hxadmin.views import ModelView
35
+
36
+ logger = logging.getLogger("hxadmin")
37
+
38
+
39
+ def json_pretty(value: Any) -> str:
40
+ return json.dumps(value, indent=2, ensure_ascii=False)
41
+
42
+
43
+ def _first_segment(path: str) -> str:
44
+ return path.strip("/").split("/", 1)[0]
45
+
46
+
47
+ def _require_async_page(path: str, handler: object) -> None:
48
+ if not inspect.iscoroutinefunction(handler):
49
+ raise TypeError(f"Page handler for {path!r} must be an async function")
50
+
51
+
52
+ _PAGE_METHODS = ("GET", "HEAD", "POST", "PUT", "PATCH", "DELETE")
53
+
54
+
55
+ def _method_not_allowed(allowed: Sequence[str]) -> Callable[[], Awaitable[Response]]:
56
+ """Endpoint answering 405 for a page path, so `/{identity}/{pk}` cannot claim it as a 404."""
57
+ allow = ", ".join(m.upper() for m in allowed)
58
+
59
+ async def not_allowed() -> Response:
60
+ return Response(status_code=405, headers={"Allow": allow})
61
+
62
+ return not_allowed
63
+
64
+
65
+ class HxAdmin:
66
+ current_user: Callable[..., Awaitable[Any]]
67
+ current_session: Callable[..., Awaitable[AsyncSession]]
68
+ _page_slot: int
69
+
70
+ def __init__(
71
+ self,
72
+ app: FastAPI,
73
+ *,
74
+ session: SessionDependency,
75
+ auth: AuthDependency,
76
+ title: str = "HxAdmin",
77
+ prefix: str = "/admin",
78
+ login_url: str | None = None,
79
+ logout_url: str | None = None,
80
+ logo_url: str | None = None,
81
+ templates_dir: str | Path | None = None,
82
+ trusted_origins: Sequence[str] = (),
83
+ ) -> None:
84
+ self.app = app
85
+ self.title = title
86
+ self.prefix = prefix.rstrip("/")
87
+ self.login_url = login_url
88
+ self.logout_url = logout_url
89
+ self.logo_url = logo_url
90
+ self.views: dict[str, ModelView[Any]] = {}
91
+ self.pages: list[AdminPage] = []
92
+ self._page_methods: dict[str, set[str]] = {}
93
+ self._method_fallbacks: dict[str, BaseRoute] = {}
94
+ self._session = session
95
+ self._auth = auth
96
+ self.trusted_origins = frozenset(normalize_origin(origin) for origin in trusted_origins)
97
+ self.templates = self._make_environment(templates_dir)
98
+ self.subapp = FastAPI(openapi_url=None, docs_url=None, redoc_url=None)
99
+ self.subapp.mount(
100
+ "/static",
101
+ StaticFiles(directory=str(Path(__file__).parent / "static")),
102
+ name="static",
103
+ )
104
+ self._build_dependencies()
105
+ self._install_routes()
106
+ self.subapp.add_exception_handler(StarletteHTTPException, self._handle_http_exception)
107
+ self.subapp.add_exception_handler(Exception, self._handle_server_error)
108
+ app.mount(self.prefix, self.subapp, name="hxadmin")
109
+
110
+ def _make_environment(self, templates_dir: str | Path | None) -> Environment:
111
+ loaders: list[BaseLoader] = [PackageLoader("hxadmin", "templates")]
112
+ if templates_dir is not None:
113
+ loaders.insert(0, FileSystemLoader(str(templates_dir)))
114
+ env = Environment(loader=ChoiceLoader(loaders), autoescape=select_autoescape(["html"]))
115
+ env.filters["json_pretty"] = json_pretty
116
+ env_globals = cast(dict[str, Any], env.globals)
117
+ env_globals["default_widget"] = default_widget
118
+ env_globals["field_widget"] = field_widget
119
+ env_globals["field_width"] = field_width
120
+ env_globals["lucide_icon"] = icon
121
+ env_globals["hx_attrs"] = html_attrs
122
+ env_globals["hx_pick"] = pick
123
+ env_globals["hx_date_format"] = date_format
124
+ env_globals["hx_user_label"] = user_label
125
+ env_globals["hx_user_initials"] = user_initials
126
+ return env
127
+
128
+ def _build_dependencies(self) -> None:
129
+ auth = self._auth
130
+ session = self._session
131
+ trusted = self.trusted_origins
132
+
133
+ async def current_user(request: Request, user: Any = Depends(auth)) -> Any:
134
+ request.state.hxadmin_user = user
135
+ if is_cross_origin(request, trusted):
136
+ logger.warning(
137
+ "Blocked cross-origin %s %s (Origin: %s, Sec-Fetch-Site: %s)",
138
+ request.method,
139
+ request.url.path,
140
+ request.headers.get("origin"),
141
+ request.headers.get("sec-fetch-site"),
142
+ )
143
+ raise StarletteHTTPException(403, "Cross-origin request blocked.")
144
+ return user
145
+
146
+ async def current_session(
147
+ session_: AsyncSession = Depends(session),
148
+ ) -> AsyncSession:
149
+ return session_
150
+
151
+ self.current_user = current_user
152
+ self.current_session = current_session
153
+
154
+ def _install_routes(self) -> None:
155
+ from hxadmin.routes import build_router
156
+
157
+ self._page_slot = len(self.subapp.router.routes)
158
+ self.subapp.include_router(build_router(self))
159
+
160
+ async def _handle_http_exception(self, request: Request, exc: Exception) -> Response:
161
+ if not isinstance(exc, StarletteHTTPException):
162
+ raise exc
163
+ htmx = request.headers.get("HX-Request") == "true"
164
+ authenticated = hasattr(request.state, "hxadmin_user")
165
+ if exc.status_code in (401, 403) and self.login_url is not None and not authenticated:
166
+ if htmx:
167
+ return Response(status_code=200, headers={"HX-Redirect": self.login_url})
168
+ return RedirectResponse(self.login_url, status_code=303)
169
+ if htmx:
170
+ toast = Toast(str(exc.detail), "error")
171
+ response: Response = Response(
172
+ status_code=exc.status_code,
173
+ headers={"HX-Trigger": hx_trigger(toast), "HX-Reswap": "none"},
174
+ )
175
+ else:
176
+ response = self.render(
177
+ request,
178
+ "error.html",
179
+ {"status_code": exc.status_code, "detail": exc.detail},
180
+ status_code=exc.status_code,
181
+ )
182
+ response.headers.update(exc.headers or {})
183
+ return response
184
+
185
+ async def _handle_server_error(self, request: Request, exc: Exception) -> Response:
186
+ """An error toast for htmx, else the error page; Starlette still re-raises `exc`."""
187
+ detail = "Something went wrong."
188
+ if request.headers.get("HX-Request") == "true":
189
+ return Response(
190
+ status_code=500,
191
+ headers={"HX-Trigger": hx_trigger(Toast(detail, "error")), "HX-Reswap": "none"},
192
+ )
193
+ return self.render(
194
+ request, "error.html", {"status_code": 500, "detail": detail}, status_code=500
195
+ )
196
+
197
+ def register[V: ModelView[Any]](self, view_cls: type[V]) -> type[V]:
198
+ view = view_cls()
199
+ if view.identity in self.views:
200
+ raise ValueError(f"A view with identity {view.identity!r} is already registered")
201
+ if any(_first_segment(page.path) == view.identity for page in self.pages):
202
+ raise ValueError(f"A page path already uses {view.identity!r}")
203
+ check_icon(view.icon)
204
+ self.views[view.identity] = view
205
+ return view_cls
206
+
207
+ def page[F: PageHandler](
208
+ self,
209
+ path: str,
210
+ *,
211
+ title: str,
212
+ category: str | None = None,
213
+ icon: str | None = None,
214
+ methods: Sequence[str] = ("GET",),
215
+ name: str | None = None,
216
+ ) -> Callable[[F], F]:
217
+ """Register a custom page under the admin prefix, guarded by `auth`, in the sidebar."""
218
+ if "{" in path:
219
+ raise ValueError(f"Sidebar page {path!r} cannot take path parameters; use admin.route")
220
+ check_icon(icon)
221
+ return self._add_page(AdminPage(path, title, category, icon, in_nav=True), methods, name)
222
+
223
+ def route[F: PageHandler](
224
+ self,
225
+ path: str,
226
+ *,
227
+ title: str = "",
228
+ methods: Sequence[str] = ("GET",),
229
+ name: str | None = None,
230
+ ) -> Callable[[F], F]:
231
+ """Register a route under the admin prefix, guarded by `auth`, not in the sidebar."""
232
+ return self._add_page(AdminPage(path, title), methods, name)
233
+
234
+ def _add_page[F: PageHandler](
235
+ self, page: AdminPage, methods: Sequence[str], name: str | None
236
+ ) -> Callable[[F], F]:
237
+ self._check_page_path(page.path)
238
+ wanted = {m.upper() for m in methods}
239
+ if "GET" in wanted:
240
+ wanted.add("HEAD")
241
+ clash = wanted & self._page_methods.get(page.path, set())
242
+ if clash:
243
+ raise ValueError(
244
+ f"Page path {page.path!r} is already registered for {', '.join(sorted(clash))}"
245
+ )
246
+ if page.in_nav and any(p.in_nav and p.path == page.path for p in self.pages):
247
+ raise ValueError(f"Page path {page.path!r} is already in the sidebar")
248
+
249
+ def decorate(handler: F) -> F:
250
+ _require_async_page(page.path, handler)
251
+ routes = self.subapp.router.routes
252
+ start = len(routes)
253
+ self.subapp.add_api_route(
254
+ page.path,
255
+ PageEndpoint(self, page, handler),
256
+ methods=list(methods),
257
+ name=name or getattr(handler, "__name__", page.path),
258
+ dependencies=[Depends(self.current_user)],
259
+ response_model=None,
260
+ include_in_schema=False,
261
+ )
262
+ added = routes[start:]
263
+ del routes[start:]
264
+ routes[self._page_slot : self._page_slot] = added
265
+ self._page_slot += len(added)
266
+ allowed = self._page_methods.get(page.path, set()) | wanted
267
+ self._page_methods[page.path] = allowed
268
+ self._set_method_fallback(page.path, allowed)
269
+ self.pages.append(page)
270
+ return handler
271
+
272
+ return decorate
273
+
274
+ def _set_method_fallback(self, path: str, allowed: set[str]) -> None:
275
+ """One 405 route per page path, kept after every page handler and before model routes."""
276
+ routes = self.subapp.router.routes
277
+ old = self._method_fallbacks.pop(path, None)
278
+ if old is not None:
279
+ routes.remove(old)
280
+ other = [m for m in _PAGE_METHODS if m not in allowed]
281
+ if not other:
282
+ return
283
+ self.subapp.add_api_route(
284
+ path,
285
+ _method_not_allowed(sorted(allowed)),
286
+ methods=other,
287
+ dependencies=[Depends(self.current_user)],
288
+ include_in_schema=False,
289
+ )
290
+ route = routes.pop()
291
+ routes.insert(self._page_slot, route)
292
+ self._method_fallbacks[path] = route
293
+
294
+ def _check_page_path(self, path: str) -> None:
295
+ first = _first_segment(path)
296
+ if not path.startswith("/") or not first or first.startswith("{"):
297
+ raise ValueError(f"Page path must start with a fixed segment like '/reports': {path!r}")
298
+ if first == "static" or first in self.views:
299
+ raise ValueError(f"Page path {path!r} collides with the admin's {first!r} routes")
300
+
301
+ def view_for(self, model: type[Any]) -> ModelView[Any] | None:
302
+ return next((v for v in self.views.values() if v.model is model), None)
303
+
304
+ def can_reach(self, request: Request, model: type[Any]) -> bool:
305
+ """Whether rows of `model` may be offered: it has no view, or an accessible one."""
306
+ target = self.view_for(model)
307
+ return target is None or target.is_accessible(request)
308
+
309
+ def relation_scope(self, request: Request, model: type[Any]) -> Select[Any] | None:
310
+ """Rows of `model` a relation may offer or accept, or None if its view is locked."""
311
+ target = self.view_for(model)
312
+ if target is None:
313
+ return select(model)
314
+ if not target.is_accessible(request):
315
+ return None
316
+ return target.get_query(request)
317
+
318
+ def display(self, obj: Any) -> str:
319
+ view = self.view_for(type(obj))
320
+ return view.display(obj) if view is not None else str(obj)
321
+
322
+ def url(self, request: Request, path: str = "/") -> str:
323
+ return f"{request.scope.get('root_path', '')}{path}"
324
+
325
+ def redirect(self, request: Request, url: str, *, toast: Toast | None = None) -> Response:
326
+ """Redirect after a successful write: HX-Redirect on 200 for htmx, 303 otherwise.
327
+
328
+ A `toast` rides along in a short-lived cookie and is shown on the next full page.
329
+ """
330
+ if request.headers.get("HX-Request") == "true":
331
+ response: Response = Response(status_code=200, headers={"HX-Redirect": url})
332
+ else:
333
+ response = RedirectResponse(url, status_code=303)
334
+ if toast is not None:
335
+ response.set_cookie(
336
+ FLASH_COOKIE,
337
+ encode_flash(toast),
338
+ max_age=60,
339
+ path=self.url(request, "/"),
340
+ httponly=True,
341
+ samesite="lax",
342
+ )
343
+ return response
344
+
345
+ def render(
346
+ self,
347
+ request: Request,
348
+ template: str,
349
+ context: Mapping[str, Any] | None = None,
350
+ *,
351
+ status_code: int = 200,
352
+ ) -> HTMLResponse:
353
+ consume = request.headers.get("HX-Request") != "true" and FLASH_COOKIE in request.cookies
354
+ flash = read_flash(request) if consume else None
355
+ full_context: dict[str, Any] = {
356
+ "admin": self,
357
+ "request": request,
358
+ "user": getattr(request.state, "hxadmin_user", None),
359
+ "nav": build_nav(self, request),
360
+ "toasts": [flash.as_dict()] if flash is not None else [],
361
+ }
362
+ if context:
363
+ full_context.update(context)
364
+ html = self.templates.get_template(template).render(full_context)
365
+ response = HTMLResponse(html, status_code=status_code)
366
+ if consume:
367
+ response.delete_cookie(
368
+ FLASH_COOKIE, path=self.url(request, "/"), httponly=True, samesite="lax"
369
+ )
370
+ return response
hxadmin/components.py ADDED
@@ -0,0 +1,74 @@
1
+ """Helpers the component macros in `templates/components/` call as Jinja globals."""
2
+
3
+ import re
4
+ from collections.abc import Mapping
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from jinja2 import Undefined
9
+ from markupsafe import escape
10
+
11
+ _NAME = re.compile(r"[@:A-Za-z_][\w.:@-]*")
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class Attributes:
16
+ """Extra HTML attributes that render themselves (Jinja treats them as safe markup).
17
+
18
+ Each pair is a name and an escaped value, or None for a bare boolean attribute.
19
+ """
20
+
21
+ pairs: tuple[tuple[str, str | None], ...]
22
+
23
+ def __html__(self) -> str:
24
+ return "".join(f" {k}" if v is None else f' {k}="{v}"' for k, v in self.pairs)
25
+
26
+ def __str__(self) -> str:
27
+ return self.__html__()
28
+
29
+
30
+ def html_attrs(
31
+ attrs: Mapping[str, Any] | None = None, kwargs: Mapping[str, Any] | None = None
32
+ ) -> Attributes:
33
+ """Render `attrs` as-is and `kwargs` with `_` turned into `-`, later keys winning.
34
+
35
+ None, False and undefined values are dropped, True renders a bare attribute and anything
36
+ else is escaped. Names must look like HTML, Alpine or htmx attribute names, and `class`
37
+ is rejected because every component takes it as its own argument.
38
+ """
39
+ merged: dict[str, Any] = {}
40
+ for key, value in (attrs or {}).items():
41
+ merged[key] = value
42
+ for key, value in (kwargs or {}).items():
43
+ merged[key.replace("_", "-")] = value
44
+ pairs: list[tuple[str, str | None]] = []
45
+ for key, value in merged.items():
46
+ if not _NAME.fullmatch(key):
47
+ raise ValueError(f"Invalid attribute name {key!r}")
48
+ if key == "class":
49
+ raise ValueError("Pass class as the component's class argument, not as an attribute")
50
+ if value is None or value is False or isinstance(value, Undefined):
51
+ continue
52
+ pairs.append((key, None if value is True else str(escape(value))))
53
+ return Attributes(tuple(pairs))
54
+
55
+
56
+ def pick(options: Mapping[str, str], key: str, what: str) -> str:
57
+ """`options[key]`, or a `ValueError` naming `what` and the allowed keys."""
58
+ try:
59
+ return options[key]
60
+ except KeyError:
61
+ allowed = ", ".join(options)
62
+ raise ValueError(f"Unknown {what} {key!r}: use one of {allowed}") from None
63
+
64
+
65
+ DATE_FORMATS: Mapping[str, str] = {
66
+ "date": "MM/dd/yyyy",
67
+ "datetime": "MM/dd/yyyy HH:mm",
68
+ "time": "HH:mm",
69
+ }
70
+
71
+
72
+ def date_format(kind: str) -> str:
73
+ """Air Datepicker format for a date input of `kind`; lower-cased, its placeholder."""
74
+ return pick(DATE_FORMATS, kind, "date kind")
hxadmin/deps.py ADDED
@@ -0,0 +1,11 @@
1
+ from collections.abc import AsyncIterator, Awaitable, Callable
2
+ from typing import Any
3
+
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+
6
+ type SessionDependency = (
7
+ Callable[..., AsyncSession]
8
+ | Callable[..., AsyncIterator[AsyncSession]]
9
+ | Callable[..., Awaitable[AsyncSession]]
10
+ )
11
+ type AuthDependency = Callable[..., Any]