Coverage for fastblocks/htmx.py: 84%

173 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-10-09 00:47 -0700

1"""FastBlocks Native HTMX Support. 

2 

3This module consolidates and enhances HTMX functionality for FastBlocks, 

4originally based on the asgi-htmx library. 

5 

6Original asgi-htmx library: 

7- Author: Marcelo Trylesinski 

8- Repository: https://github.com/marcelotrylesisnki/asgi-htmx 

9- License: MIT 

10 

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""" 

18 

19import asyncio 

20import json 

21import typing as t 

22from contextlib import suppress 

23from typing import Any 

24from urllib.parse import unquote 

25 

26from acb.debug import debug 

27from starlette.responses import HTMLResponse 

28 

29if t.TYPE_CHECKING: 

30 from starlette.types import Scope 

31else: 

32 Scope = dict 

33 

34try: 

35 from starlette.requests import Request 

36 

37 _starlette_available = True 

38except ImportError: 

39 _starlette_available = False 

40 Request = t.Any # type: ignore 

41 

42STARLETTE_AVAILABLE = _starlette_available 

43 

44 

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 ) 

51 

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 

57 

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 

62 

63 @property 

64 def boosted(self) -> bool: 

65 return self._get_header(b"HX-Boosted") == "true" 

66 

67 @property 

68 def current_url(self) -> str | None: 

69 return self._get_header(b"HX-Current-URL") 

70 

71 @property 

72 def history_restore_request(self) -> bool: 

73 return self._get_header(b"HX-History-Restore-Request") == "true" 

74 

75 @property 

76 def prompt(self) -> str | None: 

77 return self._get_header(b"HX-Prompt") 

78 

79 @property 

80 def target(self) -> str | None: 

81 return self._get_header(b"HX-Target") 

82 

83 @property 

84 def trigger(self) -> str | None: 

85 return self._get_header(b"HX-Trigger") 

86 

87 @property 

88 def trigger_name(self) -> str | None: 

89 return self._get_header(b"HX-Trigger-Name") 

90 

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 

103 

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 } 

118 

119 return {k: v for k, v in headers.items() if v is not None} 

120 

121 

122def _get_header(scope: "Scope", key: bytes) -> str | None: 

123 key_lower = key.lower() 

124 value: str | None = None 

125 should_unquote = False 

126 

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 

137 

138 # Return None if no value found 

139 if value is None: 

140 return None 

141 

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 

148 

149 

150HtmxScope = dict[str, t.Any] 

151 

152if STARLETTE_AVAILABLE and Request is not t.Any: 

153 

154 class HtmxRequest(Request): # type: ignore 

155 scope: HtmxScope 

156 

157 @property 

158 def htmx(self) -> HtmxDetails: 

159 return t.cast(HtmxDetails, self.scope["htmx"]) 

160 

161 def is_htmx(self) -> bool: 

162 return bool(self.htmx) 

163 

164 def is_boosted(self) -> bool: 

165 return self.htmx.boosted 

166 

167 def get_htmx_headers(self) -> dict[str, str | None]: 

168 return self.htmx.get_all_headers() 

169else: 

170 

171 class HtmxRequest: # type: ignore 

172 """Placeholder HtmxRequest when Starlette is not available.""" 

173 

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 ) 

178 

179 

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 {}) 

201 

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 ) 

217 

218 super().__init__( 

219 content=content, 

220 status_code=status_code, 

221 headers=init_headers, 

222 media_type=media_type, 

223 background=background, 

224 ) 

225 

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) 

268 

269 

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 = {} 

285 

286 # Publish HTMX trigger event (async, don't block response) 

287 with suppress(Exception): 

288 

289 async def _publish_event() -> None: 

290 from .adapters.templates._events_wrapper import publish_htmx_trigger 

291 

292 await publish_htmx_trigger( 

293 trigger_name=trigger_name, 

294 trigger_data=trigger_data, 

295 ) 

296 

297 # Schedule event publishing in background 

298 asyncio.create_task(_publish_event()) 

299 

300 return HtmxResponse( 

301 content=content, 

302 status_code=status_code, 

303 trigger=trigger_value, 

304 **kwargs, 

305 ) 

306 

307 

308def htmx_redirect(url: str, **kwargs: t.Any) -> HtmxResponse: 

309 # Publish HTMX redirect event (async, don't block response) 

310 with suppress(Exception): 

311 

312 async def _publish_event() -> None: 

313 from ._events_integration import get_event_publisher 

314 

315 publisher = get_event_publisher() 

316 if publisher: 

317 await publisher.publish_htmx_update( 

318 update_type="redirect", 

319 target=url, 

320 ) 

321 

322 # Schedule event publishing in background 

323 asyncio.create_task(_publish_event()) 

324 

325 return HtmxResponse(redirect=url, **kwargs) 

326 

327 

328def htmx_refresh(**kwargs: t.Any) -> HtmxResponse: 

329 # Publish HTMX refresh event (async, don't block response) 

330 with suppress(Exception): 

331 

332 async def _publish_event() -> None: 

333 from .adapters.templates._events_wrapper import publish_htmx_refresh 

334 

335 # Get target from kwargs if provided 

336 target = kwargs.get("target", "#body") 

337 await publish_htmx_refresh(target=target) 

338 

339 # Schedule event publishing in background 

340 asyncio.create_task(_publish_event()) 

341 

342 return HtmxResponse(refresh=True, **kwargs) 

343 

344 

345def htmx_push_url(url: str, content: str = "", **kwargs: t.Any) -> HtmxResponse: 

346 return HtmxResponse(content=content, push_url=url, **kwargs) 

347 

348 

349def htmx_retarget(target: str, content: str = "", **kwargs: t.Any) -> HtmxResponse: 

350 return HtmxResponse(content=content, retarget=target, **kwargs) 

351 

352 

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 

362 

363 

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]