Skip to content

Commit 1704937

Browse files
authored
Merge pull request #47 from KodeSage/feat/create_pydantic
chores: creates pydantic base model for response objects
2 parents 0cf3f50 + 89fde73 commit 1704937

6 files changed

Lines changed: 334 additions & 127 deletions

File tree

poetry.lock

Lines changed: 126 additions & 127 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ packages = [{include = "shade", from = "src"}]
1010
python = "^3.10"
1111
httpx = "^0.28.1"
1212
stellar-sdk = "^13.2.1"
13+
pydantic = "^2.0"
1314

1415
[tool.poetry.group.dev.dependencies]
1516
pytest = "^7.4.0"

src/shade/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
RateLimitError,
1616
ShadeError,
1717
)
18+
from .models import ShadeObject
1819

1920
__version__ = "0.1.0"
2021

@@ -33,6 +34,7 @@
3334
"RateLimitError",
3435
"ShadeClient",
3536
"ShadeError",
37+
"ShadeObject",
3638
"SyncHTTPClient",
3739
"config",
3840
"api_base",

src/shade/models/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""
2+
Shade API response models.
3+
"""
4+
from .base import ShadeObject
5+
6+
__all__ = ["ShadeObject"]

src/shade/models/base.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""
2+
Base model shared by every Shade API response object.
3+
"""
4+
from __future__ import annotations
5+
6+
from typing import Any, ClassVar, Optional
7+
8+
from pydantic import BaseModel, ConfigDict, ValidationError
9+
10+
from ..errors import InvalidRequestError
11+
12+
13+
class ShadeObject(BaseModel):
14+
"""Base class for API response models.
15+
16+
Subclasses get dict round-tripping (:meth:`from_dict` / :meth:`to_dict`) and a
17+
readable ``repr``. Unknown fields returned by the API are preserved rather than
18+
rejected, so a server-side addition never breaks an older SDK.
19+
20+
Attributes:
21+
_id_field: Name of the field shown in ``repr``. Subclasses that do not use
22+
a plain ``id`` should override it (e.g. ``_id_field = "invoice_id"``).
23+
"""
24+
25+
model_config = ConfigDict(populate_by_name=True, extra="allow")
26+
27+
_id_field: ClassVar[str] = "id"
28+
29+
def __init__(self, **data: Any) -> None:
30+
try:
31+
super().__init__(**data)
32+
except ValidationError as exc:
33+
raise _as_invalid_request(type(self), exc) from exc
34+
35+
@classmethod
36+
def from_dict(cls, data: dict) -> "ShadeObject":
37+
"""Build a model from a decoded JSON object.
38+
39+
Raises:
40+
InvalidRequestError: If ``data`` is not a dict or fails validation.
41+
"""
42+
if not isinstance(data, dict):
43+
raise InvalidRequestError(
44+
f"{cls.__name__}.from_dict() expects a dict, got "
45+
f"{type(data).__name__}"
46+
)
47+
return cls(**data)
48+
49+
def to_dict(self, **kwargs: Any) -> dict:
50+
"""Return the model as a plain dict, keyed by field alias where one exists.
51+
52+
Any keyword arguments are forwarded to ``model_dump``.
53+
"""
54+
kwargs.setdefault("by_alias", True)
55+
return self.model_dump(**kwargs)
56+
57+
def __repr__(self) -> str:
58+
identifier = self._identifier()
59+
if identifier is None:
60+
return f"<{type(self).__name__}>"
61+
return f"<{type(self).__name__} {self._id_field}={identifier!r}>"
62+
63+
def _identifier(self) -> Optional[Any]:
64+
"""Value of the model's primary ID field, or ``None`` when it has none."""
65+
return getattr(self, self._id_field, None)
66+
67+
68+
def _as_invalid_request(
69+
model: type[BaseModel],
70+
exc: ValidationError,
71+
) -> InvalidRequestError:
72+
"""Translate a pydantic ValidationError into the SDK's InvalidRequestError."""
73+
errors = exc.errors()
74+
field_errors: dict[str, list[str]] = {}
75+
for error in errors:
76+
location = ".".join(str(part) for part in error["loc"]) or "__root__"
77+
field_errors.setdefault(location, []).append(error["msg"])
78+
79+
count = len(errors)
80+
plural = "" if count == 1 else "s"
81+
first = next(iter(field_errors), None)
82+
return InvalidRequestError(
83+
f"{count} validation error{plural} for {model.__name__}",
84+
param=first,
85+
field_errors=field_errors,
86+
)

