Coverage for src / lexigram / ui / core / base.py: 76%

160 statements  

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

1from __future__ import annotations 

2 

3from collections.abc import Iterable 

4import html 

5import importlib 

6from typing import Any, Self 

7 

8from lexigram.logging import get_logger 

9 

10logger = get_logger(__name__) 

11 

12# Prefer a real `htpy` module when available, but tolerate environments 

13# without it (tests, minimal installs). We expose an `el` factory that 

14# constructs a real htpy element when possible, otherwise falls back to 

15# our lightweight `Element` implementation. 

16 

17try: 

18 _htpy = importlib.import_module("htpy") 

19except ImportError: # pragma: no cover - networks / minimal envs 

20 _htpy = None # type: ignore[assignment] 

21 

22 

23def _is_htpy_element(el: Any) -> bool: 

24 # Only consider objects that provide an explicit HTML conversion 

25 # method (``__html__``). Components also implement ``render`` so 

26 # checking for ``render`` would misclassify them as htpy elements. 

27 return hasattr(el, "__html__") 

28 

29 

30class Element: 

31 """A lightweight, structured HTML element compatible with htpy. 

32 

33 This provides a small subset of behaviour we need: HTML attribute 

34 escaping, boolean attributes, self-closing tag handling and a stable 

35 `__html__`/`__str__` API so our `render_to_string` function can 

36 consistently produce HTML regardless of whether `htpy` is present. 

37 """ 

38 

39 SELF_CLOSING = {"input", "img", "br", "hr", "meta", "link"} 

40 

41 def __init__(self, tag: str, *children: Any, **attrs: Any) -> None: 

42 self.tag = tag 

43 self.children = list(children) 

44 self.attrs = attrs 

45 

46 # If leading children are dicts, use them as attributes (htpy-style) 

47 while self.children and isinstance(self.children[0], dict): 

48 self.attrs.update(self.children.pop(0)) 

49 

50 # Auto-add type="button" for HTMX-enabled buttons to avoid accidental form submits 

51 if self.tag == "button": 

52 if "type" not in self.attrs: 

53 # Detect HTMX-style attributes (pythonic `hx_` names) 

54 if any(k.startswith("hx") for k in self.attrs): 

55 self.attrs["type"] = "button" 

56 

57 # Remove automatic `hx_trigger="load"` to prefer manual checks 

58 for k in list(self.attrs.keys()): 

59 if k in ("hx_trigger", "hx-trigger"): 

60 if str(self.attrs[k]) == "load": 

61 del self.attrs[k] 

62 

63 # Support Streamlit-like `with` usage by adding ourselves to the current context 

64 add_child_to_current(self) 

65 

66 def __enter__(self) -> Self: 

67 _context_stack.append(self) 

68 return self 

69 

70 def __exit__(self, exc_type, exc, tb) -> None: 

71 if _context_stack and _context_stack[-1] is self: 

72 _context_stack.pop() 

73 

74 def __html__(self) -> str: 

75 parts: list[str] = [f"<{self.tag}"] 

76 for k, v in self.attrs.items(): 

77 # Map pythonic kwarg names like `class_` to `class`, `for_` to `for`, 

78 # and convert internal underscores to hyphens so `hx_post` -> `hx-post`. 

79 if k == "class_": 

80 attr_name = "class" 

81 elif k.endswith("_") and "_" not in k[:-1]: 

82 # Handles `for_`, `id_` (reserved words or trailing underscores) 

83 attr_name = k[:-1] 

84 else: 

85 attr_name = k.replace("_", "-") 

86 

87 if v is True: 

88 parts.append(f" {attr_name}") 

89 elif v is False or v is None: 

90 continue 

91 else: 

92 parts.append(f' {attr_name}="{html.escape(str(v), quote=True)}"') 

93 

94 if self.tag in self.SELF_CLOSING: 

95 parts.append(" />") 

96 return "".join(parts) 

97 

98 parts.append(">") 

99 

100 for c in self.children: 

101 parts.append(render_to_string(c)) 

102 

