Skip to content

Commit da59a32

Browse files
authored
Merge pull request #53 from DioChuks/feat/module-config
Feat/Global `shade` module configuration
2 parents 65526a5 + 73627c2 commit da59a32

7 files changed

Lines changed: 1210 additions & 664 deletions

File tree

src/shade/__init__.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from typing import Optional
44

55
from .client import ShadeClient
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 .errors import (
@@ -55,6 +55,8 @@
5555
"WebhookEvent",
5656
"WebhookEventType",
5757
"config",
58+
"get_config",
59+
"api_key",
5860
"api_base",
5961
"environment",
6062
"max_retries",
@@ -64,6 +66,16 @@
6466
class _ShadeModule(ModuleType):
6567
"""Module subclass that exposes config-backed attributes on the shade package."""
6668

69+
@property
70+
def api_key(self) -> Optional[str]:
71+
from . import config as _config
72+
return _config.api_key
73+
74+
@api_key.setter
75+
def api_key(self, value: Optional[str]) -> None:
76+
from . import config as _config
77+
_config.api_key = value
78+
6779
@property
6880
def api_base(self) -> Optional[str]:
6981
from . import config as _config
@@ -106,3 +118,4 @@ def environment(self, value: str | Environment) -> None:
106118

107119

108120
sys.modules[__name__].__class__ = _ShadeModule
121+

src/shade/client.py

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,34 @@
33
import httpx
44

55
from shade._debug import log_request, log_response
6-
from shade.config import config
6+
from shade.config import Environment, config, get_config
77

88

99
class ShadeClient:
1010
"""HTTP client for the Shade Payment Gateway API."""
1111

1212
def __init__(
1313
self,
14-
api_key: str,
15-
base_url: str = "https://api.shadeprotocol.io",
14+
api_key: Optional[str] = None,
15+
base_url: Optional[str] = None,
16+
environment: Optional[Environment | str] = None,
1617
debug: bool = False,
1718
http_client: Optional[httpx.Client] = None,
1819
):
1920
self.api_key = api_key
20-
self.base_url = base_url.rstrip("/")
21+
self._base_url = base_url.rstrip("/") if base_url else None
22+
self.environment = environment
2123
self.debug = debug
2224
self._http = http_client or httpx.Client()
2325
self._owns_http_client = http_client is None
2426

27+
@property
28+
def base_url(self) -> str:
29+
if self._base_url:
30+
return self._base_url
31+
env = config.parse_environment(self.environment) if self.environment is not None else config.environment
32+
return config.api_base or env.base_url.rstrip("/")
33+
2534
def close(self) -> None:
2635
if self._owns_http_client:
2736
self._http.close()
@@ -35,9 +44,6 @@ def __exit__(self, *args: Any) -> None:
3544
def _should_debug(self) -> bool:
3645
return self.debug or config.debug
3746

38-
def _default_headers(self) -> dict[str, str]:
39-
return {"Authorization": f"Bearer {self.api_key}"}
40-
4147
def request(
4248
self,
4349
method: str,
@@ -47,9 +53,15 @@ def request(
4753
json: Any = None,
4854
content: Optional[bytes] = None,
4955
) -> httpx.Response:
56+
cfg = get_config(
57+
api_key=self.api_key,
58+
environment=self.environment,
59+
api_base=self._base_url,
60+
)
61+
5062
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 {})}
63+
url = f"{cfg.base_url}{normalized_path}"
64+
request_headers = {"Authorization": f"Bearer {cfg.api_key}", **(headers or {})}
5365

