11from __future__ import annotations
22
33from enum import Enum
4- from typing import Optional
4+
5+ import threading
6+ from typing import Any , NamedTuple , Optional
57
68from 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+
822class 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:
40184DEFAULT_MAX_RETRIES : int = 3
41185MAX_RETRIES_LIMIT : int = 10
42186
187+
43188def 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