103 parts.append(f"</{self.tag}>") 

104 return "".join(parts) 

105 

106 def __str__(self) -> str: # pragma: no cover - exercised indirectly 

107 return self.__html__() 

108 

109 

110class RawHTML: 

111 """Wrapper for raw HTML strings that should be included verbatim. 

112 

113 Instances implement ``__html__`` so they are detected as htpy-like 

114 elements and their contents are not escaped when inserted as children. 

115 """ 

116 

117 def __init__(self, value: str) -> None: 

118 self.value = value 

119 

120 def __html__(self) -> str: 

121 return str(self.value) 

122 

123 

124def raw(value: str) -> RawHTML: 

125 return RawHTML(value) 

126 

127 

128def el(tag: str, *children: Any, **attrs: Any) -> Any: 

129 """Construct an element using `htpy` when available, otherwise 

130 return an `Element` fallback that implements `__html__`. 

131 

132 For self-closing tags we prefer our local Element to ensure consistent 

133 output (including trailing slash), even when `htpy` is installed. 

134 """ 

135 # Ensure deterministic, self-closing rendering for known empty tags 

136 if tag in Element.SELF_CLOSING: 

137 return Element(tag, *children, **attrs) 

138 

139 # We always use our local Element for blocks that might be used with `with`, 

140 # even if htpy is present, to ensure they support the context manager protocol. 

141 return Element(tag, *children, **attrs) 

142 

143 

144# Context stack to support Streamlit-like `with` usage 

145_context_stack: list[Any] = [] 

146_no_context: bool = False 

147 

148 

149class NoContext: 

150 """Context manager to temporarily disable auto-registration of components.""" 

151 

152 def __enter__(self) -> Any: 

153 global _no_context 

154 self.old = _no_context 

155 _no_context = True 

156 

157 def __exit__(self, *args) -> Any: 

158 global _no_context 

159 _no_context = self.old 

160 

161 

162def add_child_to_current(child: Any) -> None: 

163 """If a component is active in a `with` context, append child to it.""" 

164 if _context_stack and not _no_context: 

165 parent = _context_stack[-1] 

166 parent.children.append(child) 

167 

168 

169class Component: 

170 """Base UI Component for lexigram-admin (HTPy-backed). 

171 

172 Subclasses implement `render()` which returns either a string 

173 or an htpy element. `render_to_string` converts to HTML. 

174 

175 Components support Streamlit-style `with` usage via context manager 

176 methods: entering the context makes the component the current parent 

177 for subsequent calls to `add_child_to_current` or manual appends. 

178 """ 

179 

180 def __init__(self, *children: Any, as_child: bool = False, **props: Any) -> None: 

181 self.as_child = as_child 

182 self.props = props 

183 self.children: list[Any] = ( 

184 list(children) if children else list(props.pop("children", [])) 

185 ) 

186 # Support Streamlit-like `with` usage by adding ourselves to the current context 

187 add_child_to_current(self) 

188 self.on_mount() 

189 

190 def _render_as_child(self) -> str | Any: 

191 """Render by delegating to the first child when ``as_child`` is True.""" 

192 if not self.as_child or not self.children: 

193 return None # signal to fall through to normal render 

194 

195 child = self.children[0] 

196 # Local import to avoid circular import (slot.py imports Component from here) 

197 from lexigram.ui.core.slot import Slot 

198 

199 if isinstance(child, Slot): 

200 return child.render() 

201 

202 if isinstance(child, Component): 

203 # Merge parent's non-conflicting props into the child 

204 for key, value in self.props.items(): 

205 if key not in child.props: 

206 child.props[key] = value 

207 return child.render() 

208 

209 return str(child) 

210 

211 def __init_subclass__(cls, **kwargs: Any) -> None: 

212 super().__init_subclass__(**kwargs) 

213 # Handle auto-registration if @Injectable was used 

214 if hasattr(cls, "_injectable_config"): 

215 # Registration will happen during provider discovery 

216 pass 

217 

218 def on_mount(self) -> None: 

219 """Lifecycle hook called when component is instantiated.""" 

