Coverage for fastblocks/htmx.py: 87%
146 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-21 04:50 -0700
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-21 04:50 -0700
1"""FastBlocks Native HTMX Support.
3This module consolidates and enhances HTMX functionality for FastBlocks,
4originally based on the asgi-htmx library.
6Original asgi-htmx library:
7- Author: Marcelo Trylesinski
8- Repository: https://github.com/marcelotrylesisnki/asgi-htmx
9- License: MIT
11The FastBlocks implementation extends the original with:
12- ACB (Asynchronous Component Base) integration
13- FastBlocks-specific debugging and logging
14- Enhanced template integration
15- Response helpers optimized for FastBlocks
16"""
18import json
19import typing as t
20from urllib.parse import unquote
22from acb.debug import debug
23from starlette.responses import HTMLResponse
25if t.TYPE_CHECKING:
26 from starlette.types import Scope
27else:
28 Scope = dict
30try:
31 from starlette.requests import Request
33 _starlette_available = True
34except ImportError:
35 _starlette_available = False
36 Request = t.Any # type: ignore
38STARLETTE_AVAILABLE = _starlette_available
41class HtmxDetails:
42 def __init__(self, scope: "Scope") -> None:
43 self._scope = scope
44 debug(
45 f"HtmxDetails: Processing HTMX headers for {scope.get('path', 'unknown')}"
46 )
48 def _get_header(self, name: bytes) -> str | None:
49 value = _get_header(self._scope, name)
50 if value and debug.enabled:
51 debug(f"HtmxDetails: {name.decode()}: {value}")
52 return value
54 def __bool__(self) -> bool:
55 is_htmx = self._get_header(b"HX-Request") == "true"
56 debug(f"HtmxDetails: Is HTMX request: {is_htmx}")
57 return is_htmx
59 @property
60 def boosted(self) -> bool:
61 return self._get_header(b"HX-Boosted") == "true"
63 @property
64 def current_url(self) -> str | None:
65 return self._get_header(b"HX-Current-URL")
67 @property
68 def history_restore_request(self) -> bool:
69 return self._get_header(b"HX-History-Restore-Request") == "true"
71 @property
72 def prompt(self) -> str | None:
73 return self._get_header(b"HX-Prompt")
75 @property
76 def target(self) -> str | None:
77 return self._get_header(b"HX-Target")
79 @property
80 def trigger(self) -> str | None:
81 return self._get_header(b"HX-Trigger")
83 @property
84 def trigger_name(self) -> str | None:
85 return self._get_header(b"HX-Trigger-Name")
87 @property
88 def triggering_event(self) -> t.Any:
89 value = self._get_header(b"Triggering-Event")
90 if value is None:
91 return None
92 try:
93 event_data = json.loads(value)
94 debug(f"HtmxDetails: Parsed triggering event: {event_data}")
95 return event_data
96 except json.JSONDecodeError as e:
97 debug(f"HtmxDetails: Failed to parse triggering event JSON: {e}")
98 return None
100 def get_all_headers(self) -> dict[str, str | None]:
101 headers = {
102 "HX-Request": self._get_header(b"HX-Request"),
103 "HX-Boosted": self._get_header(b"HX-Boosted"),
104 "HX-Current-URL": self.current_url,
105 "HX-History-Restore-Request": self._get_header(
106 b"HX-History-Restore-Request"
107 ),
108 "HX-Prompt": self.prompt,
109 "HX-Target": self.target,
110 "HX-Trigger": self.trigger,
111 "HX-Trigger-Name": self.trigger_name,
112 "Triggering-Event": self._get_header(b"Triggering-Event"),
113 }
115 return {k: v for k, v in headers.items() if v is not None}
118def _get_header(scope: "Scope", key: bytes) -> str | None:
119 key = key.lower()
120 value: str | None = None
121 should_unquote = False
122 try:
123 for k, v in scope["headers"]:
124 if k.lower() == key:
125 value = v.decode("latin-1")
126 if k.lower() == b"%s-uri-autoencoded" % key and v == b"true":
127 should_unquote = True
128 except (KeyError, UnicodeDecodeError) as e:
129 debug(f"HtmxDetails: Error processing header {key}: {e}")
130 return None
131 if value is None:
132 return None
133 try:
134 return unquote(value) if should_unquote else value
135 except Exception as e:
136 debug(f"HtmxDetails: Error unquoting header value: {e}")
137 return value
140HtmxScope = dict[str, t.Any]
142if STARLETTE_AVAILABLE and Request is not t.Any:
144 class HtmxRequest(Request): # type: ignore
145 scope: HtmxScope
147 @property
148 def htmx(self) -> HtmxDetails:
149 return self.scope["htmx"]
151 def is_htmx(self) -> bool:
152 return bool(self.htmx)
154 def is_boosted(self) -> bool:
155 return self.htmx.boosted
157 def get_htmx_headers(self) -> dict[str, str | None]:
158 return self.htmx.get_all_headers()
159else:
161 class HtmxRequest: # type: ignore
162 """Placeholder HtmxRequest when Starlette is not available."""
164 def __init__(self, *args: t.Any, **kwargs: t.Any) -> None:
165 raise ImportError(
166 "Starlette is required for HtmxRequest. Install with: uv add starlette"
167 )
170class HtmxResponse(HTMLResponse):
171 def __init__(
172 self,
173 content: str = "",
174 status_code: int = 200,
175 headers: t.Mapping[str, str] | None = None,
176 media_type: str | None = None,
177 background: t.Any = None,
178 trigger: str | None = None,
179 trigger_after_settle: str | None = None,
180 trigger_after_swap: str | None = None,
181 retarget: str | None = None,
182 reselect: str | None = None,
183 reswap: str | None = None,
184 push_url: str | bool | None = None,
185 replace_url: str | bool | None = None,
186 refresh: bool = False,
187 redirect: str | None = None,
188 location: dict[str, t.Any] | str | None = None,
189 ) -> None:
190 init_headers = dict(headers or {})
192 if trigger:
193 init_headers["HX-Trigger"] = trigger
194 if trigger_after_settle:
195 init_headers["HX-Trigger-After-Settle"] = trigger_after_settle
196 if trigger_after_swap:
197 init_headers["HX-Trigger-After-Swap"] = trigger_after_swap
198 if retarget:
199 init_headers["HX-Retarget"] = retarget
200 if reselect:
201 init_headers["HX-Reselect"] = reselect
202 if reswap:
203 init_headers["HX-Reswap"] = reswap
204 if push_url is not None:
205 init_headers["HX-Push-Url"] = str(push_url).lower()
206 if replace_url is not None:
207 init_headers["HX-Replace-Url"] = str(replace_url).lower()
208 if refresh:
209 init_headers["HX-Refresh"] = "true"
210 if redirect:
211 init_headers["HX-Redirect"] = redirect
212 if location:
213 if isinstance(location, dict):
214 init_headers["HX-Location"] = json.dumps(location)
215 else:
216 init_headers["HX-Location"] = str(location)
218 super().__init__(
219 content=content,
220 status_code=status_code,
221 headers=init_headers,
222 media_type=media_type,
223 background=background,
224 )
227def htmx_trigger(
228 trigger_events: str | dict[str, t.Any],
229 content: str = "",
230 status_code: int = 200,
231 **kwargs: t.Any,
232) -> HtmxResponse:
233 if isinstance(trigger_events, dict):
234 trigger_value = json.dumps(trigger_events)
235 else:
236 trigger_value = trigger_events
238 return HtmxResponse(
239 content=content,
240 status_code=status_code,
241 trigger=trigger_value,
242 **kwargs,
243 )
246def htmx_redirect(url: str, **kwargs: t.Any) -> HtmxResponse:
247 return HtmxResponse(redirect=url, **kwargs)
250def htmx_refresh(**kwargs: t.Any) -> HtmxResponse:
251 return HtmxResponse(refresh=True, **kwargs)
254def htmx_push_url(url: str, content: str = "", **kwargs: t.Any) -> HtmxResponse:
255 return HtmxResponse(content=content, push_url=url, **kwargs)
258def htmx_retarget(target: str, content: str = "", **kwargs: t.Any) -> HtmxResponse:
259 return HtmxResponse(content=content, retarget=target, **kwargs)
262def is_htmx(scope_or_request: dict[str, t.Any] | t.Any) -> bool:
263 if hasattr(scope_or_request, "headers"):
264 headers = getattr(scope_or_request, "headers", {})
265 return headers.get("HX-Request") == "true"
266 else:
267 if isinstance(scope_or_request, dict):
268 details = HtmxDetails(scope_or_request)
269 return getattr(details, "is_htmx", False)
270 return False
273__all__ = [
274 "HtmxDetails",
275 "HtmxRequest",
276 "HtmxResponse",
277 "htmx_trigger",
278 "htmx_redirect",
279 "htmx_refresh",
280 "htmx_push_url",
281 "htmx_retarget",
282 "is_htmx",
283]