Coverage for src/lexigram/admin/forms/validation.py: 78%

96 statements  

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

1"""Validation engine for Lexigram Admin Forms.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6from dataclasses import dataclass 

7import re 

8from typing import TYPE_CHECKING, Any, Protocol 

9 

10from lexigram.concurrency import Parallel 

11 

12if TYPE_CHECKING: 

13 from collections.abc import Callable 

14 

15 

16@dataclass 

17class ValidationError: 

18 """Represents a validation failure. 

19 

20 Attributes: 

21 message: Human-readable error message. 

22 code: Optional error code for machine consumption. 

23 """ 

24 

25 message: str 

26 code: str | None = None 

27 

28 

29ValidatorResult = str | list[str] | ValidationError | list[ValidationError] | None 

30 

31 

32class Validator(Protocol): 

33 """Protocol for form validators. 

34 

35 Can be a simple function or a class with __call__. 

36 Supports both sync and async execution. 

37 """ 

38 

39 async def __call__( 

40 self, 

41 value: Any, 

42 context: dict[str, Any], 

43 ) -> ValidatorResult: ... 

44 

45 

46class FormValidationEngine: 

47 """Engine for executing complex form validation logic. 

48 

49 Supports field-level validators and form-level (cross-field) validators. 

50 """ 

51 

52 def __init__(self) -> None: 

53 """Initialize empty validator maps.""" 

54 self._field_validators: dict[str, list[Validator]] = {} 

55 self._form_validators: list[Validator] = [] 

56 

57 def add_field_validator(self, field_name: str, validator: Validator) -> None: 

58 """Add a validator to a specific field.""" 

59 if field_name not in self._field_validators: 

60 self._field_validators[field_name] = [] 

61 self._field_validators[field_name].append(validator) 

62 

63 def add_form_validator(self, validator: Validator) -> None: 

64 """Add a validator for the entire form (cross-field).""" 

65 self._form_validators.append(validator) 

66 

67 async def validate_field( 

68 self, 

69 field_name: str, 

70 value: Any, 

71 data: dict[str, Any], 

72 ) -> list[ValidationError]: 

73 """Validate a single field's value.""" 

74 validators = self._field_validators.get(field_name, []) 

75 errors = [] 

76 

77 for validator in validators: 

78 result = await self._run_validator(validator, value, data) 

79 if result: 

80 errors.extend(self._normalize_errors(result)) 

81 

82 return errors 

83 

84 async def validate_form( 

85 self, 

86 data: dict[str, Any], 

87 ) -> dict[str, list[ValidationError]]: 

88 """Validate an entire form data set. 

89 

90 Runs all field validators and then form-level validators. 

91 """ 

92 all_errors: dict[str, list[ValidationError]] = {} 

93 

94 # 1. Field-level validation 

95 field_tasks = [] 

96 field_names = list(set(list(self._field_validators.keys()) + list(data.keys()))) 

97 

98 for name in field_names: 

99 val = data.get(name) 

100 field_tasks.append(self.validate_field(name, val, data)) 

101 

102 field_results = await Parallel.gather(*field_tasks) 

103 all_errors = { 

104 name: errors 

105 for name, errors in zip(field_names, field_results, strict=False) 

106 if errors 

107 } 

108 

109 # 2. Form-level (cross-field) validation 

110 form_tasks = [self._run_validator(v, None, data) for v in self._form_validators] 

111 form_results = await Parallel.gather(*form_tasks) 

112 

113 for result in form_results: 

114 if result: 

115 # Form level errors usually apply to the whole form or multiple fields. 

116 # If the result is a dict, we map to fields, otherwise to "__all__". 

117 if isinstance(result, dict): 

118 for field_name, field_errors in result.items(): 

119 if field_name not in all_errors: 

120 all_errors[field_name] = [] 

121 all_errors[field_name].extend( 

122 self._normalize_errors(field_errors), 

123 ) 

124 else: 

125 if "__all__" not in all_errors: 

126 all_errors["__all__"] = [] 

127 all_errors["__all__"].extend(self._normalize_errors(result)) 

128 

129 return all_errors 

130 

131 async def _run_validator( 

132 self, 

133 validator: Any, 

134 value: Any, 

135 data: dict[str, Any], 

136 ) -> Any: 

137 """Execute a validator regardless of whether it's sync or async.""" 

138 if asyncio.iscoroutinefunction(validator): 

139 return await validator(value, data) 

140 if callable(validator): 

141 # Check if it's a class instance with an async __call__ 

142 if callable(validator) and asyncio.iscoroutinefunction( 

143 validator.__call__, 

144 ): 

145 return await validator(value, data) 

146 return validator(value, data) 

147 return None 

148 

149 def _normalize_errors(self, result: ValidatorResult) -> list[ValidationError]: 

150 """Convert various validator return types into a standard list of errors.""" 

151 if result is None: 

152 return [] 

153 if isinstance(result, str): 

154 return [ValidationError(result)] 

155 if isinstance(result, ValidationError): 

156 return [result] 

157 if isinstance(result, list): 

158 normalized = [] 

159 for item in result: 

160 normalized.extend(self._normalize_errors(item)) 

161 return normalized 

162 return [] 

163 

164 

165# Standard Validators 

166 

167 

168def required(value: Any, _: dict[str, Any]) -> ValidatorResult: 

169 """Verify that a value is not empty.""" 

170 if value is None or (isinstance(value, str) and not value.strip()): 

171 return "This field is required." 

172 return None 

173 

174 

175def email(value: Any, _: dict[str, Any]) -> ValidatorResult: 

176 """Verify that a value is a valid email address.""" 

177 if not value: 

178 return None 

179 pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" 

180 if not re.match(pattern, str(value)): 

181 return "Please enter a valid email address." 

182 return None 

183 

184 

185def min_length(length: int) -> Callable: 

186 """Factory for minimum length validation.""" 

187 

188 def validator(value: Any, _: dict[str, Any]) -> ValidatorResult: 

189 if value and len(str(value)) < length: 

190 return f"Must be at least {length} characters long." 

191 return None 

192 

193 return validator 

194 

195 

196def max_length(length: int) -> Callable: 

197 """Factory for maximum length validation.""" 

198 

199 def validator(value: Any, _: dict[str, Any]) -> ValidatorResult: 

200 if value and len(str(value)) > length: 

201 return f"Cannot exceed {length} characters." 

202 return None 

203 

204 return validator