Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
253 changes: 126 additions & 127 deletions poetry.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ packages = [{include = "shade", from = "src"}]
python = "^3.10"
httpx = "^0.28.1"
stellar-sdk = "^13.2.1"
pydantic = "^2.0"

[tool.poetry.group.dev.dependencies]
pytest = "^7.4.0"
Expand Down
2 changes: 2 additions & 0 deletions src/shade/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
RateLimitError,
ShadeError,
)
from .models import ShadeObject

__version__ = "0.1.0"

Expand All @@ -33,6 +34,7 @@
"RateLimitError",
"ShadeClient",
"ShadeError",
"ShadeObject",
"SyncHTTPClient",
"config",
"api_base",
Expand Down
6 changes: 6 additions & 0 deletions src/shade/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""
Shade API response models.
"""
from .base import ShadeObject

__all__ = ["ShadeObject"]
86 changes: 86 additions & 0 deletions src/shade/models/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""
Base model shared by every Shade API response object.
"""
from __future__ import annotations

from typing import Any, ClassVar, Optional

from pydantic import BaseModel, ConfigDict, ValidationError

from ..errors import InvalidRequestError


class ShadeObject(BaseModel):
"""Base class for API response models.

Subclasses get dict round-tripping (:meth:`from_dict` / :meth:`to_dict`) and a
readable ``repr``. Unknown fields returned by the API are preserved rather than
rejected, so a server-side addition never breaks an older SDK.

Attributes:
_id_field: Name of the field shown in ``repr``. Subclasses that do not use
a plain ``id`` should override it (e.g. ``_id_field = "invoice_id"``).
"""

model_config = ConfigDict(populate_by_name=True, extra="allow")

_id_field: ClassVar[str] = "id"

def __init__(self, **data: Any) -> None:
try:
super().__init__(**data)
except ValidationError as exc:
raise _as_invalid_request(type(self), exc) from exc

@classmethod
def from_dict(cls, data: dict) -> "ShadeObject":
"""Build a model from a decoded JSON object.

Raises:
InvalidRequestError: If ``data`` is not a dict or fails validation.
"""
if not isinstance(data, dict):
raise InvalidRequestError(
f"{cls.__name__}.from_dict() expects a dict, got "
f"{type(data).__name__}"
)
return cls(**data)

def to_dict(self, **kwargs: Any) -> dict:
"""Return the model as a plain dict, keyed by field alias where one exists.

Any keyword arguments are forwarded to ``model_dump``.
"""
kwargs.setdefault("by_alias", True)
return self.model_dump(**kwargs)

def __repr__(self) -> str:
identifier = self._identifier()
if identifier is None:
return f"<{type(self).__name__}>"
return f"<{type(self).__name__} {self._id_field}={identifier!r}>"

def _identifier(self) -> Optional[Any]:
"""Value of the model's primary ID field, or ``None`` when it has none."""
return getattr(self, self._id_field, None)


def _as_invalid_request(
model: type[BaseModel],
exc: ValidationError,
) -> InvalidRequestError:
"""Translate a pydantic ValidationError into the SDK's InvalidRequestError."""
errors = exc.errors()
field_errors: dict[str, list[str]] = {}
for error in errors:
location = ".".join(str(part) for part in error["loc"]) or "__root__"
field_errors.setdefault(location, []).append(error["msg"])

count = len(errors)
plural = "" if count == 1 else "s"
first = next(iter(field_errors), None)
return InvalidRequestError(
f"{count} validation error{plural} for {model.__name__}",
param=first,
field_errors=field_errors,
)
113 changes: 113 additions & 0 deletions tests/test_models_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
from datetime import datetime
from typing import ClassVar, Optional

import pytest
from pydantic import Field

from shade import InvalidRequestError, ShadeObject


class Payment(ShadeObject):
id: str
amount: int
currency: str = "USDC"
created_at: Optional[datetime] = None


class Invoice(ShadeObject):
_id_field: ClassVar[str] = "invoice_id"

invoice_id: str = Field(alias="invoiceId")
total: int


class Account(ShadeObject):
name: str


def test_from_dict_builds_model():
payment = Payment.from_dict({"id": "pay_1", "amount": 500})

assert payment.id == "pay_1"
assert payment.amount == 500
assert payment.currency == "USDC"


def test_round_trip_preserves_data():
data = {
"id": "pay_1",
"amount": 500,
"currency": "XLM",
"created_at": datetime(2026, 7, 22, 12, 30),
}
payment = Payment.from_dict(data)

assert Payment.from_dict(payment.to_dict()).to_dict() == payment.to_dict()
assert payment.to_dict() == data


def test_round_trip_preserves_aliased_fields():
invoice = Invoice.from_dict({"invoiceId": "inv_1", "total": 900})

dumped = invoice.to_dict()
assert dumped == {"invoiceId": "inv_1", "total": 900}
assert Invoice.from_dict(dumped).invoice_id == "inv_1"


def test_aliased_fields_also_accept_the_field_name():
invoice = Invoice.from_dict({"invoice_id": "inv_2", "total": 10})

assert invoice.invoice_id == "inv_2"


def test_unknown_fields_are_accepted_and_preserved():
payment = Payment.from_dict(
{"id": "pay_1", "amount": 500, "settlement_network": "stellar"}
)

assert payment.settlement_network == "stellar"
assert payment.to_dict()["settlement_network"] == "stellar"


def test_type_coercion():
payment = Payment.from_dict({"id": "pay_1", "amount": "500"})

assert payment.amount == 500


def test_repr_shows_class_name_and_id():
payment = Payment.from_dict({"id": "pay_1", "amount": 500})

assert repr(payment) == "<Payment id='pay_1'>"


def test_repr_uses_overridden_id_field():
invoice = Invoice.from_dict({"invoiceId": "inv_1", "total": 900})

assert repr(invoice) == "<Invoice invoice_id='inv_1'>"


def test_repr_without_an_id_field():
assert repr(Account.from_dict({"name": "acme"})) == "<Account>"


def test_validation_error_surfaces_as_invalid_request_error():
with pytest.raises(InvalidRequestError) as excinfo:
Payment.from_dict({"id": "pay_1"})

error = excinfo.value
assert "1 validation error for Payment" in str(error)
assert error.param == "amount"
assert "amount" in error.field_errors


def test_validation_error_on_direct_construction():
with pytest.raises(InvalidRequestError):
Payment(id="pay_1", amount="not-a-number")


def test_from_dict_rejects_non_dict_input():
with pytest.raises(InvalidRequestError) as excinfo:
Payment.from_dict(["id", "pay_1"])

assert "expects a dict" in str(excinfo.value)
Loading