Coverage for fastblocks/htmx.py: 87%

146 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-21 04:50 -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""" 

17 

18import json 

19import typing as t 

20from urllib.parse import unquote 

21 

22from acb.debug import debug 

23from starlette.responses import HTMLResponse 

24 

25if t.TYPE_CHECKING: 

26 from starlette.types import Scope 

27else: 

28 Scope = dict 

29 

30try: 

31 from starlette.requests import Request 

32 

33 _starlette_available = True 

34except ImportError: 

35 _starlette_available = False 

36 Request = t.Any # type: ignore 

37 

38STARLETTE_AVAILABLE = _starlette_available 

39 

40 

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 ) 

47 

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 

53 

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 

58 

59 @property 

60 def boosted(self) -> bool: 

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

62 

63 @property 

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

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

66 

67 @property 

68 def history_restore_request(self) -> bool: 

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

70 

71 @property 

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

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

74 

75 @property 

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

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

78 

79 @property 

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

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

82 

83 @property 

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

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

86 

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 

99 

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 } 

114 

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

116 

117 

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 

138 

139 

140HtmxScope = dict[str, t.Any] 

141 

142if STARLETTE_AVAILABLE and Request is not t.Any: 

143 

144 class HtmxRequest(Request): # type: ignore 

145 scope: HtmxScope 

146 

147 @property 

148 def htmx(self) -> HtmxDetails: 

149 return self.scope["htmx"] 

150 

151 def is_htmx(self) -> bool: 

152 return bool(self.htmx) 

153 

154 def is_boosted(self) -> bool: 

155 return self.htmx.boosted 

156 

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

158 return self.htmx.get_all_headers() 

159else: 

160 

161 class HtmxRequest: # type: ignore 

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

163 

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 ) 

168 

169 

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

191 

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) 

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 

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 

237 

238 return HtmxResponse( 

239 content=content, 

240 status_code=status_code, 

241 trigger=trigger_value, 

242 **kwargs, 

243 ) 

244 

245 

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

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

248 

249 

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

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

252 

253 

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

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

256 

257 

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

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

260 

261 

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 

271 

272 

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]