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
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-10 04:11 +0800
1from __future__ import annotations
3from collections.abc import Iterable
4import html
5import importlib
6from typing import Any, Self
8from lexigram.logging import get_logger
10logger = get_logger(__name__)
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.
17try:
18 _htpy = importlib.import_module("htpy")
19except ImportError: # pragma: no cover - networks / minimal envs
20 _htpy = None # type: ignore[assignment]
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__")
30class Element:
31 """A lightweight, structured HTML element compatible with htpy.
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 """
39 SELF_CLOSING = {"input", "img", "br", "hr", "meta", "link"}
41 def __init__(self, tag: str, *children: Any, **attrs: Any) -> None:
42 self.tag = tag
43 self.children = list(children)
44 self.attrs = attrs
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))
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"
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]
63 # Support Streamlit-like `with` usage by adding ourselves to the current context
64 add_child_to_current(self)
66 def __enter__(self) -> Self:
67 _context_stack.append(self)
68 return self
70 def __exit__(self, exc_type, exc, tb) -> None:
71 if _context_stack and _context_stack[-1] is self:
72 _context_stack.pop()
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("_", "-")
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)}"')
94 if self.tag in self.SELF_CLOSING:
95 parts.append(" />")
96 return "".join(parts)
98 parts.append(">")
100 for c in self.children:
101 parts.append(render_to_string(c))
103 parts.append(f"</{self.tag}>")
104 return "".join(parts)
106 def __str__(self) -> str: # pragma: no cover - exercised indirectly
107 return self.__html__()
110class RawHTML:
111 """Wrapper for raw HTML strings that should be included verbatim.
113 Instances implement ``__html__`` so they are detected as htpy-like
114 elements and their contents are not escaped when inserted as children.
115 """
117 def __init__(self, value: str) -> None:
118 self.value = value
120 def __html__(self) -> str:
121 return str(self.value)
124def raw(value: str) -> RawHTML:
125 return RawHTML(value)
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__`.
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)
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)
144# Context stack to support Streamlit-like `with` usage
145_context_stack: list[Any] = []
146_no_context: bool = False
149class NoContext:
150 """Context manager to temporarily disable auto-registration of components."""
152 def __enter__(self) -> Any:
153 global _no_context
154 self.old = _no_context
155 _no_context = True
157 def __exit__(self, *args) -> Any:
158 global _no_context
159 _no_context = self.old
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)
169class Component:
170 """Base UI Component for lexigram-admin (HTPy-backed).
172 Subclasses implement `render()` which returns either a string
173 or an htpy element. `render_to_string` converts to HTML.
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 """
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()
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
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
199 if isinstance(child, Slot):
200 return child.render()
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()
209 return str(child)
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
218 def on_mount(self) -> None:
219 """Lifecycle hook called when component is instantiated."""
221 def __enter__(self) -> Self:
222 _context_stack.append(self)
223 return self
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()
230 def add(self, *children: Any) -> Component:
231 """Fluent API to add children to this component."""
232 self.children.extend(children)
233 return self
235 def render(self) -> str | Any:
236 raise NotImplementedError
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 )
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
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)
268 def __str__(self) -> str:
269 return self.__html__()
272def render_to_string(value: str | Any) -> str:
273 """Render a component or htpy element to an HTML string.
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 ""
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
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)
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())
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
310 logger = get_logger(__name__)
311 logger.exception("htpy element __html__() raised an exception")
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)
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())
327 return html.escape(str(value))