Skip to content

Commit 3a92d72

Browse files
committed
feat(client): implement ShadeClient for per-instance configuration
Implements issue #2. ShadeClient binds credentials and connection settings to a single object so a multi-tenant application can hold one client per merchant instead of mutating the global shade module config. - ShadeClient takes api_key, environment, api_base, timeout and max_retries, each falling back to the matching global setting when omitted. The fallback resolves once at construction, so later global changes never mutate an existing client. - Adds a global shade.api_key setting backing that fallback. - ShadeClient.from_env() builds a client from SHADE_API_KEY and SHADE_ENVIRONMENT, with keyword arguments overriding either. - A missing api_key with no global key set now raises AuthenticationError instead of ValueError, naming all three ways to supply one. - BaseResource gives resources the optional client= kwarg, resolving to the shared global client when omitted. The client is resolved per access, so a resource built before shade.api_key was assigned still picks it up. ShadeClient was previously an alias for Gateway, with a separate unrelated ShadeClient in client.py. Gateway is now a ShadeClient subclass carrying the payment methods, and the httpx-backed transport that occupied client.py moves to http.py as HTTPXTransport, alongside the other transports. The two tests asserting the old alias now assert the subclass relationship. Also fixes http.py resolving `from . import config` to the config module rather than the Config instance. That only worked because of the order of imports in __init__.py, and broke as soon as client.py imported http.py earlier in the chain.
1 parent c6a28ab commit 3a92d72

10 files changed

Lines changed: 734 additions & 158 deletions

File tree

src/shade/__init__.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22
from types import ModuleType
33
from typing import Optional
44

