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
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-14 20:55 +0000
1"""
2Pydantic helpers for GitVersioned.
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"""
10from __future__ import annotations
12from collections.abc import Callable
13from pathlib import Path
14from typing import Annotated, Any, Generic, TypeVar, get_args
16from pydantic import BeforeValidator, Field, GetCoreSchemaHandler
17from pydantic_core import CoreSchema, core_schema
19__all__ = [
20 "EnsureBool",
21 "EnsureList",
22 "EnsurePath",
23 "coerce_bool",
24 "coerce_list",
25 "coerce_path",
26]
28TypeVarT = TypeVar("TypeVarT")
31def coerce_bool(value: Any) -> bool | Any:
32 """
33 Normalize truthy/falsy strings to actual booleans.
35 .. code-block:: python
37 coerce_bool("yes") # True
38 coerce_bool("0") # False
39 coerce_bool(5) # 5
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
53def coerce_path(value: Any) -> Path | Any:
54 """
55 Normalize string paths to Path objects.
57 .. code-block:: python
59 coerce_path("/tmp/path ") # Path("/tmp/path")
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
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.
75 Splits strings by commas and applies an optional pre-coercer to items
76 before they are passed to the final Pydantic validator.
78 .. code-block:: python
80 coerce_list("a, b, c") # ["a", "b", "c"]
81 coerce_list("yes, no", coerce_bool) # [True, False]
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 []
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]
98 processed_list: list[Any] = []
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)
109 return processed_list
112class EnsureList(list[TypeVarT], Generic[TypeVarT]):
113 """
114 A list subclass that tightly integrates with Pydantic Core Schema.
116 It preprocesses inputs (splitting strings, normalizing bools) before
117 delegating the final strict validation to Pydantic's native schema.
119 .. code-block:: python
121 from pydantic import BaseModel
123 class MyModel(BaseModel):
124 items: EnsureList[int]
126 model = MyModel(items="1, 2, 3")
127 print(model.items) # [1, 2, 3]
128 """
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.
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
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)
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])
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)
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 )
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]
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]