Coverage for src/lexigram/admin/schema/validators.py: 3%
74 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:31 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:31 +0800
1from __future__ import annotations
3import re
4from typing import Any, Protocol, runtime_checkable
6from lexigram.admin.schema.exceptions import FieldError
7from lexigram.result import Err, Ok, Result
10@runtime_checkable
11class FieldValidator(Protocol):
12 """Protocol for field value validators."""
14 def __call__(self, value: Any) -> Result[Any, FieldError]:
15 """Validate a value, returning Ok(value) or Err(FieldError)."""
18class RequiredValidator:
19 """Reject None, empty strings, and whitespace-only strings."""
21 def __call__(self, value: Any) -> Result[Any, FieldError]:
22 if value is None:
23 return Err(FieldError("Field is required"))
24 if isinstance(value, str) and value.strip() == "":
25 return Err(FieldError("Field is required"))
26 return Ok(value)
29class LengthValidator:
30 """Reject values shorter than min or longer than max."""
32 def __init__(
33 self, min_length: int | None = None, max_length: int | None = None
34 ) -> None:
35 self.min_length = min_length
36 self.max_length = max_length
38 def __call__(self, value: Any) -> Result[Any, FieldError]:
39 if not hasattr(value, "__len__"):
40 return Err(FieldError("Field must have a length"))
41 length = len(value)
42 if self.min_length is not None and length < self.min_length:
43 return Err(
44 FieldError(f"Field must have at least {self.min_length} characters")
45 )
46 if self.max_length is not None and length > self.max_length:
47 return Err(
48 FieldError(f"Field must have at most {self.max_length} characters")
49 )
50 return Ok(value)
53class RangeValidator:
54 """Reject numeric values below min or above max."""
56 def __init__(
57 self, min_value: float | None = None, max_value: float | None = None
58 ) -> None:
59 self.min_value = min_value
60 self.max_value = max_value
62 def __call__(self, value: Any) -> Result[Any, FieldError]:
63 try:
64 if self.min_value is not None and value < self.min_value:
65 return Err(FieldError(f"Field must be at least {self.min_value}"))
66 if self.max_value is not None and value > self.max_value:
67 return Err(FieldError(f"Field must be at most {self.max_value}"))
68 except TypeError:
69 return Err(FieldError("Field must be a numeric value"))
70 return Ok(value)
73class EmailValidator:
74 """Reject strings that don't look like email addresses."""
76 def __call__(self, value: Any) -> Result[Any, FieldError]:
77 if not isinstance(value, str):
78 return Err(FieldError("Field must be a string"))
79 if "@" not in value:
80 return Err(FieldError("Field must contain an '@' character"))
81 local, _, domain = value.partition("@")
82 if not local:
83 return Err(FieldError("Field must have a local part before '@'"))
84 if not domain:
85 return Err(FieldError("Field must have a domain after '@'"))
86 if "." not in domain:
87 return Err(FieldError("Email domain must contain a '.'"))
88 return Ok(value)
91class URLValidator:
92 """Reject strings that don't look like URLs."""
94 def __call__(self, value: Any) -> Result[Any, FieldError]:
95 if not isinstance(value, str):
96 return Err(FieldError("Field must be a string"))
97 if not value.startswith(("http://", "https://")):
98 return Err(FieldError("Field must be a valid URL"))
99 rest = value.split("://", 1)[1]
100 if not rest:
101 return Err(FieldError("URL must include a host after the scheme"))
102 return Ok(value)
105class PatternValidator:
106 """Reject strings that don't match the given regex pattern."""
108 def __init__(self, pattern: str) -> None:
109 self._regex = re.compile(pattern)
111 def __call__(self, value: Any) -> Result[Any, FieldError]:
112 if not isinstance(value, str):
113 return Err(FieldError("Field must be a string"))
114 if not self._regex.fullmatch(value):
115 return Err(FieldError("Field does not match required pattern"))
116 return Ok(value)