Coverage for src/lexigram/admin/forms/builder.py: 74%

247 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 14:56 +0800

1"""Form builder with Pydantic integration. 

2 

3Provides a declarative API for building forms from Pydantic models 

4with automatic validation, HTMX integration, and customization. 

5""" 

6 

7from __future__ import annotations 

8 

9from dataclasses import dataclass 

10from dataclasses import field as dataclass_field 

11from datetime import date, datetime 

12from enum import Enum 

13from typing import ( 

14 TYPE_CHECKING, 

15 Any, 

16 Generic, 

17 TypeVar, 

18 get_args, 

19 get_origin, 

20 get_type_hints, 

21) 

22 

23from lexigram.admin.schema import ( 

24 BooleanField, 

25 DateField, 

26 DateTimeField, 

27 EmailField, 

28 EnumField, 

29 IntegerField, 

30 PasswordField, 

31 SchemaField, 

32 SelectField, 

33 TextAreaField, 

34 TextField, 

35) 

36from lexigram.domain import DomainModel 

37from lexigram.logging import get_logger 

38from lexigram.primitives.builder import AbstractBuilder 

39from lexigram.ui import el, render_to_string 

40 

41if TYPE_CHECKING: 

42 from collections.abc import Callable 

43 

44logger = get_logger(__name__) 

45 

46T = TypeVar("T", bound=DomainModel) 

47 

48 

49@dataclass 

50class FieldConfig: 

51 """Configuration for a form field.""" 

52 

53 name: str 

54 label: str | None = None 

55 help_text: str | None = None 

56 placeholder: str | None = None 

57 required: bool = True 

58 disabled: bool = False 

59 hidden: bool = False 

60 widget: str | None = None # Override default widget 

61 order: int = 100 

62 group: str | None = None 

63 col_span: int = 1 # For grid layouts 

64 validators: list[Callable] = dataclass_field(default_factory=list) 

65 props: dict[str, Any] = dataclass_field(default_factory=dict) 

66 

67 

68class FormBuilder(AbstractBuilder["Form[T]"], Generic[T]): 

69 """Build forms from Pydantic models. 

70 

71 Usage: 

72 @dataclass(init=False) 

73 class UserForm(DomainModel): 

74 name: str 

75 email: str 

76 age: int 

77 

78 form = (FormBuilder(UserForm) 

79 .field("name", label="Full Name", placeholder="Enter your name") 

80 .field("email", widget="email") 

81 .field("age", help_text="Must be 18 or older") 

82 .exclude("password") 

83 .build()) 

84 """ 

85 

86 def __init__(self, model: type[T]): 

87 super().__init__() 

88 self.model = model 

89 self._field_configs: dict[str, FieldConfig] = {} 

90 self._excluded: set[str] = set() 

91 self._groups: dict[str, list[str]] = {} 

92 self._layout: str = "vertical" # "vertical", "horizontal", "grid" 

93 self._columns: int = 1 

94 self._submit_label: str = "Submit" 

95 self._cancel_url: str | None = None 

96 

97 self._form_class: type | None = None 

98 

99 # Extract fields from model 

100 self._init_from_model() 

101 

102 def _init_from_model(self) -> None: 

103 """Initialize field configs from Pydantic model.""" 

104 hints = get_type_hints(self.model) 

105 model_fields = self.model.model_fields # type: ignore[attr-defined] 

106 

107 for name, field_info in model_fields.items(): 

108 hints.get(name, str) 

109 

110 # Determine if required 

111 required = field_info.is_required() 

112 

113 # Generate label from name 

114 label = name.replace("_", " ").title() 

115 

116 # Get help text from description 

117 help_text = field_info.description 

118 

119 # Determine default 

120 

121 self._field_configs[name] = FieldConfig( 

122 name=name, 

123 label=label, 

124 help_text=help_text, 

125 required=required, 

126 ) 

127 

128 def field( 

129 self, 

130 name: str, 

131 *, 

132 label: str | None = None, 

133 help_text: str | None = None, 

134 placeholder: str | None = None, 

135 required: bool | None = None, 

136 disabled: bool = False, 

137 hidden: bool = False, 

138 widget: str | None = None, 

139 order: int | None = None, 

140 group: str | None = None, 

141 col_span: int = 1, 

142 validators: list[Callable] | None = None, 

143 **props: Any, 

144 ) -> FormBuilder[T]: 

