• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Problematy / goodmap / 20976218004

13 Jan 2026 11:22PM UTC coverage: 99.309% (-0.1%) from 99.449%
20976218004

Pull #309

github

web-flow
Merge f3d696d21 into 0c8af05b0
Pull Request #309: feat: better model handling

431 of 434 new or added lines in 5 files covered. (99.31%)

3 existing lines in 1 file now uncovered.

1438 of 1448 relevant lines covered (99.31%)

0.99 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.99
/goodmap/data_models/location.py
1
"""Pydantic models for location data validation and schema generation."""
2

3
import warnings
1✔
4
from typing import Annotated, Any, Callable, Type, cast, overload
1✔
5

6
from pydantic import (
1✔
7
    AfterValidator,
8
    BaseModel,
9
    Field,
10
    ValidationError,
11
    create_model,
12
    field_validator,
13
    model_validator,
14
)
15

16
from goodmap.exceptions import LocationValidationError
1✔
17

18

19
class LocationBase(BaseModel, extra="allow"):
1✔
20
    """Base model for location data with position validation and error enrichment.
21

22
    Attributes:
23
        position: Tuple of (latitude, longitude) coordinates
24
        uuid: Unique identifier for the location
25
    """
26

27
    position: tuple[float, float]
1✔
28
    uuid: str = Field(..., max_length=100)  # UUID is 36 chars, allow some flexibility
1✔
29

30
    @field_validator("position")
1✔
31
    @classmethod
1✔
32
    def position_must_be_valid(cls, v: tuple[float, float]) -> tuple[float, float]:
1✔
33
        """Validate that latitude and longitude are within valid ranges."""
34
        lat, lon = v
1✔
35
        if lat < -90 or lat > 90:
1✔
36
            raise ValueError("latitude must be in range -90 to 90")
1✔
37
        if lon < -180 or lon > 180:
1✔
38
            raise ValueError("longitude must be in range -180 to 180")
1✔
39
        return v
1✔
40

41
    @model_validator(mode="before")
1✔
42
    @classmethod
1✔
43
    def validate_uuid_exists(cls, data: Any) -> Any:
1✔
44
        """Ensure UUID is present before validation for better error messages."""
45
        if isinstance(data, dict) and "uuid" not in data:
1✔
46
            raise ValueError("Location data must include 'uuid' field")
1✔
47
        return data
1✔
48

49
    @model_validator(mode="wrap")
1✔
50
    @classmethod
1✔
51
    def enrich_validation_errors(cls, data, handler):
1✔
52
        """Wrap validation errors with UUID context for better debugging."""
53
        try:
1✔
54
            return handler(data)
1✔
55
        except ValidationError as e:
1✔
56
            uuid = data.get("uuid") if isinstance(data, dict) else None
1✔
57
            raise LocationValidationError(e, uuid=uuid) from e
1✔
58

59
    def basic_info(self) -> dict[str, Any]:
1✔
60
        """Get basic location information summary."""
61
        return {
1✔
62
            "uuid": self.uuid,
63
            "position": self.position,
64
            "remark": bool(getattr(self, "remark", False)),
65
        }
66

67

68
# Map type strings to Python types
69
_TYPE_MAPPING = {
1✔
70
    "str": str,
71
    "list": list,
72
    "int": int,
73
    "float": float,
74
    "bool": bool,
75
    "dict": dict,
76
}
77

78

79
_MAX_LIST_ITEM_LENGTH = 100
1✔
80

81

82
def _validate_list_item_length(item: Any) -> None:
1✔
83
    """Raise ValueError if string item exceeds max length."""
84
    if isinstance(item, str) and len(item) > _MAX_LIST_ITEM_LENGTH:
1✔
85
        raise ValueError(f"list item too long (max {_MAX_LIST_ITEM_LENGTH} chars), got {len(item)}")
1✔
86

87

88
def _make_enum_validator(allowed: list[str]) -> Callable[[str], str]:
1✔
89
    """Create a validator that checks value is in allowed list."""
90

91
    def validate(v: str) -> str:
1✔
92
        if v not in allowed:
1✔
93
            raise ValueError(f"must be one of {allowed}, got '{v}'")
1✔
94
        return v
1✔
95

96
    return validate
1✔
97

98

99
def _make_list_enum_validator(allowed: list[str]) -> Callable[[list[Any]], list[Any]]:
1✔
100
    """Create a validator that checks all list items are in allowed list and within length."""
101

102
    def validate(v: list[Any]) -> list[Any]:
1✔
103
        for item in v:
1✔
104
            if item not in allowed:
1✔
105
                raise ValueError(f"must be one of {allowed}, got '{item}'")
1✔
106
            _validate_list_item_length(item)
1✔
107
        return v
1✔
108

109
    return validate
1✔
110

111

112
def _make_list_length_validator() -> Callable[[list[Any]], list[Any]]:
1✔
113
    """Create a validator that checks list item lengths."""
114

115
    def validate(v: list[Any]) -> list[Any]:
1✔
116
        for item in v:
1✔
117
            _validate_list_item_length(item)
1✔
118
        return v
1✔
119

120
    return validate
1✔
121

122

123
def _normalize_field_type(field_type_input: str | Type[Any]) -> str:
1✔
124
    """Convert field type input to string, emitting deprecation warning for type objects."""
