Coverage for src / lexigram / admin / forms / components.py: 21%

218 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-13 22:14 +0800

1"""Unified Form Components. 

2Includes FormBase, FormBuilder, and FormSchemaGenerator. 

3""" 

4 

5from __future__ import annotations 

6 

7from dataclasses import dataclass, field 

8import types as _builtin_types 

9from typing import ( 

10 TYPE_CHECKING, 

11 Any, 

12 ClassVar, 

13 Union, 

14 get_args, 

15 get_origin, 

16) 

17 

18if TYPE_CHECKING: 

19 from pydantic.fields import FieldInfo 

20 

21 from lexigram.admin.forms.builder import Form 

22 

23from lexigram.admin.exceptions import AdminValidationError 

24from lexigram.admin.forms.fields import AbstractField, FieldSchema, FieldType 

25from lexigram.contracts.exceptions import FieldError 

26from lexigram.result import Err, Ok, Result 

27from lexigram.ui import Component, el 

28 

29try: 

30 from lexigram.domain import DomainModel 

31 

32 HAS_PYDANTIC = True 

33except ImportError: 

34 HAS_PYDANTIC = False 

35 

36 

37@dataclass 

38class FormSchema: 

39 """Definition of a complete form structure.""" 

40 

41 fields: list[FieldSchema] = field(default_factory=list) 

42 title: str | None = None 

43 description: str | None = None 

44 resource_name: str | None = None 

45 metadata: dict[str, Any] = field(default_factory=dict) 

46 layout: Any | None = None 

47 

48 def get_field(self, name: str) -> FieldSchema | None: 

49 for f in self.fields: 

50 if f.name == name: 

51 return f 

52 return None 

53 

54 

55class FormMeta(type): 

56 """Metaclass to collect Field instances from class attributes.""" 

57 

58 def __new__(mcs, name, bases, namespace) -> Any: 

59 fields = {} 

60 for base in bases: 

61 if hasattr(base, "_declared_fields"): 

62 fields.update(base._declared_fields) 

63 for key, value in list(namespace.items()): 

64 if isinstance(value, AbstractField): 

65 fields[key] = value 

66 value.name = key 

67 namespace["_declared_fields"] = fields 

68 return super().__new__(mcs, name, bases, namespace) 

69 

70 

71class FormBase(Component, metaclass=FormMeta): 

72 """Base form class with lifecycle and rendering support.""" 

73 

74 _declared_fields: ClassVar[dict[str, AbstractField]] 

75 

76 def __init__( 

77 self, 

78 data: dict | None = None, 

79 initial: dict | None = None, 

80 action: str | None = None, 

81 method: str = "POST", 

82 hx_post: str | None = None, 

83 hx_target: str | None = None, 

84 **props: Any, 

85 ) -> None: 

86 super().__init__(**props) 

87 self.data = data or {} 

88 self.initial = initial or {} 

89 self.action = action 

90 self.method = method 

91 self.hx_post = hx_post 

92 self.hx_target = hx_target 

93 self.fields: dict[str, AbstractField] = {} 

94 self.errors: dict[str, list[str]] = {} 

95 self._initialize_fields() 

96 if self.data: 

97 self.is_valid() 

98 

99 def _initialize_fields(self) -> Any: 

100 for name, field_proto in self._declared_fields.items(): 

101 value = self.data.get(name) 

102 if value is None and not self.data: 

103 value = self.initial.get(name, field_proto.default) 

104 elif value is None and self.data: 

105 value = field_proto.default 

106 self.fields[name] = field_proto.bind(value) 

107 

108 def is_valid(self) -> bool: 

109 self.errors = {} 

110 is_valid = True 

111 for name, form_field in self.fields.items(): 

112 try: 

113 form_field.validate(form_field.value) 

114 except ValueError as e: 

115 form_field.errors.append(str(e)) 

116 self.errors[name] = [str(e)] 

117 is_valid = False 

118 return is_valid 

119 

120 async def validate(self) -> Result[dict[str, Any], AdminValidationError]: 

121 """Validate form data, returning a Result. 

122 

123 Returns: 

124 Ok containing cleaned data dict on success, or Err containing 

125 AdminValidationError with per-field FieldError detail on failure. 

126 """ 

127 if self.is_valid(): 

128 return Ok(self.cleaned_data) 

129 field_errors = [ 

130 FieldError(field=name, message=msgs[0], code="invalid") 

131 for name, msgs in self.errors.items() 

132 if msgs 

133 ] 

134 return Err( 

135 AdminValidationError( 

136 message="Form validation failed", 

137 errors=field_errors, 

138 ) 

139 ) 

140 

141 @property 

142 def cleaned_data(self) -> dict: 

143 return {name: form_field.value for name, form_field in self.fields.items()} 