5-
from .client import ShadeClient
5+
from .client import ShadeClient, default_client, reset_default_client
66
from .config import config, Environment
77
from .gateway import Gateway
88
from .http import AsyncHTTPClient, SyncHTTPClient
9+
from .resources import BaseResource
910
from .errors import (
1011
AuthenticationError,
1112
InvalidRequestError,
@@ -20,14 +21,12 @@
2021

2122
__version__ = "0.1.0"
2223

23-
# ShadeClient is an alias for Gateway.
24-
ShadeClient = Gateway
25-
2624
__all__ = [
2725
"AssetBalance",
2826
"AsyncHTTPClient",
2927
"AuthenticationError",
3028
"Balance",
29+
"BaseResource",
3130
"Environment",
3231
"Gateway",
3332
"HTTPError",
@@ -45,14 +44,27 @@
4544
"TransferStatus",
4645
"config",
4746
"api_base",
47+
"api_key",
48+
"default_client",
4849
"environment",
4950
"max_retries",
51+
"reset_default_client",
5052
"timeout",
5153
]
5254

5355
class _ShadeModule(ModuleType):
5456
"""Module subclass that exposes config-backed attributes on the shade package."""
5557

58+
@property
59+
def api_key(self) -> Optional[str]:
60+
from . import config as _config
61+
return _config.api_key
62+
63+
@api_key.setter
64+
def api_key(self, value: Optional[str]) -> None:
65+
from . import config as _config
66+
_config.api_key = value
67+
5668
@property
5769
def api_base(self) -> Optional[str]:
5870
from . import config as _config

src/shade/client.py

Lines changed: 196 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,231 @@
1-
from typing import Any, Mapping, Optional
1+
"""
2+
Per-instance SDK configuration.
3+
4+
``ShadeClient`` binds a set of credentials and connection settings to a single
5+
object, so an application acting on behalf of several merchants can hold one
6+
client per tenant instead of mutating the global ``shade`` module config.
7+
Anything left unset falls back to the global config at construction time.
8+
"""
9+
from __future__ import annotations
10+
11+
import os
12+
from typing import Any, Dict, Optional
213

314
import httpx
415

5-
from shade._debug import log_request, log_response
6-
from shade.config import config
16+
from .config import Environment, validate_client_settings
17+
from .config import config as _config
18+
from .errors import AuthenticationError
19+
from .http import AsyncHTTPClient, HTTPXTransport, SyncHTTPClient
20+
21+
API_KEY_ENV_VAR = "SHADE_API_KEY"
22+
ENVIRONMENT_ENV_VAR = "SHADE_ENVIRONMENT"
723

824

925
class ShadeClient:
10-
"""HTTP client for the Shade Payment Gateway API."""
26+
"""An isolated Shade API client carrying its own credentials and settings.
27+
28+
Two clients built with different API keys never share state, so a
29+
multi-tenant application can keep one per merchant::
30+
31+
acme = ShadeClient(api_key="sk_live_acme")
32+
globex = ShadeClient(api_key="sk_live_globex")
33+
34+
Every parameter falls back to the matching global setting
35+
(``shade.api_key``, ``shade.environment``, …) when omitted, and the fallback
36+
is resolved once at construction — later changes to the global config do not
37+
retroactively alter an existing client.
38+
39+
Parameters
40+
----------
41+
api_key : str, optional
42+
Your Shade API key. Defaults to the module-level ``shade.api_key``.
43+
environment : str | Environment, optional
44+
Controls the Stellar network passphrase and the default API URL.
45+
Defaults to the module-level ``shade.environment``.
46+
api_base : str, optional
47+
Override the API host for this client (local dev, staging, or a
48+
self-hosted backend). Takes precedence over the module-level
49+
``shade.api_base`` and the URL derived from ``environment``. Trailing
50+
slashes are trimmed.
51+
timeout : float, optional
52+
Per-request socket timeout in seconds. Defaults to ``shade.timeout``.
53+
max_retries : int, optional
54+
Automatic retries on HTTP 429 and transient failures. Defaults to
55+
``shade.max_retries``. Set to ``0`` to disable auto-retry.
56+
base_url : str
57+
Deprecated. Prefer ``api_base``.
58+
debug : bool
59+
Log requests and responses for this client. The global
60+
``shade.config.debug`` enables logging regardless of this flag.
61+
http_client : httpx.Client, optional
62+
Reuse an existing httpx client instead of creating one. The caller
63+
keeps ownership: :meth:`close` will not close a client it was given.
64+
65+
Raises
66+
------
67+
AuthenticationError
68+
If no API key is given and no global ``shade.api_key`` is set.
69+
ValueError
70+
If ``timeout`` or ``max_retries`` is out of range, or ``environment``
71+
is not a recognised value.
72+
"""
1173

1274
def __init__(
1375
self,
14-
api_key: str,
15-
base_url: str = "https://api.shadeprotocol.io",
76+
api_key: Optional[str] = None,
77+
environment: Optional[Environment | str] = None,
78+
api_base: Optional[str] = None,
79+
timeout: Optional[float] = None,
80+
max_retries: Optional[int] = None,
81+
base_url: str = "",
1682
debug: bool = False,
1783
http_client: Optional[httpx.Client] = None,
18-
):
19-
self.api_key = api_key
20-
self.base_url = base_url.rstrip("/")
84+
) -> None:
85+
resolved_api_key = api_key or _config.api_key
86+
if not resolved_api_key:
87+
raise AuthenticationError(
88+
"No API key provided. Pass api_key= to ShadeClient, set "
89+
f"shade.api_key, or set the {API_KEY_ENV_VAR} environment variable."
90+
)
91+
self.api_key = resolved_api_key
92+
93+
if environment is not None:
94+
self.environment = _config.parse_environment(environment)
95+
else:
96+
self.environment = _config.environment
97+
98+
self.max_retries = _config.max_retries if max_retries is None else max_retries
99+
self.timeout = _config.timeout if timeout is None else timeout
100+
validate_client_settings(self.timeout, self.max_retries)
101+
102+
# Resolution order: explicit api_base > module-level shade.api_base
103+
# > legacy base_url > environment URL
104+
resolved = api_base or _config.api_base or base_url or self.environment.base_url
105+
self._base_url = resolved.rstrip("/")
21106
self.debug = debug
22-
self._http = http_client or httpx.Client()
23-
self._owns_http_client = http_client is None
107+
108+
self._http = SyncHTTPClient(
109+
base_url=self._base_url,
110+
api_key=self.api_key,
111+
max_retries=self.max_retries,
112+
timeout=self.timeout,
113+
)
114+
self._async_http = AsyncHTTPClient(
115+
base_url=self._base_url,
116+
api_key=self.api_key,
117+
max_retries=self.max_retries,
118+
timeout=self.timeout,
119+
)
120+
self._client = HTTPXTransport(
121+
api_key=self.api_key,
122+
base_url=self._base_url,
123+
debug=debug,
124+
http_client=http_client,
125+
)
126+
127+
@classmethod
128+
def from_env(cls, **overrides: Any) -> "ShadeClient":
129+
"""Build a client from ``SHADE_API_KEY`` and ``SHADE_ENVIRONMENT``.
130+
131+
Either variable may be absent, in which case the usual global-config
132+
fallback applies — so a missing ``SHADE_API_KEY`` with no
133+
``shade.api_key`` set raises :class:`~shade.errors.AuthenticationError`.
134+
135+
Any keyword argument overrides the corresponding environment variable,
136+
letting callers take the key from the environment while setting the rest
137+
explicitly::
138+
139+
client = ShadeClient.from_env(timeout=5.0)
140+
"""
141+
env_kwargs: Dict[str, Any] = {}
142+
api_key = os.environ.get(API_KEY_ENV_VAR)
143+
if api_key:
144+
env_kwargs["api_key"] = api_key
145+
environment = os.environ.get(ENVIRONMENT_ENV_VAR)
146+
if environment:
147+
env_kwargs["environment"] = environment
148+
env_kwargs.update(overrides)
149+
return cls(**env_kwargs)
150+
151+
@property
152+
def api_base(self) -> str:
153+
"""The resolved API base URL this client sends requests to."""
154+
return self._base_url
24155

