Skip to content

Commit 27304be

Browse files
committed
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.
1 parent 549cf75 commit 27304be

6 files changed

Lines changed: 95 additions & 213 deletions

File tree

src/shade/__init__.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,10 @@
22
from types import ModuleType
33
from typing import Optional
44

5-
from .base import ShadeObject
65
from .client import ShadeClient
76
from .config import config, Environment
87
from .gateway import Gateway
98
from .http import AsyncHTTPClient, SyncHTTPClient
10-
from .merchant import Merchant
119
from .errors import (
1210
AuthenticationError,
1311
InvalidRequestError,
@@ -17,7 +15,7 @@
1715
RateLimitError,
1816
ShadeError,
1917
)
20-
from .models import ShadeObject
18+
from .models import Merchant, ShadeObject
2119

2220
__version__ = "0.1.0"
2321

src/shade/base.py

Lines changed: 0 additions & 81 deletions
This file was deleted.

src/shade/merchant.py

Lines changed: 0 additions & 125 deletions
This file was deleted.

src/shade/models/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22
Shade API response models.
33
"""
44
from .base import ShadeObject
5+
from .merchant import Merchant
56

6-
__all__ = ["ShadeObject"]
7+
__all__ = ["Merchant", "ShadeObject"]

src/shade/models/merchant.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""
2+
Merchant model.
3+
4+
Mirrors the Shade backend's Prisma ``Merchant`` schema, with field names
5+
converted from ``camelCase`` (Prisma/JSON) to ``snake_case`` (Python) via
6+
pydantic field aliases. The :attr:`Merchant.merchant_id` field (from Prisma
7+
``merchantId: Int``) is the numeric identifier the Soroban contract stamps onto
8+
every invoice, making it the bridge between the backend and the on-chain world.
9+
"""
10+
from __future__ import annotations
11+
12+
from typing import Optional
13+
14+
from pydantic import Field, StrictBool, field_validator
15+
from stellar_sdk.strkey import StrKey
16+
17+
from .base import ShadeObject
18+
19+
20+
class Merchant(ShadeObject):
21+
"""A Shade merchant account.
22+
23+
Build one from an API response with :meth:`ShadeObject.from_dict`, which maps
24+
camelCase JSON keys to the snake_case fields below. ``address`` must be a
25+
valid Stellar ed25519 public key and ``active`` / ``verified`` must be real
26+
booleans; anything else raises
27+
:class:`~shade.errors.InvalidRequestError` on construction.
28+
"""
29+
30+
id: str
31+
merchant_id: int = Field(alias="merchantId")
32+
address: str
33+
active: StrictBool
34+
verified: StrictBool
35+
account: Optional[str] = None
36+
email: Optional[str] = None
37+
first_name: Optional[str] = Field(default=None, alias="firstName")
38+
last_name: Optional[str] = Field(default=None, alias="lastName")
39+
business_name: Optional[str] = Field(default=None, alias="businessName")
40+
category: Optional[str] = None
41+
description: Optional[str] = None
42+
logo: Optional[str] = None
43+
webhook: Optional[str] = None
44+
45+
@field_validator("merchant_id", mode="before")
46+
@classmethod
47+
def _reject_bool_merchant_id(cls, value: object) -> object:
48+
# pydantic would otherwise coerce ``True``/``False`` to 1/0; a boolean is
49+
# never a valid merchant id, so reject it rather than silently accept it.
50+
if isinstance(value, bool):
51+
raise ValueError("merchant_id must be an integer, not a boolean")
52+
return value
53+
54+
@field_validator("address")
55+
@classmethod
56+
def _validate_address(cls, value: str) -> str:
57+
if not StrKey.is_valid_ed25519_public_key(value):
58+
raise ValueError(
59+
"address must be a valid Stellar public key "
60+
"(starts with 'G', 56 characters)"
61+
)
62+
return value
63+
64+
@property
65+
def display_name(self) -> Optional[str]:
66+
"""The most informative human-readable name available.
67+
68+
Prefers ``business_name``; falls back to the person's full name
69+
(``"{first_name} {last_name}"``); finally ``email``. Each candidate is
70+
trimmed, so a blank or whitespace-only value falls through to the next
71+
one rather than being returned. ``None`` when nothing is available.
72+
"""
73+
business_name = (self.business_name or "").strip()
74+
if business_name:
75+
return business_name
76+
full_name = f"{self.first_name or ''} {self.last_name or ''}".strip()
77+
if full_name:
78+
return full_name
79+
return (self.email or "").strip() or None

tests/test_merchant.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,12 @@ def test_merchant_is_exported_from_package():
5353
assert issubclass(Merchant, ShadeObject)
5454

5555

56-
def test_from_dict_ignores_unknown_keys():
57-
merchant = Merchant.from_dict(_api_response(createdAt="2026-01-01", extra="x"))
56+
def test_from_dict_preserves_unknown_keys():
57+
# The ShadeObject base allows extra fields so a server-side addition never
58+
# breaks an older SDK; the known fields still map correctly.
59+
merchant = Merchant.from_dict(_api_response(createdAt="2026-01-01"))
5860
assert merchant.merchant_id == 42
61+
assert merchant.to_dict()["createdAt"] == "2026-01-01"
5962

6063

6164
def test_from_dict_requires_a_mapping():
@@ -83,7 +86,14 @@ def test_address_wrong_length_is_rejected():
8386
def test_non_integer_merchant_id_raises():
8487
with pytest.raises(InvalidRequestError) as exc_info:
8588
Merchant.from_dict(_api_response(merchantId="abc"))
86-
assert exc_info.value.param == "merchant_id"
89+
assert exc_info.value.param == "merchantId"
90+
91+
92+
def test_boolean_merchant_id_is_rejected():
93+
# A bool would otherwise be coerced to 1/0; it is never a valid merchant id.
94+
with pytest.raises(InvalidRequestError) as exc_info:
95+
Merchant.from_dict(_api_response(merchantId=True))
96+
assert exc_info.value.param == "merchantId"
8797

8898

8999
@pytest.mark.parametrize("field", ["active", "verified"])

0 commit comments

Comments
 (0)