144 

145 def render(self) -> Any: 

146 from lexigram.ui import Button 

147 

148 layout = getattr(self, "layout", None) 

149 if layout: 

150 if hasattr(layout, "render"): 

151 form_body = layout.render(self) 

152 elif isinstance(layout, list): 

153 form_body = el( 

154 "div", 

155 *[ 

156 (n.render(self) if hasattr(n, "render") else str(n)) 

157 for n in layout 

158 ], 

159 class_="space-y-6", 

160 ) 

161 else: 

162 form_body = str(layout) 

163 else: 

164 form_content = [field.render() for field in self.fields.values()] 

165 form_body = el("div", *form_content, class_="space-y-4") 

166 

167 actions = el( 

168 "div", 

169 Button("Submit", type="submit", color="primary"), 

170 class_="flex justify-end pt-4 border-t border-border mt-6", 

171 ) 

172 

173 attrs = { 

174 "method": self.method, 

175 "class": "bg-card p-6 rounded-lg shadow", 

176 } 

177 if self.action: 

178 attrs["action"] = self.action 

179 if self.hx_post: 

180 attrs["hx-post"] = self.hx_post 

181 if self.hx_target: 

182 attrs["hx-target"] = self.hx_target 

183 

184 return el("form", form_body, actions, **attrs) 

185 

186 

187class FormSchemaGenerator: 

188 """Generates FormSchema from various data model types.""" 

189 

190 def __init__(self, resource_registry: dict[str, type] | None = None) -> None: 

191 self.resource_registry = resource_registry 

192 

193 def from_pydantic(self, model: type) -> FormSchema: 

194 """Generate a FormSchema from a model class. 

195 

196 Supports both Pydantic v2 ``BaseModel`` subclasses (via 

197 ``model_fields``) and ``DomainModel`` / stdlib dataclasses (via 

198 ``__dataclass_fields__``). 

199 """ 

200 fields = [] 

201 title = getattr(model, "__name__", "Form") 

202 

203 # Pydantic v2 BaseModel 

204 if hasattr(model, "model_fields") and isinstance(model.model_fields, dict): 

205 for name, field_info in model.model_fields.items(): 

206 fields.append(self._parse_pydantic_field(name, field_info)) 

207 elif hasattr(model, "__dataclass_fields__"): 

208 # DomainModel / stdlib dataclass 

209 import dataclasses 

210 import typing 

211 

212 type_hints = typing.get_type_hints(model) 

213 for dc_field in dataclasses.fields(model): 

214 name = dc_field.name 

215 annotation = type_hints.get(name) 

216 meta: dict = dict(dc_field.metadata) if dc_field.metadata else {} 

217 fields.append( 

218 self._parse_dataclass_field(name, annotation, dc_field, meta) 

219 ) 

220 else: 

221 raise TypeError( 

222 f"Unsupported model type: {model!r}. " 

223 "Expected a Pydantic BaseModel or dataclass-backed DomainModel." 

224 ) 

225 

226 return FormSchema(fields=fields, title=title) 

227 

228 def _parse_pydantic_field(self, name: str, field_info: FieldInfo) -> FieldSchema: 

229 annotation = field_info.annotation 

230 field_type = self._map_type(name, annotation) 

231 from pydantic_core import PydanticUndefined 

232 

233 label = ( 

234 str(field_info.title) 

235 if field_info.title 

236 else name.replace("_", " ").title() 

237 ) 

238 is_required = ( 

239 field_info.is_required() if hasattr(field_info, "is_required") else True 

240 ) 

241 if ( 

242 field_info.default is not PydanticUndefined 

243 or field_info.default_factory is not None 

244 ): 

245 is_required = False 

246 nested_schema = None 

247 if field_type == FieldType.NESTED: 

248 # Handle Optional[Model], Union[Model, None], or X | None (3.10+) 

249 origin = get_origin(annotation) 

250 args = get_args(annotation) 

251 model_class = annotation 

252 _union_types = (Union, _builtin_types.UnionType) 

253 if origin in _union_types: 

254 for arg in args: 

255 if ( 

256 arg is not type(None) 

257 and isinstance(arg, type) 

258 and issubclass(arg, DomainModel) 

259 ): 

260 model_class = arg 

261 break 

262 

263 if isinstance(model_class, type) and issubclass(model_class, DomainModel): 

264 nested_schema = self.from_pydantic(model_class) 

265 

266 related_resource = None 

267 related_field = None 

268 if field_type == FieldType.BELONGS_TO and name.endswith("_id"): 

269 related_resource = name[:-3] + "s" 

270 elif field_type == FieldType.HAS_MANY: 

271 related_field = None 

272 