5466
if self._should_debug():
5567
log_request(method, url, request_headers, content if content is not None else json)
@@ -66,3 +78,4 @@ def request(
6678
log_response(response.status_code, response.headers, response.text)
6779

6880
return response
81+

src/shade/config.py

Lines changed: 224 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,171 @@
11
from __future__ import annotations
22

33
from enum import Enum
4-
from typing import Optional
4+
5+
import threading
6+
from typing import Any, NamedTuple, Optional
57

68
from stellar_sdk import Network
79

10+
from .errors import AuthenticationError
11+
12+
13+
class ResolvedConfig(NamedTuple):
14+
api_key: str
15+
environment: Environment
16+
api_base: Optional[str]
17+
timeout: float
18+
max_retries: int
19+
base_url: str
20+
21+
822
class Config:
9-
"""Global SDK configuration."""
23+
"""Thread-safe global SDK configuration.
24+
25+
Note:
26+
Configuration assignments made on the main thread (e.g. ``shade.api_key = "..."``)
27+
update both process-wide defaults and thread-local state. Assignments made outside
28+
the main thread update ONLY thread-local state for the calling thread and do not
29+
alter process-wide defaults for other threads. Global configuration setup should be
30+
performed from the main thread during application startup.
31+
"""
32+
33+
def __init__(self) -> None:
34+
self._lock = threading.Lock()
35+
self._local = threading.local()
36+
self._generation: int = 0
37+
self._global_api_key: Optional[str] = None
38+
self._global_api_base: Optional[str] = None
39+
self._global_environment: Environment = Environment.SANDBOX
40+
self._global_timeout: float = DEFAULT_TIMEOUT
41+
self._global_max_retries: int = DEFAULT_MAX_RETRIES
42+
self._global_debug: bool = False
43+
44+
def reset(self) -> None:
45+
"""Reset configuration to defaults (useful for test teardowns)."""
46+
with self._lock:
47+
self._generation += 1
48+
self._global_api_key = None
49+
self._global_api_base = None
50+
self._global_environment = Environment.SANDBOX
51+
self._global_timeout = DEFAULT_TIMEOUT
52+
self._global_max_retries = DEFAULT_MAX_RETRIES
53+
self._global_debug = False
54+
self._local.__dict__.clear()
55+
56+
def _get_local(self, attr_name: str) -> tuple[bool, Any]:
57+
with self._lock:
58+
current_gen = self._generation
59+
if getattr(self._local, "generation", None) == current_gen:
60+
if attr_name in self._local.__dict__:
61+
return True, getattr(self._local, attr_name)
62+
return False, None
63+
64+
def _set_local(self, attr_name: str, value: Any) -> None:
65+
with self._lock:
66+
current_gen = self._generation
67+
if getattr(self._local, "generation", None) != current_gen:
68+
self._local.__dict__.clear()
69+
self._local.generation = current_gen
70+
setattr(self._local, attr_name, value)
1071

11-
def __init__(self):
12-
self.debug: bool = False
13-
self._api_base: Optional[str] = None
14-
self.timeout: float = DEFAULT_TIMEOUT
15-
self.max_retries: int = DEFAULT_MAX_RETRIES
16-
self.environment: Environment = Environment.SANDBOX
72+
@property
73+
def api_key(self) -> Optional[str]:
74+
has_local, val = self._get_local("api_key")
75+
if has_local:
76+
return val
77+
with self._lock:
78+
return self._global_api_key
79+
80+
@api_key.setter
81+
def api_key(self, value: Optional[str]) -> None:
82+
"""Set the API key. Updates process-wide default if called from main thread."""
83+
self._set_local("api_key", value)
84+
if threading.current_thread() is threading.main_thread():
85+
with self._lock:
86+
self._global_api_key = value
1787

1888
@property
1989
def api_base(self) -> Optional[str]:
20-
return self._api_base
90+
has_local, val = self._get_local("api_base")
91+
if has_local:
92+
return val
93+
with self._lock:
94+
return self._global_api_base
2195

2296
@api_base.setter
2397
def api_base(self, value: Optional[str]) -> None:
24-
self._api_base = value
98+
"""Set the API base URL override. Updates process-wide default if called from main thread."""
99+
self._set_local("api_base", value)
100+
if threading.current_thread() is threading.main_thread():
101+
with self._lock:
102+
self._global_api_base = value
103+
104+
@property
105+
def environment(self) -> Environment:
106+
has_local, val = self._get_local("environment")
107+
if has_local:
108+
return val
109+
with self._lock:
110+
return self._global_environment
111+
112+
@environment.setter
113+
def environment(self, value: str | Environment) -> None:
114+
"""Set the active environment. Updates process-wide default if called from main thread."""
115+
parsed = self.parse_environment(value)
116+
self._set_local("environment", parsed)
117+
if threading.current_thread() is threading.main_thread():
118+
with self._lock:
119+
self._global_environment = parsed
120+
121+
@property
122+
def timeout(self) -> float:
123+
has_local, val = self._get_local("timeout")
124+
if has_local:
125+
return val
126+
with self._lock:
127+
return self._global_timeout
128+
129+
@timeout.setter
130+
def timeout(self, value: float) -> None:
131+
"""Set the socket timeout. Updates process-wide default if called from main thread."""
132+
self._set_local("timeout", value)
133+
if threading.current_thread() is threading.main_thread():
134+
with self._lock:
135+
self._global_timeout = value
136+
137+
@property
138+
def max_retries(self) -> int:
139+
has_local, val = self._get_local("max_retries")
140+
if has_local:
141+
return val
142+
with self._lock:
143+
return self._global_max_retries
144+
145+
@max_retries.setter
146+
def max_retries(self, value: int) -> None:
147+
"""Set the max retries limit. Updates process-wide default if called from main thread."""
148+
self._set_local("max_retries", value)
149+
if threading.current_thread() is threading.main_thread():
150+
with self._lock:
151+
self._global_max_retries = value
152+
153+
154+
@property
155+
def debug(self) -> bool:
156+
has_local, val = self._get_local("debug")
157+
if has_local:
158+
return val
159+
with self._lock:
160+
return self._global_debug
161+
162+
@debug.setter
163+
def debug(self, value: bool) -> None:
164+
self._set_local("debug", value)
165+
if threading.current_thread() is threading.main_thread():
166+
with self._lock:
167+
self._global_debug = value
168+
25169

26170
def parse_environment(self, value: str | Environment) -> Environment:
27171
if isinstance(value, Environment):
@@ -40,6 +184,7 @@ def parse_environment(self, value: str | Environment) -> Environment:
40184
DEFAULT_MAX_RETRIES: int = 3
41185
MAX_RETRIES_LIMIT: int = 10
42186

187+
43188
def validate_client_settings(timeout: float, max_retries: int) -> None:
44189
"""Raise ValueError for out-of-range timeout or retry settings."""
45190
if timeout <= 0:
@@ -78,4 +223,72 @@ def horizon_url(self) -> str:
78223
}
79224
return _horizons[self.value]
80225

