Skip to content

Commit 0451235

Browse files
committed
Merge main into feat/2-shade-client
Reconciles ShadeClient with the thread-local global config merged in #53. Resolutions: - config.py: take main's thread-safe Config and get_config wholesale; the api_key global this branch added is already there. Only the missing-key message changes, to name all three ways to supply a key. - client.py: ShadeClient keeps its per-instance role but adopts main's lazy resolution. Explicit arguments are pinned to the instance; omitted ones resolve against the global config per request, so a missing key surfaces as AuthenticationError at request time rather than at construction. Gains main's api_key/environment setters, which propagate to the sub-clients. - gateway.py: Gateway stays a ShadeClient subclass, dropping the constructor and accessors now inherited. Keeps main's positional parameter order. - http.py: take main's dynamic SyncHTTPClient/AsyncHTTPClient; the httpx transport this branch moved out of client.py lands as HTTPXTransport and resolves through get_config like main's version did. - __init__.py: drop the "ShadeClient = Gateway" alias, since ShadeClient is now a real class, and keep main's other exports. Tests asserting construction-time snapshotting are rewritten for the lazy semantics. 353 passed.
2 parents 3a92d72 + ff36ffa commit 0451235

13 files changed

Lines changed: 2136 additions & 811 deletions

src/shade/__init__.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from typing import Optional
44

55
from .client import ShadeClient, default_client, reset_default_client
6-
from .config import config, Environment
6+
from .config import config, Environment, get_config
77
from .gateway import Gateway
88
from .http import AsyncHTTPClient, SyncHTTPClient
99
from .resources import BaseResource
@@ -16,8 +16,19 @@
1616
RateLimitError,
1717
ShadeError,
1818
SignatureVerificationError,
19+
StellarError,
20+
wrap_stellar_errors,
21+
)
22+
from .models import (
23+
AssetBalance,
24+
Balance,
25+
Merchant,
26+
ShadeObject,
27+
Transfer,
28+
TransferStatus,
29+
WebhookEvent,
30+
WebhookEventType,
1931
)
20-
from .models import AssetBalance, Balance, Merchant, ShadeObject, Transfer, TransferStatus
2132

2233
__version__ = "0.1.0"
2334

@@ -39,17 +50,22 @@
3950
"ShadeError",
4051
"SignatureVerificationError",
4152
"ShadeObject",
53+
"StellarError",
4254
"SyncHTTPClient",
4355
"Transfer",
4456
"TransferStatus",
57+
"WebhookEvent",
58+
"WebhookEventType",
4559
"config",
60+
"get_config",
4661
"api_base",
4762
"api_key",
4863
"default_client",
4964
"environment",
5065
"max_retries",
5166
"reset_default_client",
5267
"timeout",
68+
"wrap_stellar_errors",
5369
]
5470

5571
class _ShadeModule(ModuleType):
@@ -107,3 +123,4 @@ def environment(self, value: str | Environment) -> None:
107123

108124

109125
sys.modules[__name__].__class__ = _ShadeModule
126+

src/shade/client.py

