Coverage for src/dataknobs_data/validation_v2/constraints.py: 89%
159 statements
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-15 12:29 -0500
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-15 12:29 -0500
1"""
2Constraint implementations with consistent, composable API.
3"""
5import math
6import re
7from abc import ABC, abstractmethod
8from typing import Any, List, Optional, Callable, Union, Pattern as RegexPattern
9from numbers import Number
11from .result import ValidationResult, ValidationContext
14class Constraint(ABC):
15 """Base class for all constraints with composable operators."""
17 @abstractmethod
18 def check(self, value: Any, context: Optional[ValidationContext] = None) -> ValidationResult:
19 """
20 Validate a value against this constraint.
22 Args:
23 value: Value to validate
24 context: Optional validation context for stateful constraints
26 Returns:
27 ValidationResult with validation outcome
28 """
29 pass
31 def __and__(self, other: 'Constraint') -> 'All':
32 """Combine with AND: both constraints must pass."""
33 if isinstance(self, All):
34 return All(self.constraints + [other])
35 elif isinstance(other, All):
36 return All([self] + other.constraints)
37 return All([self, other])
39 def __or__(self, other: 'Constraint') -> 'Any':
40 """Combine with OR: at least one constraint must pass."""
41 if isinstance(self, Any):
42 return Any(self.constraints + [other])
43 elif isinstance(other, Any):
44 return Any([self] + other.constraints)
45 return Any([self, other])
47 def __invert__(self) -> 'Not':
48 """Negate this constraint."""
49 return Not(self)
52class All(Constraint):
53 """All constraints must pass (AND logic)."""
55 def __init__(self, constraints: List[Constraint]):
56 """
57 Initialize with list of constraints.
59 Args:
60 constraints: List of constraints that must all pass
61 """
62 self.constraints = constraints
64 def check(self, value: Any, context: Optional[ValidationContext] = None) -> ValidationResult:
65 """Check all constraints."""
66 result = ValidationResult.success(value)
68 for constraint in self.constraints:
69 check_result = constraint.check(value, context)
70 if not check_result.valid:
71 result = result.merge(check_result)
72 # Continue checking to collect all errors
74 return result
77class Any(Constraint):
78 """At least one constraint must pass (OR logic)."""
80 def __init__(self, constraints: List[Constraint]):
81 """
82 Initialize with list of constraints.
84 Args:
85 constraints: List of constraints where at least one must pass
86 """
87 self.constraints = constraints
89 def check(self, value: Any, context: Optional[ValidationContext] = None) -> ValidationResult:
90 """Check if any constraint passes."""
91 all_errors = []
93 for constraint in self.constraints:
94 check_result = constraint.check(value, context)
95 if check_result.valid:
96 return check_result
97 all_errors.extend(check_result.errors)
99 return ValidationResult.failure(
100 value,
101 [f"None of the constraints passed: {', '.join(all_errors)}"]
102 )
105class Not(Constraint):
106 """Negates a constraint."""
108 def __init__(self, constraint: Constraint):
109 """
110 Initialize with constraint to negate.
112 Args:
113 constraint: Constraint to negate
114 """
115 self.constraint = constraint
117 def check(self, value: Any, context: Optional[ValidationContext] = None) -> ValidationResult:
118 """Check if constraint fails (negation)."""
119 result = self.constraint.check(value, context)
120 if result.valid:
121 return ValidationResult.failure(
122 value,
123 [f"Value should not satisfy constraint but it does"]
124 )
125 return ValidationResult.success(value)
128class Required(Constraint):
129 """Field must be present and non-null."""
131 def __init__(self, allow_empty: bool = False):
132 """
133 Initialize required constraint.
135 Args:
136 allow_empty: If True, empty strings/collections are allowed
137 """
138 self.allow_empty = allow_empty
140 def check(self, value: Any, context: Optional[ValidationContext] = None) -> ValidationResult:
141 """Check if value is present and non-null."""
142 if value is None:
143 return ValidationResult.failure(value, ["Value is required"])
145 if not self.allow_empty:
146 # Check for empty strings and collections
147 if isinstance(value, (str, list, dict, set, tuple)) and len(value) == 0:
148 return ValidationResult.failure(value, ["Value cannot be empty"])
150 return ValidationResult.success(value)
153class Range(Constraint):
154 """Numeric value must be in specified range."""
156 def __init__(self, min: Optional[Number] = None, max: Optional[Number] = None):
157 """
158 Initialize range constraint.
160 Args:
161 min: Minimum value (inclusive)
162 max: Maximum value (inclusive)
163 """
164 if min is not None and max is not None and min > max:
165 raise ValueError(f"min ({min}) cannot be greater than max ({max})")
166 self.min = min
167 self.max = max
169 def check(self, value: Any, context: Optional[ValidationContext] = None) -> ValidationResult:
170 """Check if value is in range."""
171 if value is None:
172 return ValidationResult.success(value) # None is considered valid (use Required to enforce)
174 if not isinstance(value, Number):
175 return ValidationResult.failure(
176 value,
177 [f"Value must be a number, got {type(value).__name__}"]
178 )
180 # Check for NaN (Not a Number) - NaN is not valid for range comparisons
181 if isinstance(value, float) and math.isnan(value):
182 return ValidationResult.failure(
183 value,
184 ["Value is NaN (Not a Number), which is not valid for range comparisons"]
185 )
187 errors = []
188 if self.min is not None and value < self.min:
189 errors.append(f"Value {value} is less than minimum {self.min}")
190 if self.max is not None and value > self.max:
191 errors.append(f"Value {value} is greater than maximum {self.max}")
193 if errors:
194 return ValidationResult.failure(value, errors)
195 return ValidationResult.success(value)
198class Length(Constraint):
199 """String/collection length must be in specified range."""
201 def __init__(self, min: Optional[int] = None, max: Optional[int] = None):
202 """
203 Initialize length constraint.
205 Args:
206 min: Minimum length (inclusive)
207 max: Maximum length (inclusive)
208 """
209 if min is not None and min < 0:
210 raise ValueError(f"min length cannot be negative: {min}")
211 if max is not None and max < 0:
212 raise ValueError(f"max length cannot be negative: {max}")
213 if min is not None and max is not None and min > max:
214 raise ValueError(f"min length ({min}) cannot be greater than max ({max})")
215 self.min = min
216 self.max = max
218 def check(self, value: Any, context: Optional[ValidationContext] = None) -> ValidationResult:
219 """Check if value length is in range."""
220 if value is None:
221 return ValidationResult.success(value)
223 if not hasattr(value, '__len__'):
224 return ValidationResult.failure(
225 value,
226 [f"Value does not have a length: {type(value).__name__}"]
227 )
229 length = len(value)
230 errors = []
232 if self.min is not None and length < self.min:
233 errors.append(f"Length {length} is less than minimum {self.min}")
234 if self.max is not None and length > self.max:
235 errors.append(f"Length {length} is greater than maximum {self.max}")
237 if errors:
238 return ValidationResult.failure(value, errors)
239 return ValidationResult.success(value)
242class Pattern(Constraint):
243 """String value must match regex pattern."""
245 def __init__(self, pattern: Union[str, RegexPattern]):
246 """
247 Initialize pattern constraint.
249 Args:
250 pattern: Regex pattern (string or compiled pattern)
251 """
252 if isinstance(pattern, str):
253 self.regex = re.compile(pattern)
254 else:
255 self.regex = pattern
256 self.pattern_str = pattern if isinstance(pattern, str) else pattern.pattern
258 def check(self, value: Any, context: Optional[ValidationContext] = None) -> ValidationResult:
259 """Check if value matches pattern."""
260 if value is None:
261 return ValidationResult.success(value)
263 if not isinstance(value, str):
264 return ValidationResult.failure(
265 value,
266 [f"Value must be a string for pattern matching, got {type(value).__name__}"]
267 )
269 if not self.regex.match(value):
270 return ValidationResult.failure(
271 value,
272 [f"Value '{value}' does not match pattern '{self.pattern_str}'"]
273 )
275 return ValidationResult.success(value)
278class Enum(Constraint):
279 """Value must be in allowed set."""
281 def __init__(self, values: List[Any]):
282 """
283 Initialize enum constraint.
285 Args:
286 values: List of allowed values
287 """
288 if not values:
289 raise ValueError("Enum constraint requires at least one allowed value")
290 self.allowed = set(values)
291 self.allowed_str = ', '.join(repr(v) for v in values)
293 def check(self, value: Any, context: Optional[ValidationContext] = None) -> ValidationResult:
294 """Check if value is in allowed set."""
295 if value is None:
296 return ValidationResult.success(value)
298 if value not in self.allowed:
299 return ValidationResult.failure(
300 value,
301 [f"Value '{value}' is not in allowed values: {self.allowed_str}"]
302 )
304 return ValidationResult.success(value)
307class Unique(Constraint):
308 """Value must be unique (uses context for tracking)."""
310 def __init__(self, field_name: Optional[str] = None):
311 """
312 Initialize unique constraint.
314 Args:
315 field_name: Optional field name for context tracking
316 """
317 self.field_name = field_name or "default"
319 def check(self, value: Any, context: Optional[ValidationContext] = None) -> ValidationResult:
320 """Check if value is unique using context."""
321 if value is None:
322 return ValidationResult.success(value)
324 if context is None:
325 # Without context, we can't track uniqueness
326 return ValidationResult.success(
327 value,
328 warnings=["Unique constraint requires context for tracking"]
329 )
331 if context.has_seen(self.field_name, value):
332 return ValidationResult.failure(
333 value,
334 [f"Duplicate value '{value}' for field '{self.field_name}'"]
335 )
337 context.mark_seen(self.field_name, value)
338 return ValidationResult.success(value)
341class Custom(Constraint):
342 """Custom constraint using a callable."""
344 def __init__(
345 self,
346 validator: Callable[[Any], Union[bool, ValidationResult]],
347 error_message: str = "Custom validation failed"
348 ):
349 """
350 Initialize custom constraint.
352 Args:
353 validator: Callable that returns bool or ValidationResult
354 error_message: Error message if validation fails
355 """
356 self.validator = validator
357 self.error_message = error_message
359 def check(self, value: Any, context: Optional[ValidationContext] = None) -> ValidationResult:
360 """Check using custom validator."""
361 try:
362 result = self.validator(value)
364 if isinstance(result, ValidationResult):
365 return result
366 elif isinstance(result, bool):
367 if result:
368 return ValidationResult.success(value)
369 else:
370 return ValidationResult.failure(value, [self.error_message])
371 else:
372 return ValidationResult.failure(
373 value,
374 [f"Custom validator returned unexpected type: {type(result).__name__}"]
375 )
376 except Exception as e:
377 return ValidationResult.failure(
378 value,
379 [f"Custom validation error: {str(e)}"]
380 )