273 return FieldSchema( 

274 name=name, 

275 label=label, 

276 type=field_type, 

277 required=is_required, 

278 default=field_info.default 

279 if field_info.default is not PydanticUndefined 

280 else None, 

281 help_text=field_info.description, 

282 nested_schema=nested_schema, 

283 related_resource=related_resource, 

284 related_field=related_field, 

285 ) 

286 

287 def _parse_dataclass_field( 

288 self, 

289 name: str, 

290 annotation: Any, 

291 dc_field: Any, 

292 meta: dict, 

293 ) -> FieldSchema: 

294 """Parse a stdlib dataclass field into a ``FieldSchema``.""" 

295 import dataclasses 

296 

297 field_type = self._map_type(name, annotation) 

298 label = meta.get("title") or name.replace("_", " ").title() 

299 description = meta.get("description") 

300 is_required = ( 

301 dc_field.default is dataclasses.MISSING 

302 and dc_field.default_factory is dataclasses.MISSING 

303 ) 

304 default = ( 

305 None 

306 if is_required 

307 else ( 

308 dc_field.default 

309 if dc_field.default is not dataclasses.MISSING 

310 else None 

311 ) 

312 ) 

313 

314 nested_schema = None 

315 if field_type == FieldType.NESTED: 

316 origin = get_origin(annotation) 

317 args = get_args(annotation) 

318 model_class = annotation 

319 _union_types = (Union, _builtin_types.UnionType) 

320 if origin in _union_types: 

321 for arg in args: 

322 if ( 

323 arg is not type(None) 

324 and isinstance(arg, type) 

325 and hasattr(arg, "__dataclass_fields__") 

326 ): 

327 model_class = arg 

328 break 

329 if isinstance(model_class, type) and hasattr( 

330 model_class, "__dataclass_fields__" 

331 ): 

332 nested_schema = self.from_pydantic(model_class) 

333 

334 related_resource = None 

335 related_field = None 

336 if field_type == FieldType.BELONGS_TO and name.endswith("_id"): 

337 related_resource = name[:-3] + "s" 

338 elif field_type == FieldType.HAS_MANY: 

339 related_field = None 

340 

341 return FieldSchema( 

342 name=name, 

343 label=label, 

344 type=field_type, 

345 required=is_required, 

346 default=default, 

347 help_text=description, 

348 nested_schema=nested_schema, 

349 related_resource=related_resource, 

350 related_field=related_field, 

351 ) 

352 

353 def _map_type(self, field_name: str, annotation: Any) -> FieldType: 

354 # Detect belongs-to FK: field ends with _id 

355 if field_name.endswith("_id"): 

356 return FieldType.BELONGS_TO 

357 

358 origin = get_origin(annotation) 

359 args = get_args(annotation) 

360 

361 # Detect has-many: list of domain models 

362 if origin is list and args: 

363 inner = args[0] 

364 if isinstance(inner, type) and ( 

365 hasattr(inner, "model_fields") or hasattr(inner, "__dataclass_fields__") 

366 ): 

367 return FieldType.HAS_MANY 

368 

369 # Detect polymorphic: Optional[Union[TypeA, TypeB]] 

370 _union_types = (Union, _builtin_types.UnionType) 

371 if origin in _union_types: 

372 inner_types = [t for t in args if t is not type(None)] 

373 non_primitive = [ 

374 t 

375 for t in inner_types 

376 if isinstance(t, type) and hasattr(t, "__dataclass_fields__") 

377 ] 

378 if len(non_primitive) >= 2: 

379 return FieldType.MORPH 

380 

381 # Original mapping logic follows (unchanged) 

382 if origin in _union_types: 

383 for arg in args: 

384 if arg is not type(None): 

385 return self._map_type(field_name, arg) 

386 return FieldType.TEXT 

387 if annotation is str: 

388 return FieldType.TEXT 

389 if annotation is int or annotation is float: 

390 return FieldType.NUMBER 

391 if annotation is bool: 

392 return FieldType.CHECKBOX 

393 from datetime import date, datetime 

394 

395 if annotation is date: 

396 return FieldType.DATE 

397 if annotation is datetime: 

398 return FieldType.DATETIME 

399 if isinstance(annotation, type) and ( 

400 issubclass(annotation, DomainModel) 

401 or hasattr(annotation, "__dataclass_fields__") 

402 ): 

403 return FieldType.NESTED 

404 if origin is list: 

405 return FieldType.LIST 

406 return FieldType.TEXT 

407 

408 

409def build_form(**fields) -> Form[Any]: 

410 """Build a simple form dynamically from keyword field configs.""" 

411 from lexigram.admin.forms.builder import FormBuilder 

412 

413 builder = FormBuilder.create() 

414 for name, config in fields.items(): 

415 builder.text(name, label=config.get("label")) 

416 return builder.build()