Coverage for src / gitversioned / utils / pydantic.py: 75%

48 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-14 20:55 +0000

1""" 

2Pydantic helpers for GitVersioned. 

3 

4This module provides reusable validation and type-coercion helpers for Pydantic 

5models, integrating directly with the Pydantic core schema for performance. 

6These helpers ensure robust parsing of configurations and environment variables, 

7supporting list parsing from strings and boolean evaluation of truthy/falsy strings. 

8""" 

9 

10from __future__ import annotations 

11 

12from collections.abc import Callable 

13from pathlib import Path 

14from typing import Annotated, Any, Generic, TypeVar, get_args 

15 

16from pydantic import BeforeValidator, Field, GetCoreSchemaHandler 

17from pydantic_core import CoreSchema, core_schema 

18 

19__all__ = [ 

20 "EnsureBool", 

21 "EnsureList", 

22 "EnsurePath", 

23 "coerce_bool", 

24 "coerce_list", 

25 "coerce_path", 

26] 

27 

28TypeVarT = TypeVar("TypeVarT") 

29 

30 

31def coerce_bool(value: Any) -> bool | Any: 

32 """ 

33 Normalize truthy/falsy strings to actual booleans. 

34 

35 .. code-block:: python 

36 

37 coerce_bool("yes") # True 

38 coerce_bool("0") # False 

39 coerce_bool(5) # 5 

40 

41 :param value: The value to coerce. 

42 :return: The boolean equivalent if recognizable, otherwise the original value. 

43 """ 

44 if isinstance(value, str): 

45 cleaned_value = value.lower().strip() 

46 if cleaned_value in {"true", "1", "yes", "t", "y"}: 

47 return True 

48 if cleaned_value in {"false", "0", "no", "f", "n"}: 

49 return False 

50 return value 

51 

52 

53def coerce_path(value: Any) -> Path | Any: 

54 """ 

55 Normalize string paths to Path objects. 

56 

57 .. code-block:: python 

58 

59 coerce_path("/tmp/path ") # Path("/tmp/path") 

60 

61 :param value: The value to coerce into a path. 

62 :return: A Path object if the input is a string, otherwise the original value. 

63 """ 

64 if isinstance(value, str): 

65 return Path(value.strip()) 

66 return value 

67 

68 

69def coerce_list( 

70 value: Any, item_pre_coercer: Callable[[Any], Any] | None = None 

71) -> list[Any]: 

72 """ 

73 Recursively transform input into a list. 

74 

75 Splits strings by commas and applies an optional pre-coercer to items 

76 before they are passed to the final Pydantic validator. 

77 

78 .. code-block:: python 

79 

80 coerce_list("a, b, c") # ["a", "b", "c"] 

81 coerce_list("yes, no", coerce_bool) # [True, False] 

82 

83 :param value: The value to coerce into a list. 

84 :param item_pre_coercer: Optional function to apply to each item. 

85 :return: A list of processed items. 

86 """ 

87 if value is None: 

88 return [] 

89 

90 # 1. Normalize input sequence 

91 if isinstance(value, str): 

92 items = [item.strip() for item in value.split(",") if item.strip()] 

93 elif isinstance(value, (list, tuple, set)): 

94 items = list(value) 

95 else: 

96 items = [value] 

97 

98 processed_list: list[Any] = [] 

99 

100 # 2. Process items (Recursive + Pre-coercion) 

101 for item in items: 

102 if isinstance(item, (list, tuple, set)) and not isinstance(item, str): 

103 processed_list.append(coerce_list(item, item_pre_coercer)) 

104 else: 

105 # Apply "dirty" coercion (like 'yes' -> True) before standard validation 

106 processed_item = item_pre_coercer(item) if item_pre_coercer else item 

107 processed_list.append(processed_item) 

108 

109 return processed_list 

110 

111 

112class EnsureList(list[TypeVarT], Generic[TypeVarT]): 

113 """ 

114 A list subclass that tightly integrates with Pydantic Core Schema. 

115 

116 It preprocesses inputs (splitting strings, normalizing bools) before 

117 delegating the final strict validation to Pydantic's native schema. 

118 

119 .. code-block:: python 

120 

121 from pydantic import BaseModel 

122 

123 class MyModel(BaseModel): 

124 items: EnsureList[int] 

125 

126 model = MyModel(items="1, 2, 3") 

127 print(model.items) # [1, 2, 3] 

128 """ 

129 

130 @classmethod 

131 def __get_pydantic_core_schema__( 

132 cls, source_type: Any, handler: GetCoreSchemaHandler 

133 ) -> CoreSchema: 

134 """ 

135 Create a schema that hooks a pre-validator into the Pydantic pipeline. 

136 

137 :param source_type: The original type annotation. 

138 :param handler: The Pydantic core schema handler. 

139 :return: The constructed Pydantic core schema. 

140 """ 

141 # Extract the inner type (e.g., int from EnsureList[int]) 

142 type_arguments = get_args(source_type) 

143 inner_type: Any = type_arguments[0] if type_arguments else Any 

144 

145 # Identify if we need 'special' help for types Pydantic is strict about. 

146 # Standard types like int, float, str are handled natively by Pydantic 

147 # once the string is split into a list. 

148 pre_coercer_map: dict[Any, Callable[[Any], Any]] = { 

149 bool: coerce_bool, 

150 Path: coerce_path, 

151 } 

152 target_pre_coercer = pre_coercer_map.get(inner_type) 

153 

154 # This schema represents what we WANT the data to look like at the end. 

155 # By calling handler.generate_schema, we get Pydantic's native list logic. 

156 final_list_schema = handler.generate_schema(list[inner_type]) 

157 

158 def before_validator_logic( 

159 value: Any, _info: core_schema.ValidationInfo 

160 ) -> Any: 

161 """Preprocessing wrapper before handing off to Pydantic's core.""" 

162 return coerce_list(value, item_pre_coercer=target_pre_coercer) 

163 

164 # We wrap the native schema in a 'before' validator. 

165 # Pipeline: Raw Input -> before_validator_logic -> Native Pydantic list[T] 

166 return core_schema.with_info_before_validator_function( 

167 before_validator_logic, 

168 final_list_schema, 

169 serialization=core_schema.plain_serializer_function_ser_schema( 

170 list, when_used="json-unless-none" 

171 ), 

172 ) 

173 

174 

175EnsureBool = Annotated[ 

176 bool, 

177 BeforeValidator(coerce_bool), 

178 Field( 

179 description=( 

180 "A robust boolean type that coerces common truthy/falsy " 

181 "strings into actual booleans before validation." 

182 ) 

183 ), 

184] 

185 

186EnsurePath = Annotated[ 

187 Path, 

188 BeforeValidator(coerce_path), 

189 Field( 

190 description=( 

191 "A robust Path type that normalizes and strips whitespace " 

192 "from string paths before validation." 

193 ) 

194 ), 

195]