145 """Configure a field.""" 

146 if name not in self._field_configs: 

147 raise ValueError(f"Unknown field: {name}") 

148 

149 config = self._field_configs[name] 

150 

151 if label is not None: 

152 config.label = label 

153 if help_text is not None: 

154 config.help_text = help_text 

155 if placeholder is not None: 

156 config.placeholder = placeholder 

157 if required is not None: 

158 config.required = required 

159 if disabled: 

160 config.disabled = disabled 

161 if hidden: 

162 config.hidden = hidden 

163 if widget is not None: 

164 config.widget = widget 

165 if order is not None: 

166 config.order = order 

167 if group is not None: 

168 config.group = group 

169 if group not in self._groups: 

170 self._groups[group] = [] 

171 if name not in self._groups[group]: 

172 self._groups[group].append(name) 

173 if validators: 

174 config.validators.extend(validators) 

175 

176 config.col_span = col_span 

177 config.props.update(props) 

178 

179 return self 

180 

181 def exclude(self, *fields: str) -> FormBuilder[T]: 

182 """Exclude fields from the form.""" 

183 self._excluded.update(fields) 

184 return self 

185 

186 def include_only(self, *fields: str) -> FormBuilder[T]: 

187 """Include only specified fields.""" 

188 all_fields = set(self._field_configs.keys()) 

189 self._excluded = all_fields - set(fields) 

190 return self 

191 

192 def group( 

193 self, 

194 name: str, 

195 *fields: str, 

196 label: str | None = None, 

197 ) -> FormBuilder[T]: 

198 """Group fields together.""" 

199 self._groups[name] = list(fields) 

200 for field_name in fields: 

201 if field_name in self._field_configs: 

202 self._field_configs[field_name].group = name 

203 return self 

204 

205 def layout(self, layout: str, columns: int = 1) -> FormBuilder[T]: 

206 """Set form layout.""" 

207 self._layout = layout 

208 self._columns = columns 

209 return self 

210 

211 def submit_label(self, label: str) -> FormBuilder[T]: 

212 """Set submit button label.""" 

213 self._submit_label = label 

214 return self 

215 

216 def cancel_url(self, url: str) -> FormBuilder[T]: 

217 """Set cancel button URL.""" 

218 self._cancel_url = url 

219 return self 

220 

221 @classmethod 

222 def create(cls, form_class: type | None = None) -> FormBuilder: 

223 """Create a fluent FormBuilder without a Pydantic model. 

224 

225 Supports the create()/text()/build() API for lightweight dynamic forms. 

226 """ 

227 builder: FormBuilder = cls.__new__(cls) 

228 super(FormBuilder, builder).__init__() 

229 builder.model = None # type: ignore[assignment] 

230 builder._field_configs = {} 

231 builder._excluded = set() 

232 builder._groups = {} 

233 builder._layout = "vertical" 

234 builder._columns = 1 

235 builder._submit_label = "Submit" 

236 builder._cancel_url = None 

237 builder._form_class = form_class 

238 return builder 

239 

240 def text(self, name: str, **kwargs: Any) -> FormBuilder[T]: 

241 """Add a text field to the form (fluent API).""" 

242 label: str = kwargs.get("label") or name.replace("_", " ").title() 

243 self._field_configs[name] = FieldConfig( 

244 name=name, 

245 label=label, 

246 help_text=kwargs.get("help_text"), 

247 placeholder=kwargs.get("placeholder"), 

248 required=kwargs.get("required", True), 

249 ) 

250 return self 

251 

252 def build(self) -> Form[T]: 

253 """Build the form.""" 

254 fields: list[SchemaField] = [] 

255 hints = get_type_hints(self.model) if self.model is not None else {} 

256 

257 # Sort by order 

258 sorted_configs = sorted(self._field_configs.items(), key=lambda x: x[1].order) 

259 

260 for name, config in sorted_configs: 

