Coverage for src/dataknobs_fsm/functions/library/validators.py: 0%
206 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-20 16:46 -0600
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-20 16:46 -0600
1"""Built-in validator functions for FSM.
3This module provides commonly used validation functions that can be
4referenced in FSM configurations.
5"""
7import re
8from typing import Any, Dict, List, Union
10from pydantic import BaseModel, ValidationError
12from dataknobs_fsm.functions.base import IValidationFunction, ValidationError as FSMValidationError
15class RequiredFieldsValidator(IValidationFunction):
16 """Validate that required fields are present in data."""
18 def __init__(self, fields: List[str], allow_none: bool = False):
19 """Initialize the validator.
21 Args:
22 fields: List of required field names.
23 allow_none: Whether to allow None values for required fields.
24 """
25 self.fields = fields
26 self.allow_none = allow_none
28 def validate(self, data: Dict[str, Any]) -> bool:
29 """Validate that all required fields are present.
31 Args:
32 data: Data to validate.
34 Returns:
35 True if valid, False otherwise.
37 Raises:
38 FSMValidationError: If validation fails with details.
39 """
40 if not isinstance(data, dict):
41 raise FSMValidationError(f"Expected dict, got {type(data).__name__}")
43 missing_fields = []
44 none_fields = []
46 for field in self.fields:
47 if field not in data:
48 missing_fields.append(field)
49 elif not self.allow_none and data[field] is None:
50 none_fields.append(field)
52 if missing_fields:
53 raise FSMValidationError(
54 f"Missing required fields: {', '.join(missing_fields)}"
55 )
57 if none_fields:
58 raise FSMValidationError(
59 f"Fields cannot be None: {', '.join(none_fields)}"
60 )
62 return True
65class SchemaValidator(IValidationFunction):
66 """Validate data against a Pydantic schema."""
68 def __init__(self, schema: Union[type[BaseModel], Dict[str, Any]]):
69 """Initialize the validator.
71 Args:
72 schema: Pydantic model class or schema dictionary.
73 """
74 if isinstance(schema, dict):
75 # Create a dynamic Pydantic model from dictionary
76 from pydantic import create_model
77 self.schema = create_model("DynamicSchema", **schema)
78 else:
79 self.schema = schema
81 def validate(self, data: Dict[str, Any]) -> bool:
82 """Validate data against the schema.
84 Args:
85 data: Data to validate.
87 Returns:
88 True if valid, False otherwise.
90 Raises:
91 FSMValidationError: If validation fails with details.
92 """
93 try:
94 self.schema(**data)
95 return True
96 except ValidationError as e:
97 errors = []
98 for error in e.errors():
99 field_path = ".".join(str(loc) for loc in error["loc"])
100 errors.append(f"{field_path}: {error['msg']}")
102 raise FSMValidationError(
103 f"Schema validation failed: {'; '.join(errors)}"
104 ) from e
106 def get_validation_rules(self) -> Dict[str, Any]:
107 """Get the validation rules."""
108 if hasattr(self.schema, 'model_json_schema'):
109 return self.schema.model_json_schema()
110 elif hasattr(self.schema, '__annotations__'):
111 return dict(self.schema.__annotations__)
112 else:
113 return {"schema": str(self.schema)}
116class RangeValidator(IValidationFunction):
117 """Validate that numeric values are within specified ranges."""
119 def __init__(
120 self,
121 field_ranges: Dict[str, Dict[str, Union[int, float]]],
122 ):
123 """Initialize the validator.
125 Args:
126 field_ranges: Dictionary mapping field names to range specifications.
127 Each range can have 'min', 'max', or both.
128 """
129 self.field_ranges = field_ranges
131 def validate(self, data: Dict[str, Any]) -> bool:
132 """Validate that values are within specified ranges.
134 Args:
135 data: Data to validate.
137 Returns:
138 True if valid, False otherwise.
140 Raises:
141 FSMValidationError: If validation fails with details.
142 """
143 errors = []
145 for field, range_spec in self.field_ranges.items():
146 if field not in data:
147 continue
149 value = data[field]
150 if not isinstance(value, (int, float)):
151 errors.append(f"{field}: Expected numeric value, got {type(value).__name__}")
152 continue
154 if "min" in range_spec and value < range_spec["min"]:
155 errors.append(f"{field}: Value {value} is below minimum {range_spec['min']}")
157 if "max" in range_spec and value > range_spec["max"]:
158 errors.append(f"{field}: Value {value} is above maximum {range_spec['max']}")
160 if errors:
161 raise FSMValidationError("; ".join(errors))
163 return True
166class PatternValidator(IValidationFunction):
167 """Validate that string values match specified patterns."""
169 def __init__(
170 self,
171 field_patterns: Dict[str, str],
172 flags: int = 0,
173 ):
174 """Initialize the validator.
176 Args:
177 field_patterns: Dictionary mapping field names to regex patterns.
178 flags: Regex flags to apply (e.g., re.IGNORECASE).
179 """
180 self.field_patterns = {}
181 for field, pattern in field_patterns.items():
182 self.field_patterns[field] = re.compile(pattern, flags)
184 def validate(self, data: Dict[str, Any]) -> bool:
185 """Validate that values match specified patterns.
187 Args:
188 data: Data to validate.
190 Returns:
191 True if valid, False otherwise.
193 Raises:
194 FSMValidationError: If validation fails with details.
195 """
196 errors = []
198 for field, pattern in self.field_patterns.items():
199 if field not in data:
200 continue
202 value = data[field]
203 if not isinstance(value, str):
204 errors.append(f"{field}: Expected string value, got {type(value).__name__}")
205 continue
207 if not pattern.match(value):
208 errors.append(f"{field}: Value '{value}' does not match pattern")
210 if errors:
211 raise FSMValidationError("; ".join(errors))
213 return True
216class TypeValidator(IValidationFunction):
217 """Validate that fields have expected types."""
219 def __init__(
220 self,
221 field_types: Dict[str, Union[type, List[type]]],
222 strict: bool = False,
223 ):
224 """Initialize the validator.
226 Args:
227 field_types: Dictionary mapping field names to expected types.
228 strict: If True, reject extra fields not in field_types.
229 """
230 self.field_types = field_types
231 self.strict = strict
233 def validate(self, data: Dict[str, Any]) -> bool:
234 """Validate that fields have expected types.
236 Args:
237 data: Data to validate.
239 Returns:
240 True if valid, False otherwise.
242 Raises:
243 FSMValidationError: If validation fails with details.
244 """
245 errors = []
247 # Check field types
248 for field, expected_type in self.field_types.items():
249 if field not in data:
250 continue
252 value = data[field]
253 if isinstance(expected_type, list):
254 # Multiple allowed types
255 if not any(isinstance(value, t) for t in expected_type):
256 type_names = ", ".join(t.__name__ for t in expected_type)
257 errors.append(
258 f"{field}: Expected one of [{type_names}], "
259 f"got {type(value).__name__}"
260 )
261 else:
262 # Single expected type
263 if not isinstance(value, expected_type):
264 errors.append(
265 f"{field}: Expected {expected_type.__name__}, "
266 f"got {type(value).__name__}"
267 )
269 # Check for extra fields if strict mode
270 if self.strict:
271 extra_fields = set(data.keys()) - set(self.field_types.keys())
272 if extra_fields:
273 errors.append(f"Unexpected fields: {', '.join(extra_fields)}")
275 if errors:
276 raise FSMValidationError("; ".join(errors))
278 return True
281class LengthValidator(IValidationFunction):
282 """Validate that collections have expected lengths."""
284 def __init__(
285 self,
286 field_lengths: Dict[str, Dict[str, int]],
287 ):
288 """Initialize the validator.
290 Args:
291 field_lengths: Dictionary mapping field names to length specifications.
292 Each spec can have 'min', 'max', or 'exact'.
293 """
294 self.field_lengths = field_lengths
296 def validate(self, data: Dict[str, Any]) -> bool:
297 """Validate that collections have expected lengths.
299 Args:
300 data: Data to validate.
302 Returns:
303 True if valid, False otherwise.
305 Raises:
306 FSMValidationError: If validation fails with details.
307 """
308 errors = []
310 for field, length_spec in self.field_lengths.items():
311 if field not in data:
312 continue
314 value = data[field]
315 if not hasattr(value, "__len__"):
316 errors.append(f"{field}: Value does not have a length")
317 continue
319 length = len(value)
321 if "exact" in length_spec and length != length_spec["exact"]:
322 errors.append(
323 f"{field}: Length {length} does not match expected {length_spec['exact']}"
324 )
326 if "min" in length_spec and length < length_spec["min"]:
327 errors.append(f"{field}: Length {length} is below minimum {length_spec['min']}")
329 if "max" in length_spec and length > length_spec["max"]:
330 errors.append(f"{field}: Length {length} is above maximum {length_spec['max']}")
332 if errors:
333 raise FSMValidationError("; ".join(errors))
335 return True
338class UniqueValidator(IValidationFunction):
339 """Validate that values in collections are unique."""
341 def __init__(
342 self,
343 fields: List[str],
344 key: str | None = None,
345 ):
346 """Initialize the validator.
348 Args:
349 fields: List of field names to check for uniqueness.
350 key: Optional key to extract from collection items for uniqueness check.
351 """
352 self.fields = fields
353 self.key = key
355 def validate(self, data: Dict[str, Any]) -> bool:
356 """Validate that values are unique.
358 Args:
359 data: Data to validate.
361 Returns:
362 True if valid, False otherwise.
364 Raises:
365 FSMValidationError: If validation fails with details.
366 """
367 errors = []
369 for field in self.fields:
370 if field not in data:
371 continue
373 value = data[field]
374 if not isinstance(value, (list, tuple, set)):
375 errors.append(f"{field}: Expected collection, got {type(value).__name__}")
376 continue
378 if self.key:
379 # Extract values using key
380 try:
381 values = [item[self.key] if isinstance(item, dict) else getattr(item, self.key)
382 for item in value]
383 except (KeyError, AttributeError) as e:
384 errors.append(f"{field}: Cannot extract key '{self.key}': {e}")
385 continue
386 else:
387 values = list(value)
389 # Check for duplicates
390 seen = set()
391 duplicates = set()
392 for v in values:
393 if v in seen:
394 duplicates.add(str(v))
395 seen.add(v)
397 if duplicates:
398 errors.append(f"{field}: Duplicate values found: {', '.join(duplicates)}")
400 if errors:
401 raise FSMValidationError("; ".join(errors))
403 return True
406class DependencyValidator(IValidationFunction):
407 """Validate field dependencies (if field A exists, field B must also exist)."""
409 def __init__(
410 self,
411 dependencies: Dict[str, Union[str, List[str]]],
412 ):
413 """Initialize the validator.
415 Args:
416 dependencies: Dictionary mapping field names to their dependencies.
417 """
418 self.dependencies = dependencies
420 def validate(self, data: Dict[str, Any]) -> bool:
421 """Validate field dependencies.
423 Args:
424 data: Data to validate.
426 Returns:
427 True if valid, False otherwise.
429 Raises:
430 FSMValidationError: If validation fails with details.
431 """
432 errors = []
434 for field, deps in self.dependencies.items():
435 if field not in data:
436 continue
438 deps_list = deps if isinstance(deps, list) else [deps]
440 missing_deps = [dep for dep in deps_list if dep not in data]
442 if missing_deps:
443 errors.append(
444 f"Field '{field}' requires: {', '.join(missing_deps)}"
445 )
447 if errors:
448 raise FSMValidationError("; ".join(errors))
450 return True
453class CompositeValidator(IValidationFunction):
454 """Compose multiple validators into a single validator."""
456 def __init__(
457 self,
458 validators: List[IValidationFunction],
459 stop_on_first_error: bool = False,
460 ):
461 """Initialize the composite validator.
463 Args:
464 validators: List of validators to apply.
465 stop_on_first_error: If True, stop at first validation error.
466 """
467 self.validators = validators
468 self.stop_on_first_error = stop_on_first_error
470 def validate(self, data: Dict[str, Any]) -> bool:
471 """Apply all validators to the data.
473 Args:
474 data: Data to validate.
476 Returns:
477 True if all validators pass.
479 Raises:
480 FSMValidationError: If any validation fails.
481 """
482 errors = []
484 for validator in self.validators:
485 try:
486 validator.validate(data)
487 except FSMValidationError as e:
488 if self.stop_on_first_error:
489 raise
490 errors.append(str(e))
492 if errors:
493 raise FSMValidationError("; ".join(errors))
495 return True
498# Convenience functions for creating validators
499def required_fields(*fields: str, allow_none: bool = False) -> RequiredFieldsValidator:
500 """Create a RequiredFieldsValidator."""
501 return RequiredFieldsValidator(list(fields), allow_none)
504def schema(model: Union[type[BaseModel], Dict[str, Any]]) -> SchemaValidator:
505 """Create a SchemaValidator."""
506 return SchemaValidator(model)
509def range_check(**field_ranges: Dict[str, Union[int, float]]) -> RangeValidator:
510 """Create a RangeValidator."""
511 return RangeValidator(field_ranges)
514def pattern(**field_patterns: str) -> PatternValidator:
515 """Create a PatternValidator."""
516 return PatternValidator(field_patterns)
519def type_check(**field_types: Union[type, List[type]]) -> TypeValidator:
520 """Create a TypeValidator."""
521 return TypeValidator(field_types)
524def length(**field_lengths: Dict[str, int]) -> LengthValidator:
525 """Create a LengthValidator."""
526 return LengthValidator(field_lengths)
529def unique(*fields: str, key: str | None = None) -> UniqueValidator:
530 """Create a UniqueValidator."""
531 return UniqueValidator(list(fields), key)
534def depends_on(**dependencies: Union[str, List[str]]) -> DependencyValidator:
535 """Create a DependencyValidator."""
536 return DependencyValidator(dependencies)