Coverage for fastblocks/htmx.py: 84%
173 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 00:47 -0700
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 00:47 -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- Event-driven HTMX updates via ACB Events system
17"""
19import asyncio
20import json
21import typing as t
22from contextlib import suppress
23from typing import Any
24from urllib.parse import unquote
26from acb.debug import debug
27from starlette.responses import HTMLResponse
29if t.TYPE_CHECKING:
30 from starlette.types import Scope
31else:
32 Scope = dict
34try:
35 from starlette.requests import Request
37 _starlette_available = True
38except ImportError:
39 _starlette_available = False
40 Request = t.Any # type: ignore
42STARLETTE_AVAILABLE = _starlette_available
45class HtmxDetails:
46 def __init__(self, scope: "Scope") -> None:
47 self._scope = scope
48 debug(
49 f"HtmxDetails: Processing HTMX headers for {scope.get('path', 'unknown')}"
50 )
52 def _get_header(self, name: bytes) -> str | None:
53 value = _get_header(self._scope, name)
54 if value and debug.enabled:
55 debug(f"HtmxDetails: {name.decode()}: {value}")
56 return value
58 def __bool__(self) -> bool:
59 is_htmx = self._get_header(b"HX-Request") == "true"
60 debug(f"HtmxDetails: Is HTMX request: {is_htmx}")
61 return is_htmx
63 @property
64 def boosted(self) -> bool:
65 return self._get_header(b"HX-Boosted") == "true"
67 @property
68 def current_url(self) -> str | None:
69 return self._get_header(b"HX-Current-URL")
71 @property
72 def history_restore_request(self) -> bool:
73 return self._get_header(b"HX-History-Restore-Request") == "true"
75 @property
76 def prompt(self) -> str | None:
77 return self._get_header(b"HX-Prompt")
79 @property
80 def target(self) -> str | None:
81 return self._get_header(b"HX-Target")
83 @property
84 def trigger(self) -> str | None:
85 return self._get_header(b"HX-Trigger")
87 @property
88 def trigger_name(self) -> str | None:
89 return self._get_header(b"HX-Trigger-Name")
91 @property
92 def triggering_event(self) -> t.Any:
93 value = self._get_header(b"Triggering-Event")
94 if value is None:
95 return None
96 try:
97 event_data = json.loads(value)
98 debug(f"HtmxDetails: Parsed triggering event: {event_data}")
99 return event_data
100 except json.JSONDecodeError as e:
101 debug(f"HtmxDetails: Failed to parse triggering event JSON: {e}")
102 return None
104 def get_all_headers(self) -> dict[str, str | None]:
105 headers = {
106 "HX-Request": self._get_header(b"HX-Request"),
107 "HX-Boosted": self._get_header(b"HX-Boosted"),
108 "HX-Current-URL": self.current_url,
109 "HX-History-Restore-Request": self._get_header(
110 b"HX-History-Restore-Request"
111 ),
112 "HX-Prompt": self.prompt,
113 "HX-Target": self.target,
114 "HX-Trigger": self.trigger,
115 "HX-Trigger-Name": self.trigger_name,
116 "Triggering-Event": self._get_header(b"Triggering-Event"),
117 }
119 return {k: v for k, v in headers.items() if v is not None}
122def _get_header(scope: "Scope", key: bytes) -> str | None:
123 key_lower = key.lower()
124 value: str | None = None
125 should_unquote = False
127 # Extract header value and autoencoding flag
128 try:
129 for k, v in scope["headers"]:
130 if k.lower() == key_lower:
131 value = v.decode("latin-1")
132 if k.lower() == b"%s-uri-autoencoded" % key_lower and v == b"true":
133 should_unquote = True
134 except (KeyError, UnicodeDecodeError) as e:
135 debug(f"HtmxDetails: Error processing header {key}: {e}")
136 return None
138 # Return None if no value found
139 if value is None:
140 return None
142 # Handle URI autoencoding if needed
143 try:
144 return unquote(value) if should_unquote else value
145 except Exception as e:
146 debug(f"HtmxDetails: Error unquoting header value: {e}")
147 return value
150HtmxScope = dict[str, t.Any]
152if STARLETTE_AVAILABLE and Request is not t.Any:
154 class HtmxRequest(Request): # type: ignore
155 scope: HtmxScope
157 @property
158 def htmx(self) -> HtmxDetails:
159 return t.cast(HtmxDetails, self.scope["htmx"])
161 def is_htmx(self) -> bool:
162 return bool(self.htmx)
164 def is_boosted(self) -> bool:
165 return self.htmx.boosted
167 def get_htmx_headers(self) -> dict[str, str | None]:
168 return self.htmx.get_all_headers()
169else:
171 class HtmxRequest: # type: ignore
172 """Placeholder HtmxRequest when Starlette is not available."""
174 def __init__(self, *args: t.Any, **kwargs: t.Any) -> None:
175 raise ImportError(
176 "Starlette is required for HtmxRequest. Install with: uv add starlette"
177 )
180class HtmxResponse(HTMLResponse):
181 def __init__(
182 self,
183 content: str = "",
184 status_code: int = 200,
185 headers: t.Mapping[str, str] | None = None,
186 media_type: str | None = None,
187 background: t.Any = None,
188 trigger: str | None = None,
189 trigger_after_settle: str | None = None,
190 trigger_after_swap: str | None = None,
191 retarget: str | None = None,
192 reselect: str | None = None,
193 reswap: str | None = None,
194 push_url: str | bool | None = None,
195 replace_url: str | bool | None = None,
196 refresh: bool = False,
197 redirect: str | None = None,
198 location: dict[str, t.Any] | str | None = None,
199 ) -> None:
200 init_headers = dict(headers or {})
202 # Set HTMX-specific headers
203 self._set_htmx_headers(
204 init_headers,
205 trigger=trigger,
206 trigger_after_settle=trigger_after_settle,
207 trigger_after_swap=trigger_after_swap,
208 retarget=retarget,
209 reselect=reselect,
210 reswap=reswap,
211 push_url=push_url,
212 replace_url=replace_url,
213 refresh=refresh,
214 redirect=redirect,
215 location=location,
216 )
218 super().__init__(
219 content=content,
220 status_code=status_code,
221 headers=init_headers,
222 media_type=media_type,
223 background=background,
224 )
226 def _set_htmx_headers(
227 self,
228 headers: dict[str, str],
229 *,
230 trigger: str | None = None,
231 trigger_after_settle: str | None = None,
232 trigger_after_swap: str | None = None,
233 retarget: str | None = None,
234 reselect: str | None = None,
235 reswap: str | None = None,
236 push_url: str | bool | None = None,
237 replace_url: str | bool | None = None,
238 refresh: bool = False,
239 redirect: str | None = None,
240 location: dict[str, t.Any] | str | None = None,
241 ) -> None:
242 """Set HTMX-specific headers in the response."""
243 if trigger:
244 headers["HX-Trigger"] = trigger
245 if trigger_after_settle:
246 headers["HX-Trigger-After-Settle"] = trigger_after_settle
247 if trigger_after_swap:
248 headers["HX-Trigger-After-Swap"] = trigger_after_swap
249 if retarget:
250 headers["HX-Retarget"] = retarget
251 if reselect:
252 headers["HX-Reselect"] = reselect
253 if reswap:
254 headers["HX-Reswap"] = reswap
255 if push_url is not None:
256 headers["HX-Push-Url"] = str(push_url).lower()
257 if replace_url is not None:
258 headers["HX-Replace-Url"] = str(replace_url).lower()
259 if refresh:
260 headers["HX-Refresh"] = "true"
261 if redirect:
262 headers["HX-Redirect"] = redirect
263 if location:
264 if isinstance(location, dict):
265 headers["HX-Location"] = json.dumps(location)
266 else:
267 headers["HX-Location"] = str(location)
270def htmx_trigger(
271 trigger_events: str | dict[str, t.Any],
272 content: str = "",
273 status_code: int = 200,
274 **kwargs: t.Any,
275) -> HtmxResponse:
276 trigger_data: dict[str, Any]
277 if isinstance(trigger_events, dict):
278 trigger_value = json.dumps(trigger_events)
279 trigger_name = next(iter(trigger_events.keys()), "custom_trigger")
280 trigger_data = trigger_events
281 else:
282 trigger_value = trigger_events
283 trigger_name = trigger_events
284 trigger_data = {}
286 # Publish HTMX trigger event (async, don't block response)
287 with suppress(Exception):
289 async def _publish_event() -> None:
290 from .adapters.templates._events_wrapper import publish_htmx_trigger
292 await publish_htmx_trigger(
293 trigger_name=trigger_name,
294 trigger_data=trigger_data,
295 )
297 # Schedule event publishing in background
298 asyncio.create_task(_publish_event())
300 return HtmxResponse(
301 content=content,
302 status_code=status_code,
303 trigger=trigger_value,
304 **kwargs,
305 )
308def htmx_redirect(url: str, **kwargs: t.Any) -> HtmxResponse:
309 # Publish HTMX redirect event (async, don't block response)
310 with suppress(Exception):
312 async def _publish_event() -> None:
313 from ._events_integration import get_event_publisher
315 publisher = get_event_publisher()
316 if publisher:
317 await publisher.publish_htmx_update(
318 update_type="redirect",
319 target=url,
320 )
322 # Schedule event publishing in background
323 asyncio.create_task(_publish_event())
325 return HtmxResponse(redirect=url, **kwargs)
328def htmx_refresh(**kwargs: t.Any) -> HtmxResponse:
329 # Publish HTMX refresh event (async, don't block response)
330 with suppress(Exception):
332 async def _publish_event() -> None:
333 from .adapters.templates._events_wrapper import publish_htmx_refresh
335 # Get target from kwargs if provided
336 target = kwargs.get("target", "#body")
337 await publish_htmx_refresh(target=target)
339 # Schedule event publishing in background
340 asyncio.create_task(_publish_event())
342 return HtmxResponse(refresh=True, **kwargs)
345def htmx_push_url(url: str, content: str = "", **kwargs: t.Any) -> HtmxResponse:
346 return HtmxResponse(content=content, push_url=url, **kwargs)
349def htmx_retarget(target: str, content: str = "", **kwargs: t.Any) -> HtmxResponse:
350 return HtmxResponse(content=content, retarget=target, **kwargs)
353def is_htmx(scope_or_request: dict[str, t.Any] | t.Any) -> bool:
354 if hasattr(scope_or_request, "headers"):
355 headers = getattr(scope_or_request, "headers", {})
356 return headers.get("HX-Request") == "true"
357 else:
358 if isinstance(scope_or_request, dict):
359 details = HtmxDetails(scope_or_request)
360 return getattr(details, "is_htmx", False)
361 return False
364__all__ = [
365 "HtmxDetails",
366 "HtmxRequest",
367 "HtmxResponse",
368 "htmx_trigger",
369 "htmx_redirect",
370 "htmx_refresh",
371 "htmx_push_url",
372 "htmx_retarget",
373 "is_htmx",
374]