Coverage for src / lexigram / ui / performance / performance.py: 97%

121 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-10 04:11 +0800

1""" 

2Performance utilities for Lexigram Admin UI. 

3 

4Provides response optimization, render caching, and lazy loading 

5helpers for HTMX-powered components. 

6""" 

7 

8from __future__ import annotations 

9 

10from dataclasses import dataclass, field 

11from functools import wraps 

12from hashlib import sha256 

13from time import time 

14from typing import TYPE_CHECKING, Any, TypeVar 

15 

16from lexigram.logging import get_logger 

17from lexigram.serialization import dumps_str 

18from lexigram.ui.core.base import render_to_string 

19from lexigram.ui.core.zones import Zones 

20 

21if TYPE_CHECKING: 

22 from collections.abc import Callable 

23 

24logger = get_logger(__name__) 

25 

26T = TypeVar("T") 

27 

28 

29# === Response Optimization === 

30 

31 

32@dataclass 

33class ResponseOptimizer: 

34 """ 

35 Optimize HTMX responses with caching and conditional rendering. 

36 

37 Features: 

38 - ETag-based caching for unchanged content 

39 - Content hashing to detect changes 

40 - Conditional response headers 

41 """ 

42 

43 def compute_etag(self, content: str) -> str: 

44 """Compute ETag hash for content.""" 

45 return sha256(content.encode()).hexdigest()[:16] 

46 

47 def should_return_304( 

48 self, 

49 content: str, 

50 request_etag: str | None, 

51 ) -> bool: 

52 """Check if we can return 304 Not Modified.""" 

53 if not request_etag: 

54 return False 

55 current_etag = self.compute_etag(content) 

56 return request_etag in (current_etag, f'"{current_etag}"') 

57 

58 def optimize_response( 

59 self, 

60 content: str, 

61 request_etag: str | None = None, 

62 ) -> tuple[str, int, dict[str, str]]: 

63 """ 

64 Optimize response with caching headers. 

65 

66 Args: 

67 content: The HTML content to return 

68 request_etag: The If-None-Match header value from request 

69 

70 Returns: 

71 Tuple of (content, status_code, headers) 

72 """ 

73 etag = self.compute_etag(content) 

74 

75 if self.should_return_304(content, request_etag): 

76 return "", 304, {"ETag": f'"{etag}"'} 

77 

78 headers = { 

79 "ETag": f'"{etag}"', 

80 "Cache-Control": "private, no-cache", 

81 "Vary": "HX-Request", 

82 } 

83 

84 return content, 200, headers 

85 

86 

87def optimize_htmx_response( 

88 content: str, 

89 request_etag: str | None = None, 

90 optimizer: ResponseOptimizer | None = None, 

91) -> tuple[str, int, dict[str, str]]: 

92 """Convenience function for response optimization. 

93 

94 Args: 

95 content: HTML content to optimize. 

96 request_etag: ETag from the incoming request for cache validation. 

97 optimizer: ResponseOptimizer instance. When None, returns the content unchanged. 

98 """ 

99 if optimizer is None: 

100 return content, 200, {} 

101 return optimizer.optimize_response(content, request_etag) 

102 

103 

104# === Render Caching === 

105 

106 

107@dataclass 

108class RenderCache: 

109 """ 

110 LRU cache for rendered component fragments. 

111 

112 Use for expensive-to-render components that don't change often. 

113 """ 

114 

115 _cache: dict[str, tuple[str, float]] = field(default_factory=dict) 

116 max_size: int = 100 

117 ttl_seconds: float = 60.0 

118 

119 def _make_key(self, component_name: str, **kwargs: Any) -> str: 

120 """Create cache key from component name and params.""" 

121 sorted_items = sorted(kwargs.items()) 

122 params_str = dumps_str(sorted_items, sort_keys=True, default=str) 

123 return f"{component_name}:{sha256(params_str.encode()).hexdigest()}" 

124 

125 def get(self, component_name: str, **kwargs: Any) -> str | None: 

126 """Get cached render result if valid.""" 

127 key = self._make_key(component_name, **kwargs) 

128 if key not in self._cache: 

129 logger.debug("render_cache.miss", component=component_name) 

130 return None 

131 

132 content, cached_at = self._cache[key] 

133 if time() - cached_at > self.ttl_seconds: 

134 del self._cache[key] 

135 logger.debug("render_cache.expired", component=component_name) 

136 return None 

137 

138 logger.debug("render_cache.hit", component=component_name) 

139 return content 

140 

141 def set(self, component_name: str, content: str, **kwargs: Any) -> None: 

142 """Cache a render result.""" 

143 # Evict old entries if at capacity 

144 if len(self._cache) >= self.max_size: 

145 # Remove oldest entry 

146 oldest_key = min(self._cache, key=lambda k: self._cache[k][1]) 

147 del self._cache[oldest_key] 

148 logger.debug("render_cache.evicted", component=component_name) 

149 

150 key = self._make_key(component_name, **kwargs) 

151 self._cache[key] = (content, time()) 

152 logger.debug("render_cache.stored", component=component_name) 

153 

154 def invalidate(self, component_name: str | None = None) -> None: 

155 """Invalidate cache entries.""" 

156 if component_name is None: 

157 self._cache.clear() 

158 else: 

159 keys_to_remove = [ 

160 k for k in self._cache if k.startswith(f"{component_name}:") 

161 ] 

162 for key in keys_to_remove: 

163 del self._cache[key] 

164 

165 def cached( 

166 self, 

167 component_name: str | None = None, 

168 ) -> Callable[[Callable[..., str]], Callable[..., str]]: 

169 """Decorator for caching render methods.""" 

170 

171 def decorator(func: Callable[..., str]) -> Callable[..., str]: 

