|
| 1 | +"""Field metadata extractors for Pydantic models.""" |
| 2 | + |
| 3 | +import inspect |
| 4 | +from typing import Any, get_args, get_origin, get_type_hints |
| 5 | + |
| 6 | +from pydantic import BaseModel |
| 7 | +from pydantic.fields import FieldInfo |
| 8 | + |
| 9 | + |
| 10 | +class FieldMetadata: |
| 11 | + """Metadata about a Pydantic model field.""" |
| 12 | + |
| 13 | + def __init__( |
| 14 | + self, |
| 15 | + field_name: str, |
| 16 | + field_type: type, |
| 17 | + jsonapi_type: str | None = None, |
| 18 | + jsonapi_name: str | None = None, |
| 19 | + is_optional: bool = False, |
| 20 | + is_list: bool = False, |
| 21 | + inner_type: type | None = None, |
| 22 | + ): |
| 23 | + self.field_name = field_name |
| 24 | + self.field_type = field_type |
| 25 | + self.jsonapi_type = jsonapi_type or self._infer_jsonapi_type() |
| 26 | + self.jsonapi_name = jsonapi_name or self._convert_to_jsonapi_name(field_name) |
| 27 | + self.is_optional = is_optional |
| 28 | + self.is_list = is_list |
| 29 | + self.inner_type = inner_type |
| 30 | + |
| 31 | + def _infer_jsonapi_type(self) -> str: |
| 32 | + """Infer JSON:API type from field name patterns.""" |
| 33 | + if self.field_name == "id": |
| 34 | + return "primary" |
| 35 | + return "attribute" |
| 36 | + |
| 37 | + def _convert_to_jsonapi_name(self, field_name: str) -> str: |
| 38 | + """Convert Python field name to JSON:API name (snake_case to kebab-case).""" |
| 39 | + return field_name.replace("_", "-") |
| 40 | + |
| 41 | + |
| 42 | +def get_model_metadata(model_class: type[BaseModel]) -> dict[str, FieldMetadata]: |
| 43 | + """Extract metadata for all fields in a Pydantic model. |
| 44 | +
|
| 45 | + Returns: |
| 46 | + Dict mapping field name to FieldMetadata |
| 47 | + """ |
| 48 | + metadata: dict[str, FieldMetadata] = {} |
| 49 | + |
| 50 | + # Get type hints |
| 51 | + try: |
| 52 | + type_hints = get_type_hints(model_class, include_extras=True) |
| 53 | + except Exception: |
| 54 | + type_hints = {} |
| 55 | + |
| 56 | + # Iterate through model fields |
| 57 | + for field_name, field_info in model_class.model_fields.items(): |
| 58 | + field_type = type_hints.get(field_name, field_info.annotation) |
| 59 | + |
| 60 | + # Check for Field metadata |
| 61 | + jsonapi_type = None |
| 62 | + jsonapi_name = None |
| 63 | + |
| 64 | + if isinstance(field_info, FieldInfo): |
| 65 | + # Extract custom metadata from Field() |
| 66 | + if field_info.json_schema_extra and isinstance( |
| 67 | + field_info.json_schema_extra, dict |
| 68 | + ): |
| 69 | + jsonapi_type = field_info.json_schema_extra.get("jsonapi_type") |
| 70 | + jsonapi_name = field_info.json_schema_extra.get("jsonapi_name") |
| 71 | + else: |
| 72 | + jsonapi_type = None |
| 73 | + jsonapi_name = None |
| 74 | + |
| 75 | + # If no explicit jsonapi_name, use the Pydantic alias if available |
| 76 | + if not jsonapi_name and field_info.alias: |
| 77 | + jsonapi_name = field_info.alias |
| 78 | + |
| 79 | + # Handle Optional types |
| 80 | + is_optional = False |
| 81 | + is_list = False |
| 82 | + inner_type = field_type |
| 83 | + |
| 84 | + origin = get_origin(field_type) |
| 85 | + args = get_args(field_type) |
| 86 | + |
| 87 | + # Check for Optional (Union with None) - handles both Optional[X] and X | None |
| 88 | + import types |
| 89 | + |
| 90 | + if origin is types.UnionType or ( |
| 91 | + hasattr(types, "Union") and origin is getattr(types, "Union", None) |
| 92 | + ): |
| 93 | + if type(None) in args: |
| 94 | + is_optional = True |
| 95 | + # Get the non-None type |
| 96 | + inner_type = next( |
| 97 | + (arg for arg in args if arg is not type(None)), field_type |
| 98 | + ) |
| 99 | + |
| 100 | + # Check for List |
| 101 | + if get_origin(inner_type) is list: |
| 102 | + is_list = True |
| 103 | + list_args = get_args(inner_type) |
| 104 | + if list_args: |
| 105 | + inner_type = list_args[0] |
| 106 | + |
| 107 | + # Ensure proper types for FieldMetadata |
| 108 | + jsonapi_type_str: str | None = None |
| 109 | + if jsonapi_type is not None: |
| 110 | + jsonapi_type_str = ( |
| 111 | + str(jsonapi_type) if not isinstance(jsonapi_type, str) else jsonapi_type |
| 112 | + ) |
| 113 | + |
| 114 | + jsonapi_name_str: str | None = None |
| 115 | + if jsonapi_name is not None: |
| 116 | + jsonapi_name_str = ( |
| 117 | + str(jsonapi_name) if not isinstance(jsonapi_name, str) else jsonapi_name |
| 118 | + ) |
| 119 | + |
| 120 | + metadata[field_name] = FieldMetadata( |
| 121 | + field_name=field_name, |
| 122 | + field_type=field_type, # type: ignore[arg-type] |
| 123 | + jsonapi_type=jsonapi_type_str, |
| 124 | + jsonapi_name=jsonapi_name_str, |
| 125 | + is_optional=is_optional, |
| 126 | + is_list=is_list, |
| 127 | + inner_type=inner_type, |
| 128 | + ) |
| 129 | + |
| 130 | + return metadata |
| 131 | + |
| 132 | + |
| 133 | +def is_pydantic_model(obj: Any) -> bool: |
| 134 | + """Check if object is a Pydantic model class.""" |
| 135 | + try: |
| 136 | + return inspect.isclass(obj) and issubclass(obj, BaseModel) |
| 137 | + except TypeError: |
| 138 | + return False |
0 commit comments