25156
def close(self) -> None:
26-
if self._owns_http_client:
27-
self._http.close()
157+
self._client.close()
28158

29159
def __enter__(self) -> "ShadeClient":
30160
return self
31161

32162
def __exit__(self, *args: Any) -> None:
33163
self.close()
34164

35-
def _should_debug(self) -> bool:
36-
return self.debug or config.debug
37-
38-
def _default_headers(self) -> dict[str, str]:
39-
return {"Authorization": f"Bearer {self.api_key}"}
40-
41165
def request(
42166
self,
43167
method: str,
44168
path: str,
45169
*,
46-
headers: Optional[Mapping[str, str]] = None,
170+
headers: Optional[Dict[str, str]] = None,
47171
json: Any = None,
48172
content: Optional[bytes] = None,
49173
) -> httpx.Response:
50-
normalized_path = path if path.startswith("/") else f"/{path}"
51-
url = f"{self.base_url}{normalized_path}"
52-
request_headers = {**self._default_headers(), **(headers or {})}
53-
54-
if self._should_debug():
55-
log_request(method, url, request_headers, content if content is not None else json)
56-
57-
response = self._http.request(
174+
"""Send a request and return the raw ``httpx.Response``."""
175+
return self._client.request(
58176
method,
59-
url,
60-
headers=request_headers,
177+
path,
178+
headers=headers,
61179
json=json,
62180
content=content,
63181
)
64182

65-
if self._should_debug():
66-
log_response(response.status_code, response.headers, response.text)
183+
def __repr__(self) -> str:
184+
return (
185+
f"<{type(self).__name__} api_key={_mask_api_key(self.api_key)!r} "
186+
f"environment={self.environment.value!r} api_base={self._base_url!r}>"
187+
)
188+
189+
190+
def _mask_api_key(api_key: str) -> str:
191+
"""Show only the last four characters of a key, for use in reprs."""
192+
if len(api_key) <= 4:
193+
return "****"
194+
return "*" * (len(api_key) - 4) + api_key[-4:]
195+
196+
197+
_default_client: Optional[ShadeClient] = None
198+
_default_client_settings: Optional[tuple] = None
199+
200+
201+
def default_client() -> ShadeClient:
202+
"""Return the shared client built from the global ``shade`` config.
203+
204+
Resources fall back to this when constructed without an explicit
205+
``client=``. The instance is cached, but rebuilt whenever a global setting
206+
changes, so assigning ``shade.api_key`` after the first call still takes
207+
effect.
208+
209+
Raises:
210+
AuthenticationError: If no global ``shade.api_key`` has been set.
211+
"""
212+
global _default_client, _default_client_settings
213+
214+
settings = (
215+
_config.api_key,
216+
_config.environment,
217+
_config.api_base,
218+
_config.timeout,
219+
_config.max_retries,
220+
)
221+
if _default_client is None or _default_client_settings != settings:
222+
_default_client = ShadeClient()
223+
_default_client_settings = settings
224+
return _default_client
225+
67226

68-
return response
227+
def reset_default_client() -> None:
228+
"""Drop the cached global client. Primarily useful in tests."""
229+
global _default_client, _default_client_settings
230+
_default_client = None
231+
_default_client_settings = None

src/shade/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ class Config:
1010

1111
def __init__(self):
1212
self.debug: bool = False
13+
self.api_key: Optional[str] = None
1314
self._api_base: Optional[str] = None
1415
self.timeout: float = DEFAULT_TIMEOUT
1516
self.max_retries: int = DEFAULT_MAX_RETRIES

0 commit comments

Comments
 (0)