125
    if isinstance(field_type_input, type):
1✔
126
        warnings.warn(
1✔
127
            f"Passing Python type objects to create_location_model is deprecated. "
128
            f"Use string type names instead: '{field_type_input.__name__}' "
129
            f"instead of {field_type_input}. "
130
            f"Support for type objects will be removed in version 2.0.0.",
131
            DeprecationWarning,
132
            stacklevel=3,
133
        )
134
        return field_type_input.__name__
1✔
135
    return field_type_input
1✔
136

137

138
def _create_list_field_with_enum(allowed_values: list[str]) -> tuple[Any, Any]:
1✔
139
    """Create a list field definition with enum validation."""
140
    description = f"Allowed values: {', '.join(allowed_values)}"
1✔
141
    field_type = Annotated[list[str], AfterValidator(_make_list_enum_validator(allowed_values))]
1✔
142
    return (
1✔
143
        field_type,
144
        Field(
145
            ...,
146
            description=description,
147
            max_length=20,
148
            json_schema_extra=cast(Any, {"enum_items": allowed_values}),
149
        ),
150
    )
151

152

153
def _create_str_field_with_enum(allowed_values: list[str]) -> tuple[Any, Any]:
1✔
154
    """Create a string field definition with enum validation."""
155
    description = f"Allowed values: {', '.join(allowed_values)}"
1✔
156
    field_type = Annotated[str, AfterValidator(_make_enum_validator(allowed_values))]
1✔
157
    return (
1✔
158
        field_type,
159
        Field(
160
            ...,
161
            description=description,
162
            max_length=200,
163
            json_schema_extra=cast(Any, {"enum": allowed_values}),
164
        ),
165
    )
166

167

168
def _create_list_field_without_enum() -> tuple[Any, Any]:
1✔
169
    """Create a list field definition without enum validation."""
170
    field_type = Annotated[list[Any], AfterValidator(_make_list_length_validator())]
1✔
171
    return (field_type, Field(..., max_length=20))
1✔
172

173

174
def _create_simple_field(base_type: Type[Any]) -> tuple[Any, Any]:
1✔
175
    """Create a simple field definition for non-list types."""
176
    if base_type is str:
1✔
177
        return (base_type, Field(..., max_length=200))
1✔
NEW
178
    return (base_type, Field(...))
×
179

180

181
def _build_field_definition(
1✔
182
    field_type_str: str, allowed_values: list[str] | None
183
) -> tuple[Any, Any]:
184
    """Build a complete field definition based on type and constraints."""
185
    is_list = field_type_str.startswith("list")
1✔
186

187
    if allowed_values:
1✔
188
        if is_list:
1✔
189
            return _create_list_field_with_enum(allowed_values)
1✔
190
        return _create_str_field_with_enum(allowed_values)
1✔
191

192
    if is_list:
1✔
193
        return _create_list_field_without_enum()
1✔
194

195
    base_type = _TYPE_MAPPING.get(field_type_str, str)
1✔
196
    return _create_simple_field(base_type)
1✔
197

198

199
@overload
1✔
200
def create_location_model(
1✔
201
    obligatory_fields: list[tuple[str, str]],
202
    categories: dict[str, list[str]] | None = ...,
203
) -> Type[BaseModel]:
204
    """Create location model with string type names (recommended)."""
205
    ...
206

207

208
@overload
1✔
209
def create_location_model(
1✔
210
    obligatory_fields: list[tuple[str, Type[Any]]],
211
    categories: dict[str, list[str]] | None = ...,
212
) -> Type[BaseModel]:
213
    """Create location model with Python type objects (deprecated)."""
214
    ...
215

216

217
def create_location_model(
1✔
218
    obligatory_fields: list[tuple[str, str]] | list[tuple[str, Type[Any]]],
219
    categories: dict[str, list[str]] | None = None,
220
) -> Type[BaseModel]:
221
    """Dynamically create a Location model with additional required fields.
222

223
    Supports both string type names (recommended) and Python type objects (deprecated).
224

225
    Args:
226
        obligatory_fields: List of (field_name, field_type) tuples for required fields.
227
                          field_type can be either:
228
                          - String type name: "str", "list", "int", "float", "bool", "dict"
229
                          - Python type object: str, list, int, etc. (deprecated)
230
        categories: Optional dict mapping field names to allowed values (enums)
231

232
    Returns:
233
        Type[BaseModel]: A Location model class extending LocationBase with additional fields
234

235
    Examples:
236
        >>> # Recommended: String type names
237
        >>> model = create_location_model([("name", "str"), ("tags", "list")])
238

239
        >>> # Deprecated: Python type objects (supported for backward compatibility)
240
        >>> model = create_location_model([("name", str), ("tags", list)])
241
    """
242
    categories = categories or {}
1✔
243
    fields: dict[str, Any] = {}
1✔
244

245
    for field_name, field_type_input in obligatory_fields:
1✔
246
        field_type_str = _normalize_field_type(field_type_input)
1✔
247
        allowed_values = categories.get(field_name)
1✔
248
        fields[field_name] = _build_field_definition(field_type_str, allowed_values)
1✔
249

250
    return create_model(
1✔
251
        "Location",
252
        __base__=LocationBase,
253
        __module__="goodmap.data_models.location",
254
        **fields,
255
    )
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc