Coverage for youversion/utils.py: 100%
170 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-26 11:31 +0100
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-26 11:31 +0100
1"""Utility functions for YouVersion API client."""
3import hashlib
4import re
5from typing import Any
7from pydantic import BaseModel, create_model
10class DynamicPydanticFactory:
11 """Factory for creating Pydantic models dynamically from API responses."""
13 def __init__(self):
14 """Initialize the factory with a cache for generated classes."""
15 self._class_cache: dict[str, type[BaseModel]] = {}
17 def _sanitize_name(self, name: str) -> str:
18 """Sanitize a name to be a valid Python identifier.
20 Args:
21 name: Original name
23 Returns:
24 Sanitized name valid as Python identifier
25 """
26 # Replace invalid characters with underscores
27 name = re.sub(r"[^a-zA-Z0-9_]", "_", name)
28 # Ensure it doesn't start with a number
29 if name and name[0].isdigit():
30 name = "_" + name
31 # Ensure it's not empty
32 if not name:
33 name = "_empty"
34 return name
36 def _infer_type(self, value: Any, field_name: str = "") -> tuple[Any, Any]:
37 """Infer Python type from a value.
39 Args:
40 value: Value to infer type from
41 field_name: Name of the field (for context)
43 Returns:
44 Tuple of (type, default_value)
45 """
46 if value is None:
47 return (Any | None, None)
48 elif isinstance(value, bool):
49 return (bool, False)
50 elif isinstance(value, int):
51 return (int, 0)
52 elif isinstance(value, float):
53 return (float, 0.0)
54 elif isinstance(value, str):
55 # ponytail: API often sends null for optional string fields
56 return (str | None, None)
57 elif isinstance(value, list):
58 if not value:
59 # Empty list - can't infer element type
60 # Use field_name to create model name
61 # (e.g., "verses" -> "Verse")
62 if field_name:
63 # Singularize and PascalCase: "verses" -> "Verse"
64 element_class_name = self._get_element_class_name(field_name)
65 element_class = self.create_model(element_class_name, {})
66 return (list[element_class] | None, None)
67 return (list[Any] | None, None)
68 # Infer type from first element
69 # Use field_name to create model name for dict elements
70 if isinstance(value[0], dict) and field_name:
71 element_class_name = self._get_element_class_name(field_name)
72 element_type, _ = self._infer_type(value[0], element_class_name)
73 else:
74 element_type, _ = self._infer_type(value[0], field_name)
75 return (list[element_type] | None, None)
76 elif isinstance(value, dict):
77 # Nested dict - create a nested Pydantic model
78 nested_class_name = (
79 self._get_element_class_name(field_name)
80 if field_name
81 else self._sanitize_name(field_name)
82 )
83 nested_class = self.create_model(nested_class_name, value)
84 # Return as optional type with None default
85 return (nested_class | None, None)
86 else:
87 return (Any, None)
89 def _get_element_class_name(self, field_name: str) -> str:
90 """Get a class name for list elements based on field name.
92 Converts snake_case to PascalCase and handles plural forms.
93 Examples: "download_urls" -> "DownloadUrl", "user_ids" -> "UserId"
95 Args:
96 field_name: Field name (e.g., "verses", "download_urls", "results")
98 Returns:
99 Singularized and PascalCase class name
100 (e.g., "Verse", "DownloadUrl")
101 """
102 sanitized = self._sanitize_name(field_name)
103 if sanitized:
104 # Handle plural forms: remove trailing 's' if present
105 if sanitized.endswith("s") and len(sanitized) > 1:
106 singular = sanitized[:-1]
107 else:
108 singular = sanitized
110 # Convert snake_case to PascalCase
111 # Split by underscore, capitalize each word, then join
112 parts = singular.split("_")
113 # Check if already in PascalCase
114 # (no underscores and has mixed case)
115 if len(parts) == 1 and singular[0].isupper():
116 # Already PascalCase - preserve it
117 pascal_case = singular
118 else:
119 # Convert to PascalCase: capitalize each word
120 pascal_case = "".join(word.capitalize() for word in parts if word)
122 # Ensure it's not empty after processing
123 if not pascal_case or pascal_case == "_empty":
124 return "Item"
125 return pascal_case
126 return "Item"
128 def _value_signature(self, value: Any) -> str:
129 """Build a stable shape signature for cache keys."""
130 if value is None:
131 return "none"
132 if isinstance(value, bool):
133 return "bool"
134 if isinstance(value, int):
135 return "int"
136 if isinstance(value, float):
137 return "float"
138 if isinstance(value, str):
139 return "str"
140 if isinstance(value, list):
141 if not value:
142 return "list"
143 return f"list[{self._value_signature(value[0])}]"
144 if isinstance(value, dict):
145 parts = [
146 f"{k}:{self._value_signature(v)}" for k, v in sorted(value.items())
147 ]
148 return "{" + ",".join(parts) + "}"
149 return type(value).__name__
151 def _cache_key(self, class_name: str, data: dict[str, Any]) -> str:
152 """Cache key from model name and data shape (not object id).
154 ponytail: id(data) is reused after GC and caused stale schemas
155 when paging moments (page-1 Extra model applied to page-2 data).
156 """
157 digest = hashlib.sha256(self._value_signature(data).encode()).hexdigest()[:16]
158 return f"{class_name}_{digest}"
160 def create_model(self, class_name: str, data: dict[str, Any]) -> type[BaseModel]:
161 """Create a Pydantic model dynamically from a dictionary.
163 Args:
164 class_name: Name for the generated class
165 data: Dictionary containing the data structure
167 Returns:
168 Generated Pydantic model class
169 """
170 # Sanitize class name first
171 sanitized = self._sanitize_name(class_name)
172 # Check if already in PascalCase (multiple capital letters)
173 if sanitized and sanitized[0].isupper():
174 # Check if it's already PascalCase
175 # (has multiple words or is single word)
176 # If it contains underscores, convert to PascalCase
177 if "_" in sanitized:
178 parts = sanitized.split("_")
179 final_class_name = "".join(word.capitalize() for word in parts if word)
180 else:
181 # Already PascalCase or single word - preserve it
182 final_class_name = sanitized
183 elif sanitized:
184 # Not uppercase, capitalize first letter only
185 final_class_name = sanitized.capitalize()
186 else:
187 final_class_name = sanitized
189 # Check cache with sanitized class name and data shape
190 cache_key = self._cache_key(final_class_name, data)
191 if cache_key in self._class_cache:
192 return self._class_cache[cache_key]
194 # Use final_class_name for model creation
195 class_name = final_class_name
197 # Build field definitions for Pydantic
198 # Make all fields optional to avoid field ordering issues
199 field_definitions: dict[str, tuple[Any, Any]] = {}
201 for key, value in data.items():
202 field_name = self._sanitize_name(key)
203 field_type, default_value = self._infer_type(value, field_name)
205 # Handle different default value types
206 # Check if default_value is a Field instance by checking type name
207 if (
208 default_value is not None
209 and hasattr(default_value, "__class__")
210 and default_value.__class__.__name__ == "FieldInfo"
211 ):
212 # Already a Pydantic Field
213 field_definitions[field_name] = (field_type, default_value)
214 elif default_value is None:
215 # Optional field with None default
216 type_str = str(field_type)
217 if (
218 "Optional" in type_str
219 or "Union" in type_str
220 or "| None" in type_str
221 ):
222 optional_type = field_type
223 else:
224 optional_type = field_type | None
225 field_definitions[field_name] = (optional_type, None)
226 else:
227 # Field with explicit default value
228 field_definitions[field_name] = (field_type, default_value)
230 # Create the Pydantic model
231 try:
232 generated_class = create_model(class_name, **field_definitions)
234 # Cache the class
235 self._class_cache[cache_key] = generated_class
237 return generated_class
238 except (TypeError, ValueError):
239 # Fallback to a simple model if creation fails
240 fallback_fields = {self._sanitize_name(k): (Any, None) for k in data.keys()}
241 return create_model(class_name, **fallback_fields)
243 def create_instance(self, class_name: str, data: dict[str, Any]) -> Any:
244 """Create a Pydantic model instance from a dictionary.
246 Args:
247 class_name: Name for the generated class
248 data: Dictionary containing the data
250 Returns:
251 Instance of the generated Pydantic model
252 """
253 model_type = self.create_model(class_name, data)
254 return self._create_instance_recursive(model_type, data)
256 def _create_instance_recursive(
257 self, model_type: type[BaseModel], data: dict[str, Any]
258 ) -> Any:
259 """Recursively create Pydantic model instance.
261 Handles nested dictionaries and lists, creating model instances.
263 Args:
264 model_type: The Pydantic model type to instantiate
265 data: Dictionary containing the data
267 Returns:
268 Instance of the Pydantic model
269 """
270 processed_data = {}
272 for key, value in data.items():
273 field_name = self._sanitize_name(key)
275 # Handle nested dictionaries
276 if isinstance(value, dict):
277 # Check if the field type is a nested model
278 field_info = model_type.model_fields.get(field_name)
279 if field_info:
280 annotation = field_info.annotation
281 # Handle Optional types
282 if hasattr(annotation, "__args__"):
283 args = annotation.__args__
284 # Find the model type in Optional[ModelType]
285 nested_class = None
286 for arg in args:
287 if hasattr(arg, "model_fields"):
288 nested_class = arg
289 break
290 if nested_class:
291 processed_data[field_name] = (
292 self._create_instance_recursive(nested_class, value)
293 )
294 else:
295 # No model type found, pass dict
296 processed_data[field_name] = value
297 elif hasattr(annotation, "model_fields"):
298 # Direct model type
299 processed_data[field_name] = self._create_instance_recursive(
300 annotation, value
301 )
302 else:
303 # dict type, pass as-is
304 processed_data[field_name] = value
305 else:
306 # No field info, pass as-is
307 processed_data[field_name] = value
308 # Handle lists
309 elif isinstance(value, list):
310 processed_list = []
311 for item in value:
312 if isinstance(item, dict):
313 # Check if list element type is a model
314 field_info = model_type.model_fields.get(field_name)
315 if field_info:
316 annotation = field_info.annotation
317 # Handle list[ModelType] or list[Any]
318 if hasattr(annotation, "__args__"):
319 element_type = annotation.__args__[0]
320 if hasattr(element_type, "model_fields"):
321 item_class = element_type
322 processed_list.append(
323 self._create_instance_recursive(
324 item_class, item
325 )
326 )
327 else:
328 processed_list.append(item)
329 else:
330 processed_list.append(item)
331 else:
332 processed_list.append(item)
333 else:
334 processed_list.append(item)
335 processed_data[field_name] = processed_list
336 else:
337 processed_data[field_name] = value
339 # Pydantic models handle extra fields and validation automatically
340 # Use model_validate for better compatibility with Pydantic v2
341 try:
342 return model_type.model_validate(processed_data)
343 except Exception:
344 # If validation fails, try with only known fields
345 model_fields = set(model_type.model_fields.keys())
346 filtered_data = {
347 k: v for k, v in processed_data.items() if k in model_fields
348 }
349 try:
350 return model_type.model_validate(filtered_data)
351 except Exception:
352 # Last resort: try direct instantiation
353 return model_type(**filtered_data)
356# Global factory instance
357_factory = DynamicPydanticFactory()
360def create_model_from_response(
361 class_name: str, data: dict[str, Any]
362) -> type[BaseModel]:
363 """Create a Pydantic model dynamically from an API response.
365 Args:
366 class_name: Name for the generated class
367 data: Dictionary containing the API response data
369 Returns:
370 Generated Pydantic model class
372 Example:
373 >>> response = {"id": 1, "name": "Test"}
374 >>> MyClass = create_model_from_response("MyResponse", response)
375 >>> instance = MyClass(id=1, name="Test")
376 """
377 return _factory.create_model(class_name, data)
380def create_instance_from_response(class_name: str, data: dict[str, Any]) -> Any:
381 """Create a Pydantic model instance from an API response.
383 Args:
384 class_name: Name for the generated class
385 data: Dictionary containing the API response data
387 Returns:
388 Instance of the generated Pydantic model
390 Example:
391 >>> response = {"id": 1, "name": "Test"}
392 >>> instance = create_instance_from_response("MyResponse", response)
393 >>> print(instance.id) # 1
394 """
395 return _factory.create_instance(class_name, data)
398# Backward compatibility aliases
399create_dataclass_from_response = create_model_from_response