tests/test_models_base.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
from datetime import datetime
2+
from typing import ClassVar, Optional
3+
4+
import pytest
5+
from pydantic import Field
6+
7+
from shade import InvalidRequestError, ShadeObject
8+
9+
10+
class Payment(ShadeObject):
11+
id: str
12+
amount: int
13+
currency: str = "USDC"
14+
created_at: Optional[datetime] = None
15+
16+
17+
class Invoice(ShadeObject):
18+
_id_field: ClassVar[str] = "invoice_id"
19+
20+
invoice_id: str = Field(alias="invoiceId")
21+
total: int
22+
23+
24+
class Account(ShadeObject):
25+
name: str
26+
27+
28+
def test_from_dict_builds_model():
29+
payment = Payment.from_dict({"id": "pay_1", "amount": 500})
30+
31+
assert payment.id == "pay_1"
32+
assert payment.amount == 500
33+
assert payment.currency == "USDC"
34+
35+
36+
def test_round_trip_preserves_data():
37+
data = {
38+
"id": "pay_1",
39+
"amount": 500,
40+
"currency": "XLM",
41+
"created_at": datetime(2026, 7, 22, 12, 30),
42+
}
43+
payment = Payment.from_dict(data)
44+
45+
assert Payment.from_dict(payment.to_dict()).to_dict() == payment.to_dict()
46+
assert payment.to_dict() == data
47+
48+
49+
def test_round_trip_preserves_aliased_fields():
50+
invoice = Invoice.from_dict({"invoiceId": "inv_1", "total": 900})
51+
52+
dumped = invoice.to_dict()
53+
assert dumped == {"invoiceId": "inv_1", "total": 900}
54+
assert Invoice.from_dict(dumped).invoice_id == "inv_1"
55+
56+
57+
def test_aliased_fields_also_accept_the_field_name():
58+
invoice = Invoice.from_dict({"invoice_id": "inv_2", "total": 10})
59+
60+
assert invoice.invoice_id == "inv_2"
61+
62+
63+
def test_unknown_fields_are_accepted_and_preserved():
64+
payment = Payment.from_dict(
65+
{"id": "pay_1", "amount": 500, "settlement_network": "stellar"}
66+
)
67+
68+
assert payment.settlement_network == "stellar"
69+
assert payment.to_dict()["settlement_network"] == "stellar"
70+
71+
72+
def test_type_coercion():
73+
payment = Payment.from_dict({"id": "pay_1", "amount": "500"})
74+
75+
assert payment.amount == 500
76+
77+
78+
def test_repr_shows_class_name_and_id():
79+
payment = Payment.from_dict({"id": "pay_1", "amount": 500})
80+
81+
assert repr(payment) == "<Payment id='pay_1'>"
82+
83+
84+
def test_repr_uses_overridden_id_field():
85+
invoice = Invoice.from_dict({"invoiceId": "inv_1", "total": 900})
86+
87+
assert repr(invoice) == "<Invoice invoice_id='inv_1'>"
88+
89+
90+
def test_repr_without_an_id_field():
91+
assert repr(Account.from_dict({"name": "acme"})) == "<Account>"
92+
93+
94+
def test_validation_error_surfaces_as_invalid_request_error():
95+
with pytest.raises(InvalidRequestError) as excinfo:
96+
Payment.from_dict({"id": "pay_1"})
97+
98+
error = excinfo.value
99+
assert "1 validation error for Payment" in str(error)
100+
assert error.param == "amount"
101+
assert "amount" in error.field_errors
102+
103+
104+
def test_validation_error_on_direct_construction():
105+
with pytest.raises(InvalidRequestError):
106+
Payment(id="pay_1", amount="not-a-number")
107+
108+
109+
def test_from_dict_rejects_non_dict_input():
110+
with pytest.raises(InvalidRequestError) as excinfo:
111+
Payment.from_dict(["id", "pay_1"])
112+
113+
assert "expects a dict" in str(excinfo.value)

0 commit comments

Comments
 (0)