Coverage for src/lexigram/admin/settings/panel/nodes.py: 97%
149 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +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()
199 package_source: str = "built-in"
200 scope: Literal["global", "tenant"] = "global"
201 store_name: str = "db"
203 _nodes: dict[str, AbstractConfigNode] = {}
205 @classmethod
206 def get_nodes(cls) -> dict[str, AbstractConfigNode]:
207 """Get all nodes defined on this spec."""
208 return cls._nodes
210 @classmethod
211 def to_dict(cls) -> dict[str, Any]:
212 """Convert spec to UI-consumable format."""
213 return {
214 "namespace": cls.namespace,
215 "label": cls.label,
216 "icon": cls.icon,
217 "description": cls.description,
218 "nodes": [node.to_dict() for node in cls.get_nodes().values()],
219 }
222class PydanticConfigSpec(ConfigSpec):
223 """Spec that derives its nodes from a DomainModel.
225 ``DomainModel`` is dataclass-backed (pydantic ``FieldInfo`` defaults are
226 converted to ``dataclasses.field(metadata=...)`` at class creation), so
227 nodes are built from ``__dataclass_fields__`` plus resolved type hints.
228 """
230 model: type[DomainModel] | None = None
231 node_overrides: dict[str, type[AbstractConfigNode] | AbstractConfigNode] = {}
233 @classmethod
234 def get_nodes(cls) -> dict[str, AbstractConfigNode]:
235 """Build nodes dynamically from the bound model's fields."""
236 if not cls.model:
237 return {}
239 ensure = getattr(cls.model, "_ensure_dataclass", None)
240 if callable(ensure):
241 ensure(cls.model)
243 dc_fields = getattr(cls.model, "__dataclass_fields__", {})
244 if not dc_fields:
245 raise TypeError(
246 f"{cls.__name__} model must be a dataclass-backed DomainModel"
247 )
248 hints = getattr(cls.model, "_cached_type_hints", None)
249 if not hints:
250 try:
251 hints = get_type_hints(cls.model)
252 except (NameError, TypeError, AttributeError):
253 hints = {}
255 nodes: dict[str, AbstractConfigNode] = {}
256 for name, field in dc_fields.items():
257 annotation = hints.get(name, str)
258 has_default = (
259 field.default is not MISSING or field.default_factory is not MISSING
260 )
261 default = None if field.default is MISSING else field.default
262 metadata = field.metadata or {}
264 kwargs: dict[str, Any] = {
265 "label": metadata.get("title") or name.replace("_", " ").title(),
266 "default": default,
267 "help_text": metadata.get("description"),
268 "required": not has_default,
269 }
271 override = cls.node_overrides.get(name)
272 if isinstance(override, AbstractConfigNode):
273 node = copy.copy(override)
274 node._name = name
275 nodes[name] = node
276 continue
278 node_cls = override
279 if node_cls is None:
280 if annotation is bool:
281 node_cls = BooleanNode
282 elif annotation is int:
283 node_cls = IntNode
284 kwargs["ge"] = metadata.get("ge")
285 kwargs["le"] = metadata.get("le")
286 elif get_origin(annotation) is Literal:
287 node_cls = EnumNode
288 options = [str(o) for o in get_args(annotation)]
289 kwargs["options"] = options
290 if (default is None or str(default) not in options) and not kwargs[
291 "required"
292 ]:
293 kwargs["default"] = options[0]
294 if kwargs["default"] is not None:
295 kwargs["default"] = str(kwargs["default"])
296 else:
297 node_cls = StringNode
299 node = node_cls(**kwargs)
300 node._name = name
301 nodes[name] = node
303 return nodes