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

1"""Core configuration spec and node definitions.""" 

2 

3from __future__ import annotations 

4 

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 

10 

11if TYPE_CHECKING: 

12 from lexigram.domain import DomainModel 

13 

14__all__ = [ 

15 "AbstractConfigNode", 

16 "BooleanNode", 

17 "ColorNode", 

18 "ConfigSpec", 

19 "ConfigSpecMeta", 

20 "EnumNode", 

21 "IntNode", 

22 "PydanticConfigSpec", 

23 "SecretNode", 

24 "StringNode", 

25] 

26 

27_HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$") 

28 

29 

30class AbstractConfigNode(ABC): 

31 """Base class for a configuration field metadata.""" 

32 

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 

53 

54 @abstractmethod 

55 def validate(self, value: Any) -> Any: 

56 """Validate and coerce value.""" 

57 

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 } 

72 

73 

74class StringNode(AbstractConfigNode): 

75 """Configuration node for string values.""" 

76 

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 

80 

81 

82class ColorNode(StringNode): 

83 """Configuration node for hex color values.""" 

84 

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 

91 

92 

93class IntNode(AbstractConfigNode): 

94 """Configuration node for integer values.""" 

95 

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 

121 

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 

133 

134 

135class BooleanNode(AbstractConfigNode): 

136 """Configuration node for boolean values.""" 

137 

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) 

145 

146 

147class EnumNode(AbstractConfigNode): 

148 """Configuration node for enumerated choice values.""" 

149 

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 

155 

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 

163 

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 

169 

170 

171class SecretNode(StringNode): 

172 """Field for sensitive data, usually masked in UI.""" 

173 

174 

175class ConfigSpecMeta(type): 

176 """Metaclass to collect nodes defined on a spec.""" 

177 

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 

186 

187 attrs["_nodes"] = nodes 

188 return super().__new__(mcs, name, bases, attrs) 

189 

190 

191class ConfigSpec(metaclass=ConfigSpecMeta): 

192 """Base class for grouping configuration nodes.""" 

193 

194 namespace: str = "" 

195 label: str = "" 

196 icon: str = "cog" 

197 description: str = "" 

198 required_permissions: frozenset[str] = frozenset() 

199 

200 _nodes: dict[str, AbstractConfigNode] = {} 

201 

202 @classmethod 

203 def get_nodes(cls) -> dict[str, AbstractConfigNode]: 

204 """Get all nodes defined on this spec.""" 

205 return cls._nodes 

206 

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 } 

217 

218 

219class PydanticConfigSpec(ConfigSpec): 

220 """Spec that derives its nodes from a DomainModel. 

221 

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 """ 

226 

227 model: type[DomainModel] | None = None 

228 node_overrides: dict[str, type[AbstractConfigNode] | AbstractConfigNode] = {} 

229 

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 {} 

235 

236 ensure = getattr(cls.model, "_ensure_dataclass", None) 

237 if callable(ensure): 

238 ensure(cls.model) 

239 

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 = {} 

251 

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 {} 

260 

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 } 

267 

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 

274 

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 

295 

296 node = node_cls(**kwargs) 

297 node._name = name 

298 nodes[name] = node 

299 

300 return nodes