261 if name in self._excluded or config.hidden: 

262 continue 

263 

264 field_type = hints.get(name, str) 

265 field = self._create_field(name, field_type, config) 

266 fields.append(field) 

267 

268 return Form( 

269 model=self.model, 

270 fields=fields, 

271 groups=self._groups, 

272 layout=self._layout, 

273 columns=self._columns, 

274 submit_label=self._submit_label, 

275 cancel_url=self._cancel_url, 

276 ) 

277 

278 def _create_field( 

279 self, 

280 name: str, 

281 field_type: type, 

282 config: FieldConfig, 

283 ) -> SchemaField: 

284 """Create a SchemaField instance from type and config.""" 

285 # Handle widget override 

286 if config.widget: 

287 return self._create_field_by_widget(name, config) 

288 

289 # Handle Optional types 

290 origin = get_origin(field_type) 

291 if origin is type(None) or str(origin) == "typing.Union": 

292 args = get_args(field_type) 

293 # Get the non-None type 

294 for arg in args: 

295 if arg is not type(None): 

296 field_type = arg 

297 config.required = False 

298 break 

299 

300 common: dict[str, Any] = { 

301 "name": name, 

302 "label": config.label, 

303 "help_text": config.help_text, 

304 "required": config.required, 

305 "readonly": config.disabled, 

306 } 

307 

308 # Map Python types to schema field classes 

309 if field_type is str: 

310 return TextField(**common) 

311 if field_type is int: 

312 return IntegerField(**common) 

313 if field_type is bool: 

314 return BooleanField(**common) 

315 if field_type is date: 

316 return DateField(**common) 

317 if field_type is datetime: 

318 return DateTimeField(**common) 

319 if isinstance(field_type, type) and issubclass(field_type, Enum): 

320 return EnumField( 

321 **common, 

322 enum_cls=field_type, 

323 ) 

324 # Default to text 

325 return TextField(**common) 

326 

327 def _create_field_by_widget(self, name: str, config: FieldConfig) -> SchemaField: 

328 """Create field by widget name.""" 

329 widget = config.widget 

330 common: dict[str, Any] = { 

331 "name": name, 

332 "label": config.label, 

333 "help_text": config.help_text, 

334 "required": config.required, 

335 "readonly": config.disabled, 

336 } 

337 if widget == "textarea": 

338 return TextAreaField(**common) 

339 if widget == "email": 

340 return EmailField(**common) 

341 if widget == "password": 

342 return PasswordField(**common) 

343 if widget == "switch": 

344 return BooleanField(**common) 

345 if widget == "select": 

346 options = config.props.get("options", []) 

347 return SelectField(**common, options=options) 

348 return TextField(**common) 

349 

350 

351@dataclass 

352class FormResult(Generic[T]): 

353 """Result of form validation.""" 

354 

355 success: bool 

356 data: T | None = None 

357 errors: dict[str, list[str]] = dataclass_field(default_factory=dict) 

358 

359 @property 

360 def is_valid(self) -> bool: 

361 return self.success and not self.errors 

362 

363 

364class Form(Generic[T]): 

365 """A built form ready for rendering and validation.""" 

366 

367 def __init__( 

368 self, 

369 model: type[T], 

370 fields: list[SchemaField], 

371 groups: dict[str, list[str]], 

372 layout: str, 

373 columns: int, 

374 submit_label: str, 

375 cancel_url: str | None, 

376 ): 

377 self.model = model 

378 self.fields = {f.name: f for f in fields} 

379 self.field_list = fields 

380 self.groups = groups 

381 self.layout = layout 

382 self.columns = columns 

383 self.submit_label = submit_label 

384 self.cancel_url = cancel_url 

385 self.values: dict[str, Any] = {} 

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

387 

388 def bind(self, data: dict[str, Any]) -> Form[T]: 

389 """Bind data to form values.""" 

390 for name in self.fields: 

391 if name in data: 

392 self.values[name] = data[name] 

393 return self 

394 

395 async def validate(self, data: dict[str, Any]) -> FormResult[T]: 

396 """Validate form data against model.""" 

397 errors: dict[str, list[str]] = {} 

