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

1"""Form-data coercion and validation-error helpers for admin resources.""" 

2 

3from __future__ import annotations 

4 

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 

12 

13 

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 

22 

23 

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 

32 

33 for key, value in list(data.items()): 

34 if key not in hints: 

35 continue 

36 if not isinstance(value, str): 

37 continue 

38 

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 

112 

113 data[key] = _json_loads(value) 

114 except Exception: # noqa: S110 — intentional best-effort fallback 

115 pass 

116 

117 return data 

118 

119 

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 

126 

127 

128__all__ = [ 

129 "_coerce_form_data", 

130 "_unwrap_optional", 

131 "_validation_errors_to_dict", 

132]