Skip to content

Commit 75be068

Browse files
committed
jsonapi-pydantic library
1 parent d1ec88b commit 75be068

13 files changed

Lines changed: 787 additions & 285 deletions

File tree

src/pytfe/jsonapi/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""JSON:API unmarshaling library for python-tfe."""
2+
3+
from .types import IncludedIndex, JSONAPIResponse
4+
from .unmarshaler import unmarshal_many_payload, unmarshal_payload
5+
6+
__all__ = [
7+
"unmarshal_payload",
8+
"unmarshal_many_payload",
9+
"JSONAPIResponse",
10+
"IncludedIndex",
11+
]

src/pytfe/jsonapi/metadata.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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

src/pytfe/jsonapi/types.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
from typing import Any, Generic, TypeVar
2+
3+
from pydantic import BaseModel
4+
5+
T = TypeVar("T", bound=BaseModel)
6+
7+
8+
class JSONAPINode:
9+
"""Represents a JSON:API resource object node."""
10+
11+
def __init__(self, data: dict[str, Any]):
12+
self.id: str = data.get("id", "")
13+
self.type: str = data.get("type", "")
14+
self.attributes: dict[str, Any] = data.get("attributes", {})
15+
self.relationships: dict[str, Any] = data.get("relationships", {})
16+
self.links: dict[str, Any] | None = data.get("links")
17+
self.meta: dict[str, Any] | None = data.get("meta")
18+
self._raw_data = data
19+
20+
def get_relationship_linkage(
21+
self, rel_name: str
22+
) -> dict[str, Any] | list[dict[str, Any]] | None:
23+
"""Extract relationship linkage data (type and id)."""
24+
if not self.relationships or rel_name not in self.relationships:
25+
return None
26+
27+
rel_data = self.relationships[rel_name].get("data")
28+
if rel_data is None:
29+
return None
30+
# Can be dict or list of dicts based on relationship type
31+
return rel_data # type: ignore[no-any-return]
32+
33+
34+
class IncludedIndex:
35+
"""Index for fast lookup of included resources."""
36+
37+
def __init__(self, included: list[dict[str, Any]] | None = None):
38+
self._index: dict[tuple[str, str], JSONAPINode] = {}
39+
40+
if included:
41+
for item in included:
42+
node = JSONAPINode(item)
43+
if node.type and node.id:
44+
key = (node.type, node.id)
45+
self._index[key] = node
46+
47+
def get(self, resource_type: str, resource_id: str) -> JSONAPINode | None:
48+
"""Lookup a resource by type and id."""
49+
return self._index.get((resource_type, resource_id))
50+
51+
def resolve_relationship(
52+
self, rel_data: dict[str, Any] | None
53+
) -> JSONAPINode | None:
54+
"""Resolve a relationship linkage to full node."""
55+
if not rel_data or not isinstance(rel_data, dict):
56+
return None
57+
58+
resource_type = rel_data.get("type")
59+
resource_id = rel_data.get("id")
60+
61+
if not resource_type or not resource_id:
62+
return None
63+
64+
return self.get(resource_type, resource_id)
65+
66+
67+
class JSONAPIResponse(Generic[T]):
68+
"""Complete JSON:API response with data and included."""
69+
70+
def __init__(self, response_dict: dict[str, Any]):
71+
self.data: dict[str, Any] | list[dict[str, Any]] = response_dict.get("data", {})
72+
self.included: list[dict[str, Any]] = response_dict.get("included", [])
73+
self.links: dict[str, Any] | None = response_dict.get("links")
74+
self.meta: dict[str, Any] | None = response_dict.get("meta")
75+
76+
# Build included index
77+
self.included_index = IncludedIndex(self.included)
78+
79+
def is_collection(self) -> bool:
80+
"""Check if data is a collection (list) or single resource."""
81+
return isinstance(self.data, list)

0 commit comments

Comments
 (0)