398 cleaned: dict[str, Any] = {} 

399 

400 # Run field-level validation first 

401 for name, field in self.fields.items(): 

402 if name not in data: 

403 continue 

404 raw = data.get(name) 

405 raw = raw if raw is None or isinstance(raw, str) else str(raw) 

406 result = field.from_form(raw) 

407 if result.is_err(): 

408 message = str(result.unwrap_err()) 

409 errors.setdefault(name, []).append(message) 

410 self.errors[name] = [message] 

411 else: 

412 value = result.unwrap() 

413 if field.required and ( 

414 value is None or (isinstance(value, str) and not value) 

415 ): 

416 message = "This field is required." 

417 errors.setdefault(name, []).append(message) 

418 self.errors[name] = [message] 

419 else: 

420 cleaned[name] = value 

421 

422 # If field validation passed, try Pydantic model validation 

423 if not errors and self.model is not None: 

424 try: 

425 instance = self.model.model_validate(cleaned) 

426 return FormResult(success=True, data=instance) 

427 except (ValueError, TypeError, AttributeError) as e: 

428 if hasattr(e, "errors"): 

429 for error in e.errors(): 

430 field_name = ( 

431 str(error["loc"][0]) if error["loc"] else "__root__" 

432 ) 

433 message = error["msg"] 

434 errors.setdefault(field_name, []).append(message) 

435 self.errors[field_name] = [message] 

436 else: 

437 message = str(e) 

438 errors.setdefault("__root__", []).append(message) 

439 self.errors["__root__"] = [message] 

440 

441 return FormResult(success=False, errors=errors) 

442 

443 def _render_field_els(self) -> list[Any]: 

444 """Render all field elements with current bound values.""" 

445 return [ 

446 el( 

447 "div", 

448 field.render_form(self.values.get(name)), 

449 class_="form-field", 

450 ) 

451 for name, field in self.fields.items() 

452 ] 

453 

454 def render_html(self, action: str, method: str = "POST") -> str: 

455 """Render form as HTML.""" 

456 field_els = self._render_field_els() 

457 btns: list[Any] = [ 

458 el("button", self.submit_label, type="submit", class_="btn btn-primary") 

459 ] 

460 if self.cancel_url: 

461 btns.append( 

462 el("a", "Cancel", href=self.cancel_url, class_="btn btn-secondary") 

463 ) 

464 return render_to_string( 

465 el( 

466 "form", 

467 el( 

468 "div", 

469 *field_els, 

470 class_="form-fields", 

471 style=f"display:grid;grid-template-columns:repeat({self.columns},1fr);gap:1rem", 

472 ), 

473 el("div", *btns, class_="form-actions", style="margin-top:1.5rem"), 

474 action=action, 

475 method=method, 

476 class_=f"admin-form layout-{self.layout}", 

477 ) 

478 ) 

479 

480 def render_htmx( 

481 self, 

482 action: str, 

483 target: str = "#form-result", 

484 swap: str = "innerHTML", 

485 ) -> str: 

486 """Render form with HTMX attributes.""" 

487 field_els = self._render_field_els() 

488 btns: list[Any] = [ 

489 el("button", self.submit_label, type="submit", class_="btn btn-primary") 

490 ] 

491 if self.cancel_url: 

492 btns.append( 

493 el("a", "Cancel", href=self.cancel_url, class_="btn btn-secondary") 

494 ) 

495 spinner_id = target.lstrip("#") + "-spinner" 

496 return render_to_string( 

497 el( 

498 "form", 

499 el("div", "Saving...", id=spinner_id, class_="htmx-indicator"), 

500 el( 

501 "div", 

502 *field_els, 

503 class_="form-fields", 

504 style=f"display:grid;grid-template-columns:repeat({self.columns},1fr);gap:1rem", 

505 ), 

506 el("div", *btns, class_="form-actions", style="margin-top:1.5rem"), 

507 **{ 

508 "hx-post": action, 

509 "hx-target": target, 

510 "hx-swap": swap, 

511 "hx-indicator": "#form-spinner", 

512 "class": f"admin-form layout-{self.layout}", 

513 }, 

514 ) 

515 )