220 

221 def __enter__(self) -> Self: 

222 _context_stack.append(self) 

223 return self 

224 

225 def __exit__(self, exc_type, exc, tb) -> None: 

226 # Pop self from the stack if it's the active context 

227 if _context_stack and _context_stack[-1] is self: 

228 _context_stack.pop() 

229 

230 def add(self, *children: Any) -> Component: 

231 """Fluent API to add children to this component.""" 

232 self.children.extend(children) 

233 return self 

234 

235 def render(self) -> str | Any: 

236 raise NotImplementedError 

237 

238 def __html__(self) -> str: 

239 # Check asChild delegation first 

240 as_child_result = self._render_as_child() 

241 if as_child_result is not None: 

242 rendered = as_child_result 

243 else: 

244 from lexigram.ui.config import ( 

245 UIConfig, # lazy to avoid circular at import time 

246 ) 

247 

248 try: 

249 cfg = UIConfig() # use defaults; provider may supply a richer instance 

250 except Exception as e: # noqa: BLE001 

251 logger.debug("ui_config_load_failed", error=str(e)) 

252 cfg = None 

253 

254 rendered = self.render() 

255 debug = getattr(cfg, "debug_components", False) 

256 if debug: 

257 component_name = type(self).__name__ 

258 logger.debug("component.render", component=component_name) 

259 # Inject data-component attribute on the outermost element. 

260 html_str = render_to_string(rendered) 

261 # Prepend data-component as an HTML comment marker that 

262 # does not mutate the element tree (safe, non-intrusive). 

263 return ( 

264 f'<!-- data-component="{html.escape(component_name)}" -->{html_str}' 

265 ) 

266 return render_to_string(rendered) 

267 

268 def __str__(self) -> str: 

269 return self.__html__() 

270 

271 

272def render_to_string(value: str | Any) -> str: 

273 """Render a component or htpy element to an HTML string. 

274 

275 This performs a best-effort conversion: strings are returned verbatim, 

276 htpy elements are converted if they provide a renderer, iterables are 

277 flattened by rendering each child and concatenating the results, and 

278 component instances are rendered via their `render()` method. 

279 """ 

280 # None becomes empty string 

281 if value is None: 

282 return "" 

283 

284 # Strings are returned verbatim. Escaping happens at the Element/htpy 

285 # attribute layer when content is inserted into HTML. To include 

286 # pre-rendered HTML safely, use RawHTML (via raw()) which signals intent. 

287 if isinstance(value, str): 

288 return value 

289 

290 # Iterables (lists/tuples/generators) are rendered element-wise. We 

291 # explicitly exclude `bytes`/`dict` as they are not HTML sequences. 

292 if isinstance(value, Iterable) and not isinstance(value, (str, bytes, dict)): 

293 # Check if the value has iter_chunks method (deprecation path) 

294 if hasattr(value, "iter_chunks"): 

295 return "".join(render_to_string(chunk) for chunk in value.iter_chunks()) 

296 return "".join(render_to_string(v) for v in value) 

297 

298 # Note: we check for Component first to avoid infinite recursion if 

299 # Component implements __html__ (which it does, calling this function). 

300 if isinstance(value, Component): 

301 return render_to_string(value.render()) 

302 

303 if _is_htpy_element(value): 

304 try: 

305 # Prefer the explicit HTML representation if available 

306 return value.__html__() 

307 except (AttributeError, TypeError): 

308 from lexigram.logging import get_logger 

309 

310 logger = get_logger(__name__) 

311 logger.exception("htpy element __html__() raised an exception") 

312 

313 try: 

314 return str(value) 

315 except (TypeError, ValueError) as e: 

316 logger.debug( 

317 "str() conversion failed for htpy element; falling back to repr: %s", 

318 e, 

319 exc_info=True, 

320 ) 

321 return repr(value) 

322 

323 # fallback for objects with render method but not inheriting from Component 

324 if hasattr(value, "render") and callable(value.render): 

325 return render_to_string(value.render()) 

326 

327 return html.escape(str(value))