81-
config = Config()
226+
227+
config = Config()
228+
229+
230+
def get_config(
231+
api_key: Optional[str] = None,
232+
environment: Optional[Environment | str] = None,
233+
api_base: Optional[str] = None,
234+
timeout: Optional[float] = None,
235+
max_retries: Optional[int] = None,
236+
) -> ResolvedConfig:
237+
"""Merge instance-level overrides with global defaults.
238+
239+
Parameters
240+
----------
241+
api_key : str, optional
242+
Instance API key. If absent/None, uses ``shade.api_key``.
243+
environment : str | Environment, optional
244+
Instance environment. If absent/None, uses ``shade.environment``.
245+
api_base : str, optional
246+
Instance API base URL override. If absent/None, uses ``shade.api_base``.
247+
timeout : float, optional
248+
Instance socket timeout. If absent/None, uses ``shade.timeout``.
249+
max_retries : int, optional
250+
Instance retry limit. If absent/None, uses ``shade.max_retries``.
251+
252+
Returns
253+
-------
254+
ResolvedConfig
255+
A named tuple with resolved configuration values.
256+
257+
Raises
258+
------
259+
AuthenticationError
260+
If no valid API key is set globally or at instance level.
261+
ValueError
262+
If timeout or max_retries are invalid.
263+
"""
264+
resolved_api_key = api_key if api_key is not None else config.api_key
265+
if not resolved_api_key:
266+
raise AuthenticationError(
267+
"No API key provided. Set your API key using 'shade.api_key = <API_KEY>' "
268+
"or pass api_key to the client."
269+
)
270+
271+
resolved_env = (
272+
config.parse_environment(environment)
273+
if environment is not None
274+
else config.environment
275+
)
276+
277+
resolved_api_base = api_base if api_base is not None else config.api_base
278+
resolved_timeout = timeout if timeout is not None else config.timeout
279+
resolved_max_retries = (
280+
max_retries if max_retries is not None else config.max_retries
281+
)
282+
283+
validate_client_settings(resolved_timeout, resolved_max_retries)
284+
285+
base_url = (resolved_api_base or resolved_env.base_url).rstrip("/")
286+
287+
return ResolvedConfig(
288+
api_key=resolved_api_key,
289+
environment=resolved_env,
290+
api_base=resolved_api_base,
291+
timeout=resolved_timeout,
292+
max_retries=resolved_max_retries,
293+
base_url=base_url,
294+
)

0 commit comments

Comments
 (0)