172 name = component_name or func.__qualname__ 

173 

174 @wraps(func) 

175 def wrapper(*args: Any, **kwargs: Any) -> str: 

176 # Try cache first 

177 cached = self.get(name, **kwargs) 

178 if cached is not None: 

179 return cached 

180 

181 # Render and cache 

182 result = func(*args, **kwargs) 

183 self.set(name, result, **kwargs) 

184 return result 

185 

186 return wrapper 

187 

188 return decorator 

189 

190 

191def cached_render( 

192 component_name: str | None = None, 

193 cache: RenderCache | None = None, 

194) -> Callable[[Callable[..., str]], Callable[..., str]]: 

195 """Decorator for caching component renders. 

196 

197 Args: 

198 component_name: Name for the cached component fragment. 

199 cache: RenderCache instance to use. When None, returns an identity decorator. 

200 """ 

201 if cache is None: 

202 return lambda func: func 

203 return cache.cached(component_name) 

204 

205 

206# === Lazy Loading === 

207 

208 

209def lazy_load_placeholder( 

210 url: str, 

211 target_id: str, 

212 trigger: str = "load", 

213 placeholder: str | None = None, 

214) -> str: 

215 """ 

216 Create a lazy-load placeholder that fetches content on trigger. 

217 

218 Args: 

219 url: URL to fetch content from 

220 target_id: ID of the element to replace 

221 trigger: HTMX trigger (load, revealed, intersect, etc.) 

222 placeholder: Optional placeholder content (defaults to skeleton) 

223 

224 Returns: 

225 HTML string for the placeholder 

226 """ 

227 from lexigram.ui.atoms.skeleton import Skeleton 

228 from lexigram.ui.core.base import el 

229 

230 if placeholder is None: 

231 placeholder = render_to_string(Skeleton(variant="table", rows=5)) 

232 

233 return render_to_string( 

234 el( 

235 "div", 

236 placeholder, 

237 id=target_id, 

238 hx_get=url, 

239 hx_trigger=trigger, 

240 hx_swap="outerHTML", 

241 ), 

242 ) 

243 

244 

245def infinite_scroll_trigger( 

246 url: str, 

247 target: str | None = None, 

248 swap: str = "beforeend", 

249 threshold: str = "200px", 

250) -> str: 

251 """ 

252 Create an infinite scroll trigger element. 

253 

254 Args: 

255 url: URL to fetch next page from 

256 target: HTMX target selector (defaults to Zones.DATA) 

257 swap: HTMX swap mode 

258 threshold: Distance from bottom to trigger load 

259 

260 Returns: 

261 HTML string for the trigger element 

262 """ 

263 from lexigram.ui.atoms.spinner import Spinner 

264 from lexigram.ui.core.base import el 

265 

266 target = target or Zones.DATA.selector 

267 

268 return render_to_string( 

269 el( 

270 "div", 

271 Spinner(size="sm"), 

272 class_="flex justify-center py-4", 

273 hx_get=url, 

274 hx_trigger=f"revealed threshold:{threshold}", 

275 hx_target=target, 

276 hx_swap=swap, 

277 ), 

278 ) 

279 

280 

281# === Debouncing === 

282 

283 

284from lexigram.ui.config import DebounceConfig 

285 

286 

287def debounced_search_attrs( 

288 url: str, 

289 delay_ms: int = 300, 

290 target: str | None = None, 

291) -> dict[str, str]: 

292 """ 

293 Generate HTMX attributes for a debounced search input. 

294 

295 Args: 

296 url: URL to search endpoint 

297 delay_ms: Debounce delay in milliseconds 

298 target: HTMX target (defaults to Zones.DATA) 

299 

300 Returns: 

301 Dictionary of HTMX attributes 

302 """ 

303 target = target or Zones.DATA.selector 

304 config = DebounceConfig(delay_ms=delay_ms) 

305 

306 return { 

307 "hx-get": url, 

308 "hx-trigger": config.to_trigger("input"), 

309 "hx-target": target, 

310 "hx-swap": Zones.DATA.swap_mode.value, 

311 "hx-indicator": ".htmx-indicator", 

312 } 

313 

314 

315# === Request Coalescing === 

316 

317 

318@dataclass 

319class RequestCoalescer: 

320 """ 

321 Coalesce rapid-fire requests into single requests. 

322 

323 Useful for batch operations or rapid filter changes. 

324 """ 

325 

326 pending: dict[str, Any] = field(default_factory=dict) 

327 window_ms: float = 100 

328 

329 def add(self, key: str, value: Any) -> None: 

330 """Add a value to coalesce.""" 

331 self.pending[key] = value 

332 

333 def flush(self) -> dict[str, Any]: 

334 """Get and clear all pending values.""" 

335 result = dict(self.pending) 

336 self.pending.clear() 

337 return result 

338 

339 

340# === Performance Middleware Helpers === 

341 

342 

343def add_htmx_timing_header( 

344 headers: dict[str, str], 

345 render_time_ms: float, 

346) -> dict[str, str]: 

347 """Add Server-Timing header for HTMX requests.""" 

348 headers = dict(headers) 

349 headers["Server-Timing"] = f"render;dur={render_time_ms:.2f}" 

350 return headers 

351 

352 

353def measure_render_time(func: Callable[..., str]) -> Callable[..., tuple[str, float]]: 

354 """Decorator to measure render time.""" 

355 

356 @wraps(func) 

357 def wrapper(*args: Any, **kwargs: Any) -> tuple[str, float]: 

358 start = time() 

359 result = func(*args, **kwargs) 

360 elapsed_ms = (time() - start) * 1000 

361 return result, elapsed_ms 

362 

363 return wrapper