Coverage for src/lexigram/admin/resources/form_coercion.py: 0%
103 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
1"""Form-data coercion and validation-error helpers for admin resources."""
3from __future__ import annotations
5from datetime import date, datetime
6from decimal import Decimal
7from decimal import InvalidOperation as DecimalInvalidOp
8from enum import Enum
9import types
10from typing import Any, Union, cast, get_args, get_origin, get_type_hints
11from uuid import UUID
14def _unwrap_optional(tp: type) -> type:
15 """Unwrap Optional[T] or T | None to T."""
16 origin = get_origin(tp)
17 if origin in (Union, types.UnionType):
18 non_none = [a for a in get_args(tp) if a is not type(None)]
19 if len(non_none) == 1:
20 return cast("type", non_none[0])
21 return tp
24def _coerce_form_data(data: dict, model: type | None) -> dict:
25 """Convert HTML form string values to proper Python types."""
26 if model is None:
27 return data
28 try:
29 hints = get_type_hints(model)
30 except Exception:
31 return data
33 for key, value in list(data.items()):
34 if key not in hints:
35 continue
36 if not isinstance(value, str):
37 continue
39 expected = _unwrap_optional(hints[key])
40 origin = get_origin(expected)
41 if expected is bool:
42 data[key] = value == "on"
43 elif expected is int:
44 try:
45 data[key] = int(value)
46 except (ValueError, TypeError):
47 pass
48 elif expected is float:
49 if value == "":
50 data[key] = None
51 else:
52 try:
53 data[key] = float(value)
54 except (ValueError, TypeError):
55 pass
56 elif expected is Decimal:
57 if value == "":
58 data[key] = None
59 else:
60 try:
61 data[key] = Decimal(value)
62 except (DecimalInvalidOp, ValueError, TypeError):
63 pass
64 elif expected is UUID:
65 if value == "":
66 data[key] = None
67 else:
68 try:
69 data[key] = UUID(value)
70 except (ValueError, AttributeError):
71 pass
72 elif expected is datetime:
73 if value == "":
74 data[key] = None
75 else:
76 try:
77 data[key] = datetime.fromisoformat(value.replace("Z", "+00:00"))
78 except (ValueError, TypeError):
79 pass
80 elif expected is date:
81 if value == "":
82 data[key] = None
83 else:
84 try:
85 data[key] = date.fromisoformat(value)
86 except (ValueError, TypeError):
87 pass
88 elif origin is list:
89 if value == "":
90 data[key] = []
91 else:
92 args = get_args(expected)
93 inner = args[0] if args else str
94 items = [s.strip() for s in value.split(",")]
95 if inner is not str:
96 try:
97 data[key] = [inner(item) for item in items]
98 except (ValueError, TypeError):
99 pass
100 else:
101 data[key] = items
102 elif isinstance(expected, type) and issubclass(expected, Enum):
103 if value != "":
104 try:
105 data[key] = expected(value)
106 except (ValueError, TypeError):
107 pass
108 elif origin is dict:
109 if value != "":
110 try:
111 from lexigram.serialization import loads_str as _json_loads
113 data[key] = _json_loads(value)
114 except Exception: # noqa: S110 — intentional best-effort fallback
115 pass
117 return data
120def _validation_errors_to_dict(error: Any) -> dict[str, list[str]]:
121 """Convert AdminValidationError.errors (list[FieldError]) to dict form."""
122 errors: dict[str, list[str]] = {}
123 for fe in error.errors:
124 errors.setdefault(fe.field, []).append(fe.message)
125 return errors
128__all__ = [
129 "_coerce_form_data",
130 "_unwrap_optional",
131 "_validation_errors_to_dict",
132]