forked from ShadeProtocol/shade-python
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathclient.py
More file actions
266 lines (223 loc) · 9.02 KB
/
Copy pathclient.py
File metadata and controls
266 lines (223 loc) · 9.02 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
"""
Per-instance SDK configuration.
``ShadeClient`` binds a set of credentials and connection settings to a single
object, so an application acting on behalf of several merchants can hold one
client per tenant instead of mutating the global ``shade`` module config.
Anything left unset falls back to the global config, resolved per request so a
client on the defaults follows later changes to ``shade.api_key`` and friends.
"""
from __future__ import annotations
import os
import threading
from typing import Any, Dict, Optional
import httpx
from .config import Environment, validate_client_settings
from .config import config as _config
from .http import AsyncHTTPClient, HTTPXTransport
from .http_client import _SyncHTTPClient
API_KEY_ENV_VAR = "SHADE_API_KEY"
ENVIRONMENT_ENV_VAR = "SHADE_ENVIRONMENT"
class ShadeClient:
"""An isolated Shade API client carrying its own credentials and settings.
Two clients built with different API keys never share state, so a
multi-tenant application can keep one per merchant::
acme = ShadeClient(api_key="sk_live_acme")
globex = ShadeClient(api_key="sk_live_globex")
Every parameter falls back to the matching global setting
(``shade.api_key``, ``shade.environment``, …) when omitted. Explicit
arguments are pinned to the instance; omitted ones track the global config,
which is read at request time rather than captured at construction.
Parameters
----------
api_key : str, optional
Your Shade API key. Defaults to the module-level ``shade.api_key``.
environment : str | Environment, optional
Controls the Stellar network passphrase and the default API URL.
Defaults to the module-level ``shade.environment``.
api_base : str, optional
Override the API host for this client (local dev, staging, or a
self-hosted backend). Takes precedence over the module-level
``shade.api_base`` and the URL derived from ``environment``. Trailing
slashes are trimmed.
base_url : str
Deprecated. Prefer ``api_base``.
max_retries : int, optional
Automatic retries on HTTP 429 and transient failures. Defaults to
``shade.max_retries``. Set to ``0`` to disable auto-retry.
timeout : float, optional
Per-request socket timeout in seconds. Defaults to ``shade.timeout``.
debug : bool
Log requests and responses for this client. The global
``shade.config.debug`` enables logging regardless of this flag.
http_client : httpx.Client, optional
Reuse an existing httpx client instead of creating one. The caller
keeps ownership: :meth:`close` will not close a client it was given.
Raises
------
ValueError
If ``timeout`` or ``max_retries`` is out of range, or ``environment``
is not a recognised value.
"""
def __init__(
self,
api_key: Optional[str] = None,
environment: Optional[Environment | str] = None,
api_base: Optional[str] = None,
base_url: str = "",
max_retries: Optional[int] = None,
timeout: Optional[float] = None,
debug: bool = False,
http_client: Optional[httpx.Client] = None,
) -> None:
self._api_key = api_key
self._environment = (
_config.parse_environment(environment) if environment is not None else None
)
api_base = api_base or (base_url if base_url else None)
self._api_base = api_base.rstrip("/") if api_base else None
self._timeout = timeout
self._max_retries = max_retries
self.debug = debug
if timeout is not None or max_retries is not None:
validate_client_settings(
timeout if timeout is not None else _config.timeout,
max_retries if max_retries is not None else _config.max_retries,
)
self._http = _SyncHTTPClient(
api_base=self._api_base,
api_key=self._api_key,
environment=self._environment,
max_retries=self._max_retries,
timeout=self._timeout,
)
self._async_http = AsyncHTTPClient(
base_url=self._api_base,
api_key=self._api_key,
environment=self._environment,
max_retries=self._max_retries,
timeout=self._timeout,
)
self._client = HTTPXTransport(
api_key=self._api_key,
base_url=self._api_base,
environment=self._environment,
timeout=self._timeout,
debug=debug,
http_client=http_client,
)
@classmethod
def from_env(cls, **overrides: Any) -> "ShadeClient":
"""Build a client from ``SHADE_API_KEY`` and ``SHADE_ENVIRONMENT``.
Either variable may be absent, in which case the usual global-config
fallback applies — so a missing ``SHADE_API_KEY`` with no
``shade.api_key`` set leaves the client without credentials, and its
requests raise :class:`~shade.errors.AuthenticationError`.
Any keyword argument overrides the corresponding environment variable,
letting callers take the key from the environment while setting the rest
explicitly::
client = ShadeClient.from_env(timeout=5.0)
"""
env_kwargs: Dict[str, Any] = {}
api_key = os.environ.get(API_KEY_ENV_VAR)
if api_key:
env_kwargs["api_key"] = api_key
environment = os.environ.get(ENVIRONMENT_ENV_VAR)
if environment:
env_kwargs["environment"] = environment
env_kwargs.update(overrides)
return cls(**env_kwargs)
@property
def api_key(self) -> Optional[str]:
return self._api_key if self._api_key is not None else _config.api_key
@api_key.setter
def api_key(self, value: Optional[str]) -> None:
self._api_key = value
self._http.api_key = value
self._async_http.api_key = value
self._client.api_key = value
@property
def environment(self) -> Environment:
if self._environment is not None:
return self._environment
return _config.environment
@environment.setter
def environment(self, value: str | Environment) -> None:
parsed = _config.parse_environment(value)
self._environment = parsed
self._http.environment = parsed
self._async_http.environment = parsed
self._client.environment = parsed
@property
def timeout(self) -> float:
return self._timeout if self._timeout is not None else _config.timeout
@property
def max_retries(self) -> int:
return self._max_retries if self._max_retries is not None else _config.max_retries
@property
def _base_url(self) -> str:
if self._api_base:
return self._api_base
if _config.api_base:
return _config.api_base.rstrip("/")
return self.environment.base_url.rstrip("/")
@property
def api_base(self) -> str:
"""The API base URL this client currently sends requests to."""
return self._base_url
def close(self) -> None:
self._http.close()
self._client.close()
def __enter__(self) -> "ShadeClient":
return self
def __exit__(self, *args: Any) -> None:
self.close()
def request(
self,
method: str,
path: str,
*,
headers: Optional[Dict[str, str]] = None,
json: Any = None,
content: Optional[bytes] = None,
) -> httpx.Response:
"""Send a request and return the raw ``httpx.Response``."""
return self._client.request(
method,
path,
headers=headers,
json=json,
content=content,
)
def __repr__(self) -> str:
return (
f"<{type(self).__name__} api_key={_mask_api_key(self.api_key)!r} "
f"environment={self.environment.value!r} api_base={self._base_url!r}>"
)
def _mask_api_key(api_key: Optional[str]) -> str:
"""Show only the last four characters of a key, for use in reprs."""
if not api_key:
return "unset"
if len(api_key) <= 4:
return "****"
return "*" * (len(api_key) - 4) + api_key[-4:]
_default_client: Optional[ShadeClient] = None
_default_client_lock = threading.Lock()
def default_client() -> ShadeClient:
"""Return the shared client backed by the global ``shade`` config.
Resources fall back to this when constructed without an explicit
``client=``. It pins no settings of its own, so every global change —
including a ``shade.api_key`` assigned after the first call — is picked up
on the next request.
"""
global _default_client
with _default_client_lock:
if _default_client is None:
_default_client = ShadeClient()
return _default_client
def reset_default_client() -> None:
"""Drop the cached global client. Primarily useful in tests."""
global _default_client
with _default_client_lock:
client, _default_client = _default_client, None
if client is not None:
client.close()