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

1from __future__ import annotations 

2 

3import re 

4from typing import Any, Protocol, runtime_checkable 

5 

6from lexigram.admin.schema.exceptions import FieldError 

7from lexigram.result import Err, Ok, Result 

8 

9 

10@runtime_checkable 

11class FieldValidator(Protocol): 

12 """Protocol for field value validators.""" 

13 

14 def __call__(self, value: Any) -> Result[Any, FieldError]: 

15 """Validate a value, returning Ok(value) or Err(FieldError).""" 

16 

17 

18class RequiredValidator: 

19 """Reject None, empty strings, and whitespace-only strings.""" 

20 

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) 

27 

28 

29class LengthValidator: 

30 """Reject values shorter than min or longer than max.""" 

31 

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 

37 

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) 

51 

52 

53class RangeValidator: 

54 """Reject numeric values below min or above max.""" 

55 

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 

61 

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) 

71 

72 

73class EmailValidator: 

74 """Reject strings that don't look like email addresses.""" 

75 

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) 

89 

90 

91class URLValidator: 

92 """Reject strings that don't look like URLs.""" 

93 

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) 

103 

104 

105class PatternValidator: 

106 """Reject strings that don't match the given regex pattern.""" 

107 

108 def __init__(self, pattern: str) -> None: 

109 self._regex = re.compile(pattern) 

110 

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)