From ff6c8da4087bb135c66a4bfddfe7d2e5c2e57a7f Mon Sep 17 00:00:00 2001 From: Gift Amadi <120387225+giftexceed@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:25:55 +0000 Subject: [PATCH 1/5] feat: implement Merchant model mirroring backend Prisma schema Add ShadeObject base class and Merchant model with camelCase (JSON) <-> snake_case (Python) mapping via a per-class alias table and a from_dict constructor. - ShadeObject: from_dict/to_dict alias mapping, ignores unknown keys, equality and repr driven by constructor params. - Merchant: explicitly typed fields (no generic settings dict); merchant_id coerced to int; address validated as a Stellar ed25519 public key (raises InvalidRequestError on construction); display_name computed property (business_name -> full name -> email). - Export Merchant and ShadeObject from the package. - Tests covering mapping, validation, display_name and round-tripping. Closes #39 --- src/shade/__init__.py | 4 ++ src/shade/base.py | 81 ++++++++++++++++++++++++++ src/shade/merchant.py | 108 ++++++++++++++++++++++++++++++++++ tests/test_merchant.py | 128 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 321 insertions(+) create mode 100644 tests/test_merchant.py diff --git a/src/shade/__init__.py b/src/shade/__init__.py index d8a9ff7..5126750 100644 --- a/src/shade/__init__.py +++ b/src/shade/__init__.py @@ -2,10 +2,12 @@ from types import ModuleType from typing import Optional +from .base import ShadeObject from .client import ShadeClient from .config import config, Environment from .gateway import Gateway from .http import AsyncHTTPClient, SyncHTTPClient +from .merchant import Merchant from .errors import ( AuthenticationError, InvalidRequestError, @@ -28,11 +30,13 @@ "Gateway", "HTTPError", "InvalidRequestError", + "Merchant", "NetworkError", "NotFoundError", "RateLimitError", "ShadeClient", "ShadeError", + "ShadeObject", "SyncHTTPClient", "config", "api_base", diff --git a/src/shade/base.py b/src/shade/base.py index e69de29..a28e452 100644 --- a/src/shade/base.py +++ b/src/shade/base.py @@ -0,0 +1,81 @@ +""" +Base class for typed Shade API resource objects. + +The Shade backend speaks JSON with ``camelCase`` keys (mirroring its Prisma +schema). Python models expose the same data as ``snake_case`` attributes. +:class:`ShadeObject` handles that translation: each subclass declares its +``camelCase`` -> ``snake_case`` mapping in :attr:`ShadeObject._ALIASES` and is +constructed from an API payload via :meth:`ShadeObject.from_dict`. +""" +from __future__ import annotations + +import inspect +from typing import Any, ClassVar, Dict, Mapping + +from .errors import InvalidRequestError + + +class ShadeObject: + """Base for API resource models with camelCase <-> snake_case mapping. + + Subclasses populate :attr:`_ALIASES` with the JSON keys whose names differ + from their Python attribute (i.e. the multi-word, ``camelCase`` ones). + Single-word keys that already match their attribute need no entry. + """ + + # JSON (camelCase) key -> attribute (snake_case) name. + _ALIASES: ClassVar[Dict[str, str]] = {} + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "ShadeObject": + """Build an instance from a raw API response body. + + camelCase keys are translated to their snake_case attribute names via + :attr:`_ALIASES`; keys the constructor does not accept are ignored so + that additive backend changes do not break deserialization. + """ + if not isinstance(data, Mapping): + raise InvalidRequestError( + f"{cls.__name__}.from_dict expected a mapping, " + f"got {type(data).__name__}" + ) + + translated: Dict[str, Any] = {} + for key, value in data.items(): + translated[cls._ALIASES.get(key, key)] = value + + accepted = cls._constructor_params() + kwargs = {k: v for k, v in translated.items() if k in accepted} + return cls(**kwargs) + + def to_dict(self) -> Dict[str, Any]: + """Serialize back to a camelCase payload using the reverse of ``_ALIASES``.""" + reverse = {attr: key for key, attr in self._ALIASES.items()} + result: Dict[str, Any] = {} + for attr in self._constructor_params(): + result[reverse.get(attr, attr)] = getattr(self, attr) + return result + + @classmethod + def _constructor_params(cls) -> frozenset[str]: + params = inspect.signature(cls).parameters + return frozenset( + name + for name, param in params.items() + if param.kind + in ( + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + ) + ) + + def __eq__(self, other: object) -> bool: + if type(self) is not type(other): + return NotImplemented + return self.to_dict() == other.to_dict() # type: ignore[attr-defined] + + def __repr__(self) -> str: + fields = ", ".join( + f"{attr}={getattr(self, attr)!r}" for attr in self._constructor_params() + ) + return f"{type(self).__name__}({fields})" diff --git a/src/shade/merchant.py b/src/shade/merchant.py index e69de29..97da65f 100644 --- a/src/shade/merchant.py +++ b/src/shade/merchant.py @@ -0,0 +1,108 @@ +""" +Merchant model. + +Mirrors the Shade backend's Prisma ``Merchant`` schema, with field names +converted from ``camelCase`` (Prisma/JSON) to ``snake_case`` (Python). The +:attr:`Merchant.merchant_id` field (from Prisma ``merchantId: Int``) is the +numeric identifier the Soroban contract stamps onto every invoice, making it +the bridge between the backend and the on-chain world. +""" +from __future__ import annotations + +from typing import Any, ClassVar, Dict, Optional + +from stellar_sdk.strkey import StrKey + +from .base import ShadeObject +from .errors import InvalidRequestError + + +class Merchant(ShadeObject): + """A Shade merchant account. + + Construct from an API response with :meth:`ShadeObject.from_dict`, which + maps camelCase JSON keys to the snake_case attributes below. The + ``address`` must be a valid Stellar ed25519 public key; anything else + raises :class:`~shade.errors.InvalidRequestError` on construction. + """ + + _ALIASES: ClassVar[Dict[str, str]] = { + "merchantId": "merchant_id", + "firstName": "first_name", + "lastName": "last_name", + "businessName": "business_name", + } + + def __init__( + self, + *, + id: str, + merchant_id: int, + address: str, + active: bool, + verified: bool, + account: Optional[str] = None, + email: Optional[str] = None, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + business_name: Optional[str] = None, + category: Optional[str] = None, + description: Optional[str] = None, + logo: Optional[str] = None, + webhook: Optional[str] = None, + ) -> None: + self.id: str = id + self.merchant_id: int = _coerce_merchant_id(merchant_id) + self.address: str = _validate_stellar_address(address) + self.active: bool = bool(active) + self.verified: bool = bool(verified) + self.account: Optional[str] = account + self.email: Optional[str] = email + self.first_name: Optional[str] = first_name + self.last_name: Optional[str] = last_name + self.business_name: Optional[str] = business_name + self.category: Optional[str] = category + self.description: Optional[str] = description + self.logo: Optional[str] = logo + self.webhook: Optional[str] = webhook + + @property + def display_name(self) -> Optional[str]: + """The most informative human-readable name available. + + Prefers ``business_name``; falls back to the person's full name + (``"{first_name} {last_name}"`` trimmed); finally ``email``. + """ + if self.business_name: + return self.business_name + full_name = f"{self.first_name or ''} {self.last_name or ''}".strip() + if full_name: + return full_name + return self.email + + +def _coerce_merchant_id(value: Any) -> int: + """Return ``value`` as an ``int``, rejecting bools and non-integers.""" + if isinstance(value, bool) or not isinstance(value, (int, str)): + raise InvalidRequestError( + f"merchant_id must be an integer, got {type(value).__name__}", + param="merchant_id", + ) + try: + return int(value) + except (TypeError, ValueError): + raise InvalidRequestError( + f"merchant_id must be an integer, got {value!r}", + param="merchant_id", + ) + + +def _validate_stellar_address(address: Any) -> str: + """Validate a Stellar ed25519 public key (starts with ``G``, 56 chars).""" + if not isinstance(address, str) or not StrKey.is_valid_ed25519_public_key(address): + raise InvalidRequestError( + f"address must be a valid Stellar public key " + f"(starts with 'G', 56 characters), got {address!r}", + param="address", + ) + return address diff --git a/tests/test_merchant.py b/tests/test_merchant.py new file mode 100644 index 0000000..d2b5a27 --- /dev/null +++ b/tests/test_merchant.py @@ -0,0 +1,128 @@ +import pytest +from stellar_sdk import Keypair + +import shade +from shade import InvalidRequestError, Merchant, ShadeObject + +VALID_ADDRESS = Keypair.random().public_key + + +def _api_response(**overrides): + """A representative camelCase backend payload.""" + data = { + "id": "clx123", + "merchantId": 42, + "address": VALID_ADDRESS, + "account": "GACCOUNT", + "email": "owner@acme.test", + "firstName": "Ada", + "lastName": "Lovelace", + "businessName": "Acme Payments", + "category": "software", + "description": "We take money.", + "logo": "https://cdn.test/logo.png", + "webhook": "https://acme.test/hooks", + "active": True, + "verified": True, + } + data.update(overrides) + return data + + +def test_from_dict_maps_camelcase_to_snake_case(): + merchant = Merchant.from_dict(_api_response()) + + assert merchant.id == "clx123" + assert merchant.merchant_id == 42 + assert merchant.address == VALID_ADDRESS + assert merchant.first_name == "Ada" + assert merchant.last_name == "Lovelace" + assert merchant.business_name == "Acme Payments" + assert merchant.active is True + assert merchant.verified is True + + +def test_merchant_id_is_int(): + merchant = Merchant.from_dict(_api_response(merchantId=7)) + assert isinstance(merchant.merchant_id, int) + assert merchant.merchant_id == 7 + + +def test_merchant_is_exported_from_package(): + assert shade.Merchant is Merchant + assert issubclass(Merchant, ShadeObject) + + +def test_from_dict_ignores_unknown_keys(): + merchant = Merchant.from_dict(_api_response(createdAt="2026-01-01", extra="x")) + assert merchant.merchant_id == 42 + + +def test_from_dict_requires_a_mapping(): + with pytest.raises(InvalidRequestError): + Merchant.from_dict([("id", "x")]) # type: ignore[arg-type] + + +def test_invalid_address_raises_on_construction(): + with pytest.raises(InvalidRequestError) as exc_info: + Merchant.from_dict(_api_response(address="not-a-stellar-key")) + assert exc_info.value.param == "address" + + +def test_address_wrong_length_is_rejected(): + with pytest.raises(InvalidRequestError): + Merchant( + id="x", + merchant_id=1, + address="G" + "A" * 55, # starts with G but too short / bad checksum + active=True, + verified=False, + ) + + +def test_non_integer_merchant_id_raises(): + with pytest.raises(InvalidRequestError) as exc_info: + Merchant.from_dict(_api_response(merchantId="abc")) + assert exc_info.value.param == "merchant_id" + + +def test_display_name_prefers_business_name(): + merchant = Merchant.from_dict(_api_response()) + assert merchant.display_name == "Acme Payments" + + +def test_display_name_falls_back_to_full_name(): + merchant = Merchant.from_dict(_api_response(businessName=None)) + assert merchant.display_name == "Ada Lovelace" + + +def test_display_name_trims_missing_last_name(): + merchant = Merchant.from_dict(_api_response(businessName=None, lastName=None)) + assert merchant.display_name == "Ada" + + +def test_display_name_falls_back_to_email(): + merchant = Merchant.from_dict( + _api_response(businessName=None, firstName=None, lastName=None) + ) + assert merchant.display_name == "owner@acme.test" + + +def test_optional_fields_default_to_none(): + merchant = Merchant( + id="x", + merchant_id=1, + address=VALID_ADDRESS, + active=False, + verified=False, + ) + assert merchant.account is None + assert merchant.email is None + assert merchant.display_name is None + + +def test_to_dict_round_trips_to_camelcase(): + payload = _api_response() + merchant = Merchant.from_dict(payload) + assert merchant.to_dict() == payload + assert Merchant.from_dict(merchant.to_dict()) == merchant From df8876dc385d168cee0b3829d9b54971d44e98c3 Mon Sep 17 00:00:00 2001 From: Gift Amadi <120387225+giftexceed@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:54:11 +0000 Subject: [PATCH 2/5] fix: reject non-boolean active/verified and suppress A002 on id Address maintainer review feedback on #45. - Replace bool() coercion of active/verified with a strict _require_bool check. bool("false") is True, so a malformed payload or a direct caller could silently flip a flag; non-bool values now raise InvalidRequestError with the offending param. - Add a targeted "noqa: A002" on the id parameter, keeping the schema-compatible public field name while satisfying the builtin-shadowing lint. - Add regression coverage asserting "false"/"true"/""/0/1/None are rejected for both flags, and that real booleans are preserved. --- src/shade/merchant.py | 20 +++++++++++++++++--- tests/test_merchant.py | 15 +++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/shade/merchant.py b/src/shade/merchant.py index 97da65f..edca765 100644 --- a/src/shade/merchant.py +++ b/src/shade/merchant.py @@ -36,7 +36,7 @@ class Merchant(ShadeObject): def __init__( self, *, - id: str, + id: str, # noqa: A002 - required API field name merchant_id: int, address: str, active: bool, @@ -54,8 +54,8 @@ def __init__( self.id: str = id self.merchant_id: int = _coerce_merchant_id(merchant_id) self.address: str = _validate_stellar_address(address) - self.active: bool = bool(active) - self.verified: bool = bool(verified) + self.active: bool = _require_bool(active, "active") + self.verified: bool = _require_bool(verified, "verified") self.account: Optional[str] = account self.email: Optional[str] = email self.first_name: Optional[str] = first_name @@ -81,6 +81,20 @@ def display_name(self) -> Optional[str]: return self.email +def _require_bool(value: Any, param: str) -> bool: + """Return ``value`` only if it is a real ``bool``. + + Coercing here would be unsafe: ``bool("false")`` is ``True``, so a + malformed payload could silently flip a flag like ``active``. + """ + if not isinstance(value, bool): + raise InvalidRequestError( + f"{param} must be a boolean, got {value!r}", + param=param, + ) + return value + + def _coerce_merchant_id(value: Any) -> int: """Return ``value`` as an ``int``, rejecting bools and non-integers.""" if isinstance(value, bool) or not isinstance(value, (int, str)): diff --git a/tests/test_merchant.py b/tests/test_merchant.py index d2b5a27..75e0475 100644 --- a/tests/test_merchant.py +++ b/tests/test_merchant.py @@ -86,6 +86,21 @@ def test_non_integer_merchant_id_raises(): assert exc_info.value.param == "merchant_id" +@pytest.mark.parametrize("field", ["active", "verified"]) +@pytest.mark.parametrize("value", ["false", "true", "", 0, 1, None]) +def test_non_boolean_flags_are_rejected(field, value): + """Strings like "false" must not be silently coerced to True.""" + with pytest.raises(InvalidRequestError) as exc_info: + Merchant.from_dict(_api_response(**{field: value})) + assert exc_info.value.param == field + + +def test_boolean_flags_are_preserved(): + merchant = Merchant.from_dict(_api_response(active=False, verified=True)) + assert merchant.active is False + assert merchant.verified is True + + def test_display_name_prefers_business_name(): merchant = Merchant.from_dict(_api_response()) assert merchant.display_name == "Acme Payments" From 8a802b797ecae2aa006b59c714080d67fbadaf05 Mon Sep 17 00:00:00 2001 From: Gift Amadi <120387225+giftexceed@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:04:36 +0000 Subject: [PATCH 3/5] fix: ignore whitespace-only candidates in display_name Address maintainer review feedback on #45. A whitespace-only business_name such as " " passed the truthiness check and was returned verbatim, skipping the full-name and email fallbacks. Each candidate is now trimmed before it is tested and returned, so blank values fall through to the next one. The same latent bug applied to email, which was returned unnormalized as the final fallback; it is now trimmed too, and display_name yields None when every candidate is blank. The full-name branch already stripped. Add regression coverage for whitespace-only business_name, whitespace-only first/last names, an all-blank merchant, and trimming of the returned value. --- src/shade/merchant.py | 11 +++++++---- tests/test_merchant.py | 25 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/shade/merchant.py b/src/shade/merchant.py index edca765..b113298 100644 --- a/src/shade/merchant.py +++ b/src/shade/merchant.py @@ -71,14 +71,17 @@ def display_name(self) -> Optional[str]: """The most informative human-readable name available. Prefers ``business_name``; falls back to the person's full name - (``"{first_name} {last_name}"`` trimmed); finally ``email``. + (``"{first_name} {last_name}"``); finally ``email``. Each candidate is + trimmed, so a blank or whitespace-only value falls through to the next + one rather than being returned. ``None`` when nothing is available. """ - if self.business_name: - return self.business_name + business_name = (self.business_name or "").strip() + if business_name: + return business_name full_name = f"{self.first_name or ''} {self.last_name or ''}".strip() if full_name: return full_name - return self.email + return (self.email or "").strip() or None def _require_bool(value: Any, param: str) -> bool: diff --git a/tests/test_merchant.py b/tests/test_merchant.py index 75e0475..da82031 100644 --- a/tests/test_merchant.py +++ b/tests/test_merchant.py @@ -123,6 +123,31 @@ def test_display_name_falls_back_to_email(): assert merchant.display_name == "owner@acme.test" +def test_display_name_ignores_whitespace_only_business_name(): + """A blank business_name must fall through, not be returned verbatim.""" + merchant = Merchant.from_dict(_api_response(businessName=" ")) + assert merchant.display_name == "Ada Lovelace" + + +def test_display_name_ignores_whitespace_only_names(): + merchant = Merchant.from_dict( + _api_response(businessName="", firstName=" ", lastName="\t") + ) + assert merchant.display_name == "owner@acme.test" + + +def test_display_name_is_none_when_every_candidate_is_blank(): + merchant = Merchant.from_dict( + _api_response(businessName=" ", firstName="", lastName=None, email=" ") + ) + assert merchant.display_name is None + + +def test_display_name_trims_the_returned_value(): + merchant = Merchant.from_dict(_api_response(businessName=" Acme Payments ")) + assert merchant.display_name == "Acme Payments" + + def test_optional_fields_default_to_none(): merchant = Merchant( id="x", From 27304beb77387f204a1fec852bd135ff60f43969 Mon Sep 17 00:00:00 2001 From: Gift Amadi <120387225+giftexceed@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:55:48 +0000 Subject: [PATCH 4/5] refactor: move Merchant into models/ on the pydantic ShadeObject base Rebuild the Merchant model on the shared pydantic ShadeObject introduced on main (#47), replacing the standalone plain-Python model and base. - Move src/shade/merchant.py -> src/shade/models/merchant.py and delete the now-redundant src/shade/base.py. - Map camelCase JSON to snake_case fields with pydantic Field(alias=...); from_dict / to_dict / repr come from ShadeObject. - Enforce validation via pydantic: StrictBool for active/verified (no silent coercion of strings like "false"), a Stellar public-key field_validator on address, and a before-validator rejecting boolean merchant_id (which pydantic would otherwise coerce to 1/0). All surface as InvalidRequestError through the base. - Export Merchant from shade.models and the top-level package. - Update tests: unknown keys are now preserved (extra="allow"), the merchant_id error param is the "merchantId" alias, and add a boolean merchant_id rejection case. --- src/shade/__init__.py | 4 +- src/shade/base.py | 81 ----------------------- src/shade/merchant.py | 125 ----------------------------------- src/shade/models/__init__.py | 3 +- src/shade/models/merchant.py | 79 ++++++++++++++++++++++ tests/test_merchant.py | 16 ++++- 6 files changed, 95 insertions(+), 213 deletions(-) delete mode 100644 src/shade/base.py delete mode 100644 src/shade/merchant.py create mode 100644 src/shade/models/merchant.py diff --git a/src/shade/__init__.py b/src/shade/__init__.py index 71d4cf4..eab2a06 100644 --- a/src/shade/__init__.py +++ b/src/shade/__init__.py @@ -2,12 +2,10 @@ from types import ModuleType from typing import Optional -from .base import ShadeObject from .client import ShadeClient from .config import config, Environment from .gateway import Gateway from .http import AsyncHTTPClient, SyncHTTPClient -from .merchant import Merchant from .errors import ( AuthenticationError, InvalidRequestError, @@ -17,7 +15,7 @@ RateLimitError, ShadeError, ) -from .models import ShadeObject +from .models import Merchant, ShadeObject __version__ = "0.1.0" diff --git a/src/shade/base.py b/src/shade/base.py deleted file mode 100644 index a28e452..0000000 --- a/src/shade/base.py +++ /dev/null @@ -1,81 +0,0 @@ -""" -Base class for typed Shade API resource objects. - -The Shade backend speaks JSON with ``camelCase`` keys (mirroring its Prisma -schema). Python models expose the same data as ``snake_case`` attributes. -:class:`ShadeObject` handles that translation: each subclass declares its -``camelCase`` -> ``snake_case`` mapping in :attr:`ShadeObject._ALIASES` and is -constructed from an API payload via :meth:`ShadeObject.from_dict`. -""" -from __future__ import annotations - -import inspect -from typing import Any, ClassVar, Dict, Mapping - -from .errors import InvalidRequestError - - -class ShadeObject: - """Base for API resource models with camelCase <-> snake_case mapping. - - Subclasses populate :attr:`_ALIASES` with the JSON keys whose names differ - from their Python attribute (i.e. the multi-word, ``camelCase`` ones). - Single-word keys that already match their attribute need no entry. - """ - - # JSON (camelCase) key -> attribute (snake_case) name. - _ALIASES: ClassVar[Dict[str, str]] = {} - - @classmethod - def from_dict(cls, data: Mapping[str, Any]) -> "ShadeObject": - """Build an instance from a raw API response body. - - camelCase keys are translated to their snake_case attribute names via - :attr:`_ALIASES`; keys the constructor does not accept are ignored so - that additive backend changes do not break deserialization. - """ - if not isinstance(data, Mapping): - raise InvalidRequestError( - f"{cls.__name__}.from_dict expected a mapping, " - f"got {type(data).__name__}" - ) - - translated: Dict[str, Any] = {} - for key, value in data.items(): - translated[cls._ALIASES.get(key, key)] = value - - accepted = cls._constructor_params() - kwargs = {k: v for k, v in translated.items() if k in accepted} - return cls(**kwargs) - - def to_dict(self) -> Dict[str, Any]: - """Serialize back to a camelCase payload using the reverse of ``_ALIASES``.""" - reverse = {attr: key for key, attr in self._ALIASES.items()} - result: Dict[str, Any] = {} - for attr in self._constructor_params(): - result[reverse.get(attr, attr)] = getattr(self, attr) - return result - - @classmethod - def _constructor_params(cls) -> frozenset[str]: - params = inspect.signature(cls).parameters - return frozenset( - name - for name, param in params.items() - if param.kind - in ( - inspect.Parameter.POSITIONAL_OR_KEYWORD, - inspect.Parameter.KEYWORD_ONLY, - ) - ) - - def __eq__(self, other: object) -> bool: - if type(self) is not type(other): - return NotImplemented - return self.to_dict() == other.to_dict() # type: ignore[attr-defined] - - def __repr__(self) -> str: - fields = ", ".join( - f"{attr}={getattr(self, attr)!r}" for attr in self._constructor_params() - ) - return f"{type(self).__name__}({fields})" diff --git a/src/shade/merchant.py b/src/shade/merchant.py deleted file mode 100644 index b113298..0000000 --- a/src/shade/merchant.py +++ /dev/null @@ -1,125 +0,0 @@ -""" -Merchant model. - -Mirrors the Shade backend's Prisma ``Merchant`` schema, with field names -converted from ``camelCase`` (Prisma/JSON) to ``snake_case`` (Python). The -:attr:`Merchant.merchant_id` field (from Prisma ``merchantId: Int``) is the -numeric identifier the Soroban contract stamps onto every invoice, making it -the bridge between the backend and the on-chain world. -""" -from __future__ import annotations - -from typing import Any, ClassVar, Dict, Optional - -from stellar_sdk.strkey import StrKey - -from .base import ShadeObject -from .errors import InvalidRequestError - - -class Merchant(ShadeObject): - """A Shade merchant account. - - Construct from an API response with :meth:`ShadeObject.from_dict`, which - maps camelCase JSON keys to the snake_case attributes below. The - ``address`` must be a valid Stellar ed25519 public key; anything else - raises :class:`~shade.errors.InvalidRequestError` on construction. - """ - - _ALIASES: ClassVar[Dict[str, str]] = { - "merchantId": "merchant_id", - "firstName": "first_name", - "lastName": "last_name", - "businessName": "business_name", - } - - def __init__( - self, - *, - id: str, # noqa: A002 - required API field name - merchant_id: int, - address: str, - active: bool, - verified: bool, - account: Optional[str] = None, - email: Optional[str] = None, - first_name: Optional[str] = None, - last_name: Optional[str] = None, - business_name: Optional[str] = None, - category: Optional[str] = None, - description: Optional[str] = None, - logo: Optional[str] = None, - webhook: Optional[str] = None, - ) -> None: - self.id: str = id - self.merchant_id: int = _coerce_merchant_id(merchant_id) - self.address: str = _validate_stellar_address(address) - self.active: bool = _require_bool(active, "active") - self.verified: bool = _require_bool(verified, "verified") - self.account: Optional[str] = account - self.email: Optional[str] = email - self.first_name: Optional[str] = first_name - self.last_name: Optional[str] = last_name - self.business_name: Optional[str] = business_name - self.category: Optional[str] = category - self.description: Optional[str] = description - self.logo: Optional[str] = logo - self.webhook: Optional[str] = webhook - - @property - def display_name(self) -> Optional[str]: - """The most informative human-readable name available. - - Prefers ``business_name``; falls back to the person's full name - (``"{first_name} {last_name}"``); finally ``email``. Each candidate is - trimmed, so a blank or whitespace-only value falls through to the next - one rather than being returned. ``None`` when nothing is available. - """ - business_name = (self.business_name or "").strip() - if business_name: - return business_name - full_name = f"{self.first_name or ''} {self.last_name or ''}".strip() - if full_name: - return full_name - return (self.email or "").strip() or None - - -def _require_bool(value: Any, param: str) -> bool: - """Return ``value`` only if it is a real ``bool``. - - Coercing here would be unsafe: ``bool("false")`` is ``True``, so a - malformed payload could silently flip a flag like ``active``. - """ - if not isinstance(value, bool): - raise InvalidRequestError( - f"{param} must be a boolean, got {value!r}", - param=param, - ) - return value - - -def _coerce_merchant_id(value: Any) -> int: - """Return ``value`` as an ``int``, rejecting bools and non-integers.""" - if isinstance(value, bool) or not isinstance(value, (int, str)): - raise InvalidRequestError( - f"merchant_id must be an integer, got {type(value).__name__}", - param="merchant_id", - ) - try: - return int(value) - except (TypeError, ValueError): - raise InvalidRequestError( - f"merchant_id must be an integer, got {value!r}", - param="merchant_id", - ) - - -def _validate_stellar_address(address: Any) -> str: - """Validate a Stellar ed25519 public key (starts with ``G``, 56 chars).""" - if not isinstance(address, str) or not StrKey.is_valid_ed25519_public_key(address): - raise InvalidRequestError( - f"address must be a valid Stellar public key " - f"(starts with 'G', 56 characters), got {address!r}", - param="address", - ) - return address diff --git a/src/shade/models/__init__.py b/src/shade/models/__init__.py index 54b221a..815f70a 100644 --- a/src/shade/models/__init__.py +++ b/src/shade/models/__init__.py @@ -2,5 +2,6 @@ Shade API response models. """ from .base import ShadeObject +from .merchant import Merchant -__all__ = ["ShadeObject"] +__all__ = ["Merchant", "ShadeObject"] diff --git a/src/shade/models/merchant.py b/src/shade/models/merchant.py new file mode 100644 index 0000000..a83c0ce --- /dev/null +++ b/src/shade/models/merchant.py @@ -0,0 +1,79 @@ +""" +Merchant model. + +Mirrors the Shade backend's Prisma ``Merchant`` schema, with field names +converted from ``camelCase`` (Prisma/JSON) to ``snake_case`` (Python) via +pydantic field aliases. The :attr:`Merchant.merchant_id` field (from Prisma +``merchantId: Int``) is the numeric identifier the Soroban contract stamps onto +every invoice, making it the bridge between the backend and the on-chain world. +""" +from __future__ import annotations + +from typing import Optional + +from pydantic import Field, StrictBool, field_validator +from stellar_sdk.strkey import StrKey + +from .base import ShadeObject + + +class Merchant(ShadeObject): + """A Shade merchant account. + + Build one from an API response with :meth:`ShadeObject.from_dict`, which maps + camelCase JSON keys to the snake_case fields below. ``address`` must be a + valid Stellar ed25519 public key and ``active`` / ``verified`` must be real + booleans; anything else raises + :class:`~shade.errors.InvalidRequestError` on construction. + """ + + id: str + merchant_id: int = Field(alias="merchantId") + address: str + active: StrictBool + verified: StrictBool + account: Optional[str] = None + email: Optional[str] = None + first_name: Optional[str] = Field(default=None, alias="firstName") + last_name: Optional[str] = Field(default=None, alias="lastName") + business_name: Optional[str] = Field(default=None, alias="businessName") + category: Optional[str] = None + description: Optional[str] = None + logo: Optional[str] = None + webhook: Optional[str] = None + + @field_validator("merchant_id", mode="before") + @classmethod + def _reject_bool_merchant_id(cls, value: object) -> object: + # pydantic would otherwise coerce ``True``/``False`` to 1/0; a boolean is + # never a valid merchant id, so reject it rather than silently accept it. + if isinstance(value, bool): + raise ValueError("merchant_id must be an integer, not a boolean") + return value + + @field_validator("address") + @classmethod + def _validate_address(cls, value: str) -> str: + if not StrKey.is_valid_ed25519_public_key(value): + raise ValueError( + "address must be a valid Stellar public key " + "(starts with 'G', 56 characters)" + ) + return value + + @property + def display_name(self) -> Optional[str]: + """The most informative human-readable name available. + + Prefers ``business_name``; falls back to the person's full name + (``"{first_name} {last_name}"``); finally ``email``. Each candidate is + trimmed, so a blank or whitespace-only value falls through to the next + one rather than being returned. ``None`` when nothing is available. + """ + business_name = (self.business_name or "").strip() + if business_name: + return business_name + full_name = f"{self.first_name or ''} {self.last_name or ''}".strip() + if full_name: + return full_name + return (self.email or "").strip() or None diff --git a/tests/test_merchant.py b/tests/test_merchant.py index da82031..4c0d3d2 100644 --- a/tests/test_merchant.py +++ b/tests/test_merchant.py @@ -53,9 +53,12 @@ def test_merchant_is_exported_from_package(): assert issubclass(Merchant, ShadeObject) -def test_from_dict_ignores_unknown_keys(): - merchant = Merchant.from_dict(_api_response(createdAt="2026-01-01", extra="x")) +def test_from_dict_preserves_unknown_keys(): + # The ShadeObject base allows extra fields so a server-side addition never + # breaks an older SDK; the known fields still map correctly. + merchant = Merchant.from_dict(_api_response(createdAt="2026-01-01")) assert merchant.merchant_id == 42 + assert merchant.to_dict()["createdAt"] == "2026-01-01" def test_from_dict_requires_a_mapping(): @@ -83,7 +86,14 @@ def test_address_wrong_length_is_rejected(): def test_non_integer_merchant_id_raises(): with pytest.raises(InvalidRequestError) as exc_info: Merchant.from_dict(_api_response(merchantId="abc")) - assert exc_info.value.param == "merchant_id" + assert exc_info.value.param == "merchantId" + + +def test_boolean_merchant_id_is_rejected(): + # A bool would otherwise be coerced to 1/0; it is never a valid merchant id. + with pytest.raises(InvalidRequestError) as exc_info: + Merchant.from_dict(_api_response(merchantId=True)) + assert exc_info.value.param == "merchantId" @pytest.mark.parametrize("field", ["active", "verified"]) From 9530f3b13fc8d87aa310134927057b53dc61d4b5 Mon Sep 17 00:00:00 2001 From: Gift Amadi <120387225+giftexceed@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:07:10 +0000 Subject: [PATCH 5/5] fix: strip name components before joining in display_name Address maintainer review feedback on #45. The full-name fallback only stripped the combined string, so padded components leaked internal whitespace: first_name=" Ada " and last_name=" Lovelace " produced "Ada Lovelace". Strip each component and join the non-empty ones with a single space. Add regression coverage for padded name components. --- src/shade/models/merchant.py | 4 +++- tests/test_merchant.py | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/shade/models/merchant.py b/src/shade/models/merchant.py index a83c0ce..5c984df 100644 --- a/src/shade/models/merchant.py +++ b/src/shade/models/merchant.py @@ -73,7 +73,9 @@ def display_name(self) -> Optional[str]: business_name = (self.business_name or "").strip() if business_name: return business_name - full_name = f"{self.first_name or ''} {self.last_name or ''}".strip() + first_name = (self.first_name or "").strip() + last_name = (self.last_name or "").strip() + full_name = " ".join(part for part in (first_name, last_name) if part) if full_name: return full_name return (self.email or "").strip() or None diff --git a/tests/test_merchant.py b/tests/test_merchant.py index 4c0d3d2..f827884 100644 --- a/tests/test_merchant.py +++ b/tests/test_merchant.py @@ -158,6 +158,15 @@ def test_display_name_trims_the_returned_value(): assert merchant.display_name == "Acme Payments" +def test_display_name_normalizes_padded_name_components(): + # Each component is stripped before joining, so padding does not leak into + # the middle of the full name as a double space. + merchant = Merchant.from_dict( + _api_response(businessName=None, firstName=" Ada ", lastName=" Lovelace ") + ) + assert merchant.display_name == "Ada Lovelace" + + def test_optional_fields_default_to_none(): merchant = Merchant( id="x",