Lines changed: 93 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
``ShadeClient`` binds a set of credentials and connection settings to a single
55
object, so an application acting on behalf of several merchants can hold one
66
client per tenant instead of mutating the global ``shade`` module config.
7-
Anything left unset falls back to the global config at construction time.
7+
Anything left unset falls back to the global config, resolved per request so a
8+
client on the defaults follows later changes to ``shade.api_key`` and friends.
89
"""
910
from __future__ import annotations
1011

@@ -15,7 +16,6 @@
1516

1617
from .config import Environment, validate_client_settings
1718
from .config import config as _config
18-
from .errors import AuthenticationError
1919
from .http import AsyncHTTPClient, HTTPXTransport, SyncHTTPClient
2020

2121
API_KEY_ENV_VAR = "SHADE_API_KEY"
@@ -32,9 +32,9 @@ class ShadeClient:
3232
globex = ShadeClient(api_key="sk_live_globex")
3333
3434
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.
35+
(``shade.api_key``, ``shade.environment``, …) when omitted. Explicit
36+
arguments are pinned to the instance; omitted ones track the global config,
37+
which is read at request time rather than captured at construction.
3838
3939
Parameters
4040
----------
@@ -48,13 +48,13 @@ class ShadeClient:
4848
self-hosted backend). Takes precedence over the module-level
4949
``shade.api_base`` and the URL derived from ``environment``. Trailing
5050
slashes are trimmed.
51-
timeout : float, optional
52-
Per-request socket timeout in seconds. Defaults to ``shade.timeout``.
51+
base_url : str
52+
Deprecated. Prefer ``api_base``.
5353
max_retries : int, optional
5454
Automatic retries on HTTP 429 and transient failures. Defaults to
5555
``shade.max_retries``. Set to ``0`` to disable auto-retry.
56-
base_url : str
57-
Deprecated. Prefer ``api_base``.
56+
timeout : float, optional
57+
Per-request socket timeout in seconds. Defaults to ``shade.timeout``.
5858
debug : bool
5959
Log requests and responses for this client. The global
6060
``shade.config.debug`` enables logging regardless of this flag.
@@ -64,8 +64,6 @@ class ShadeClient:
6464
6565
Raises
6666
------
67-
AuthenticationError
68-
If no API key is given and no global ``shade.api_key`` is set.
6967
ValueError
7068
If ``timeout`` or ``max_retries`` is out of range, or ``environment``
7169
is not a recognised value.
@@ -76,50 +74,46 @@ def __init__(
7674
api_key: Optional[str] = None,
7775
environment: Optional[Environment | str] = None,
7876
api_base: Optional[str] = None,
79-
timeout: Optional[float] = None,
80-
max_retries: Optional[int] = None,
8177
base_url: str = "",
78+
max_retries: Optional[int] = None,
79+
timeout: Optional[float] = None,
8280
debug: bool = False,
8381
http_client: Optional[httpx.Client] = None,
8482
) -> 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("/")
83+
self._api_key = api_key
84+
self._environment = (
85+
_config.parse_environment(environment) if environment is not None else None
86+
)
87+
api_base = api_base or (base_url if base_url else None)
88+
self._api_base = api_base.rstrip("/") if api_base else None
89+
self._timeout = timeout
90+
self._max_retries = max_retries
10691
self.debug = debug
10792

93+
if timeout is not None or max_retries is not None:
94+
validate_client_settings(
95+
timeout if timeout is not None else _config.timeout,
96+
max_retries if max_retries is not None else _config.max_retries,
97+
)
98+
10899
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,
100+
base_url=self._api_base,
101+
api_key=self._api_key,
102+
environment=self._environment,
103+
max_retries=self._max_retries,
104+
timeout=self._timeout,
113105
)
114106
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,
107+
base_url=self._api_base,
108+
api_key=self._api_key,
109+
environment=self._environment,
110+
max_retries=self._max_retries,
111+
timeout=self._timeout,
119112
)
120113
self._client = HTTPXTransport(
121-
api_key=self.api_key,
122-
base_url=self._base_url,
114+
api_key=self._api_key,
115+
base_url=self._api_base,
116+
environment=self._environment,
123117
debug=debug,
124118
http_client=http_client,
125119
)
@@ -130,7 +124,8 @@ def from_env(cls, **overrides: Any) -> "ShadeClient":
130124
131125
Either variable may be absent, in which case the usual global-config
132126
fallback applies — so a missing ``SHADE_API_KEY`` with no
133-
``shade.api_key`` set raises :class:`~shade.errors.AuthenticationError`.
127+
``shade.api_key`` set leaves the client without credentials, and its
128+
requests raise :class:`~shade.errors.AuthenticationError`.
134129
135130
Any keyword argument overrides the corresponding environment variable,
136131
letting callers take the key from the environment while setting the rest
@@ -148,9 +143,50 @@ def from_env(cls, **overrides: Any) -> "ShadeClient":
148143
env_kwargs.update(overrides)
149144
return cls(**env_kwargs)
150145

146+
@property
147+
def api_key(self) -> Optional[str]:
148+
return self._api_key if self._api_key is not None else _config.api_key
149+
150+
@api_key.setter
151+
def api_key(self, value: Optional[str]) -> None:
152+
self._api_key = value
153+
self._http.api_key = value
154+
self._async_http.api_key = value
155+
self._client.api_key = value
156+
157+
@property
158+
def environment(self) -> Environment:
159+
if self._environment is not None:
160+
return self._environment
161+
return _config.environment
162+
163+
@environment.setter
164+
def environment(self, value: str | Environment) -> None:
165+
parsed = _config.parse_environment(value)
166+
self._environment = parsed
167+
self._http.environment = parsed
168+
self._async_http.environment = parsed
169+
self._client.environment = parsed
170+
171+
@property
172+
def timeout(self) -> float:
173+
return self._timeout if self._timeout is not None else _config.timeout
174+
175+
@property
176+
def max_retries(self) -> int:
177+
return self._max_retries if self._max_retries is not None else _config.max_retries
178+
179+
@property
180+
def _base_url(self) -> str:
181+
if self._api_base:
182+
return self._api_base
183+
if _config.api_base:
184+
return _config.api_base.rstrip("/")
185+
return self.environment.base_url.rstrip("/")
186+
151187
@property
152188
def api_base(self) -> str:
153-
"""The resolved API base URL this client sends requests to."""
189+
"""The API base URL this client currently sends requests to."""
154190
return self._base_url
155191

156192
def close(self) -> None:
@@ -187,45 +223,34 @@ def __repr__(self) -> str:
187223
)
188224

189225

190-
def _mask_api_key(api_key: str) -> str:
226+
def _mask_api_key(api_key: Optional[str]) -> str:
191227
"""Show only the last four characters of a key, for use in reprs."""
228+
if not api_key:
229+
return "unset"
192230
if len(api_key) <= 4:
193231
return "****"
194232
return "*" * (len(api_key) - 4) + api_key[-4:]
195233

196234

197235
_default_client: Optional[ShadeClient] = None
198-
_default_client_settings: Optional[tuple] = None
199236

200237

201238
def default_client() -> ShadeClient:
202-
"""Return the shared client built from the global ``shade`` config.
239+
"""Return the shared client backed by the global ``shade`` config.
203240
204241
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.
242+
``client=``. It pins no settings of its own, so every global change —
243+
including a ``shade.api_key`` assigned after the first call — is picked up
244+
on the next request.
211245
"""
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:
246+
global _default_client
247+
248+
if _default_client is None:
222249
_default_client = ShadeClient()
223-
_default_client_settings = settings
224250
return _default_client
225251

226252

227253
def reset_default_client() -> None:
228254
"""Drop the cached global client. Primarily useful in tests."""
229-
global _default_client, _default_client_settings
255+
global _default_client
230256
_default_client = None
231-
_default_client_settings = None

0 commit comments

Comments
 (0)