Coverage for src / lexigram / admin / settings / panel / nodes.py: 36%
146 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
1"""Core configuration spec and node definitions."""
3from __future__ import annotations
5from abc import ABC, abstractmethod
6import copy
7from dataclasses import MISSING
8import re
9from typing import TYPE_CHECKING, Any, Literal, get_args, get_origin, get_type_hints
11if TYPE_CHECKING:
12 from lexigram.domain import DomainModel
14__all__ = [
15 "AbstractConfigNode",
16 "BooleanNode",
17 "ColorNode",
18 "ConfigSpec",
19 "ConfigSpecMeta",
20 "EnumNode",
21 "IntNode",
22 "PydanticConfigSpec",
23 "SecretNode",
24 "StringNode",
25]
27_HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
30class AbstractConfigNode(ABC):
31 """Base class for a configuration field metadata."""
33 def __init__(
34 self,
35 label: str,
36 default: Any = None,
37 help_text: str | None = None,
38 required: bool = False,
39 readonly: bool = False,
40 icon: str | None = None,
41 category: str | None = None,
42 **extra,
43 ) -> None:
44 self.label = label
45 self.default = default
46 self.help_text = help_text
47 self.required = required
48 self.readonly = readonly
49 self.icon = icon
50 self.category = category
51 self.extra = extra
52 self._name: str | None = None
54 @abstractmethod
55 def validate(self, value: Any) -> Any:
56 """Validate and coerce value."""
58 def to_dict(self) -> dict[str, Any]:
59 """Convert to dictionary for UI rendering."""
60 return {
61 "name": self._name,
62 "label": self.label,
63 "type": self.__class__.__name__.lower().replace("node", ""),
64 "default": self.default,
65 "help_text": self.help_text,
66 "required": self.required,
67 "readonly": self.readonly,
68 "icon": self.icon,
69 "category": self.category,
70 "extra": self.extra,
71 }
74class StringNode(AbstractConfigNode):
75 """Configuration node for string values."""
77 def validate(self, value: Any) -> str:
78 """Validate and coerce value to string."""
79 return str(value) if value is not None else self.default
82class ColorNode(StringNode):
83 """Configuration node for hex color values."""
85 def validate(self, value: Any) -> str:
86 """Validate value is a 6-digit hex color, else fall back to default."""
87 val = str(value) if value is not None else self.default
88 if not _HEX_COLOR_RE.match(val):
89 return self.default
90 return val
93class IntNode(AbstractConfigNode):
94 """Configuration node for integer values."""
96 def __init__(
97 self,
98 label: str,
99 default: Any = None,
100 help_text: str | None = None,
101 required: bool = False,
102 readonly: bool = False,
103 icon: str | None = None,
104 category: str | None = None,
105 ge: int | None = None,
106 le: int | None = None,
107 **extra,
108 ) -> None:
109 super().__init__(
110 label,
111 default=default,
112 help_text=help_text,
113 required=required,
114 readonly=readonly,
115 icon=icon,
116 category=category,
117 **extra,
118 )
119 self.ge = ge
120 self.le = le
122 def validate(self, value: Any) -> int:
123 """Validate and coerce value to int."""
124 try:
125 val = int(value)
126 except (ValueError, TypeError):
127 return self.default
128 if self.ge is not None and val < self.ge:
129 return self.default
130 if self.le is not None and val > self.le:
131 return self.default
132 return val
135class BooleanNode(AbstractConfigNode):
136 """Configuration node for boolean values."""
138 def validate(self, value: Any) -> bool:
139 """Validate and coerce value to bool."""
140 if isinstance(value, bool):
141 return value
142 if isinstance(value, str):
143 return value.lower() in ("true", "1", "yes", "on")
144 return bool(value)
147class EnumNode(AbstractConfigNode):
148 """Configuration node for enumerated choice values."""
150 def __init__(
151 self, *args: Any, options: list[str] | dict[str, str], **kwargs: Any
152 ) -> None:
153 super().__init__(*args, **kwargs)
154 self.options = options
156 def validate(self, value: Any) -> str:
157 """Validate that value is a member of the allowed options."""
158 val = str(value)
159 allowed = list(self.options) if isinstance(self.options, dict) else self.options
160 if val not in allowed:
161 return self.default
162 return val
164 def to_dict(self) -> dict[str, Any]:
165 """Convert to dictionary for UI rendering, including options."""
166 d = super().to_dict()
167 d["options"] = self.options
168 return d
171class SecretNode(StringNode):
172 """Field for sensitive data, usually masked in UI."""
175class ConfigSpecMeta(type):
176 """Metaclass to collect nodes defined on a spec."""
178 def __new__(mcs, name, bases, attrs) -> Any:
179 nodes: dict[str, AbstractConfigNode] = {}
180 for base in bases:
181 nodes.update(getattr(base, "_nodes", {}))
182 for key, value in attrs.items():
183 if isinstance(value, AbstractConfigNode):
184 value._name = key
185 nodes[key] = value
187 attrs["_nodes"] = nodes
188 return super().__new__(mcs, name, bases, attrs)
191class ConfigSpec(metaclass=ConfigSpecMeta):
192 """Base class for grouping configuration nodes."""
194 namespace: str = ""
195 label: str = ""
196 icon: str = "cog"
197 description: str = ""
198 required_permissions: frozenset[str] = frozenset()
200 _nodes: dict[str, AbstractConfigNode] = {}
202 @classmethod
203 def get_nodes(cls) -> dict[str, AbstractConfigNode]:
204 """Get all nodes defined on this spec."""
205 return cls._nodes
207 @classmethod
208 def to_dict(cls) -> dict[str, Any]:
209 """Convert spec to UI-consumable format."""
210 return {
211 "namespace": cls.namespace,
212 "label": cls.label,
213 "icon": cls.icon,
214 "description": cls.description,
215 "nodes": [node.to_dict() for node in cls.get_nodes().values()],
216 }
219class PydanticConfigSpec(ConfigSpec):
220 """Spec that derives its nodes from a DomainModel.
222 ``DomainModel`` is dataclass-backed (pydantic ``FieldInfo`` defaults are
223 converted to ``dataclasses.field(metadata=...)`` at class creation), so
224 nodes are built from ``__dataclass_fields__`` plus resolved type hints.
225 """
227 model: type[DomainModel] | None = None
228 node_overrides: dict[str, type[AbstractConfigNode] | AbstractConfigNode] = {}
230 @classmethod
231 def get_nodes(cls) -> dict[str, AbstractConfigNode]:
232 """Build nodes dynamically from the bound model's fields."""
233 if not cls.model:
234 return {}
236 ensure = getattr(cls.model, "_ensure_dataclass", None)
237 if callable(ensure):
238 ensure(cls.model)
240 dc_fields = getattr(cls.model, "__dataclass_fields__", {})
241 if not dc_fields:
242 raise TypeError(
243 f"{cls.__name__} model must be a dataclass-backed DomainModel"
244 )
245 hints = getattr(cls.model, "_cached_type_hints", None)
246 if not hints:
247 try:
248 hints = get_type_hints(cls.model)
249 except (NameError, TypeError, AttributeError):
250 hints = {}
252 nodes: dict[str, AbstractConfigNode] = {}
253 for name, field in dc_fields.items():
254 annotation = hints.get(name, str)
255 has_default = (
256 field.default is not MISSING or field.default_factory is not MISSING
257 )
258 default = None if field.default is MISSING else field.default
259 metadata = field.metadata or {}
261 kwargs: dict[str, Any] = {
262 "label": metadata.get("title") or name.replace("_", " ").title(),
263 "default": default,
264 "help_text": metadata.get("description"),
265 "required": not has_default,
266 }
268 override = cls.node_overrides.get(name)
269 if isinstance(override, AbstractConfigNode):
270 node = copy.copy(override)
271 node._name = name
272 nodes[name] = node
273 continue
275 node_cls = override
276 if node_cls is None:
277 if annotation is bool:
278 node_cls = BooleanNode
279 elif annotation is int:
280 node_cls = IntNode
281 kwargs["ge"] = metadata.get("ge")
282 kwargs["le"] = metadata.get("le")
283 elif get_origin(annotation) is Literal:
284 node_cls = EnumNode
285 options = [str(o) for o in get_args(annotation)]
286 kwargs["options"] = options
287 if (default is None or str(default) not in options) and not kwargs[
288 "required"
289 ]:
290 kwargs["default"] = options[0]
291 if kwargs["default"] is not None:
292 kwargs["default"] = str(kwargs["default"])
293 else:
294 node_cls = StringNode
296 node = node_cls(**kwargs)
297 node._name = name
298 nodes[name] = node
300 return nodes