From 13b542df6532016fe6fb3af1f828204ff8b68d35 Mon Sep 17 00:00:00 2001 From: DioChuks Date: Tue, 28 Jul 2026 08:42:34 +0100 Subject: [PATCH 01/12] feat: support thread-safe configuration via `threading.local()` for thread isolation and thread-safe locks for global defaults. --- src/shade/config.py | 337 +++++++++++++++++++++++++++++++++----------- 1 file changed, 256 insertions(+), 81 deletions(-) diff --git a/src/shade/config.py b/src/shade/config.py index a96b469..3530016 100644 --- a/src/shade/config.py +++ b/src/shade/config.py @@ -1,81 +1,256 @@ -from __future__ import annotations - -from enum import Enum -from typing import Optional - -from stellar_sdk import Network - -class Config: - """Global SDK configuration.""" - - def __init__(self): - self.debug: bool = False - self._api_base: Optional[str] = None - self.timeout: float = DEFAULT_TIMEOUT - self.max_retries: int = DEFAULT_MAX_RETRIES - self.environment: Environment = Environment.SANDBOX - - @property - def api_base(self) -> Optional[str]: - return self._api_base - - @api_base.setter - def api_base(self, value: Optional[str]) -> None: - self._api_base = value - - def parse_environment(self, value: str | Environment) -> Environment: - if isinstance(value, Environment): - return value - if isinstance(value, str): - try: - return Environment(value.lower()) - except ValueError: - pass - raise ValueError("Invalid environment. Valid options are: 'sandbox', 'production'") - - -# Default HTTP client settings. Override via ``shade.timeout`` / ``shade.max_retries`` -# or per-client constructor arguments on ``ShadeClient`` / ``Gateway``. -DEFAULT_TIMEOUT: float = 30.0 -DEFAULT_MAX_RETRIES: int = 3 -MAX_RETRIES_LIMIT: int = 10 - -def validate_client_settings(timeout: float, max_retries: int) -> None: - """Raise ValueError for out-of-range timeout or retry settings.""" - if timeout <= 0: - raise ValueError(f"timeout must be greater than 0, got {timeout!r}") - if max_retries < 0 or max_retries > MAX_RETRIES_LIMIT: - raise ValueError( - f"max_retries must be between 0 and {MAX_RETRIES_LIMIT}, got {max_retries!r}" - ) - - -class Environment(str, Enum): - SANDBOX = "sandbox" - PRODUCTION = "production" - - @property - def base_url(self) -> str: - _urls: dict[str, str] = { - "sandbox": "https://testnet.api.shadeprotocol.io/v1", - "production": "https://api.shadeprotocol.io/v1", - } - return _urls[self.value] - - @property - def network_passphrase(self) -> str: - _passphrases: dict[str, str] = { - "sandbox": Network.TESTNET_NETWORK_PASSPHRASE, - "production": Network.PUBLIC_NETWORK_PASSPHRASE, - } - return _passphrases[self.value] - - @property - def horizon_url(self) -> str: - _horizons: dict[str, str] = { - "sandbox": "https://horizon-testnet.stellar.org", - "production": "https://horizon.stellar.org", - } - return _horizons[self.value] - -config = Config() \ No newline at end of file +from __future__ import annotations + +from enum import Enum + +import threading +from typing import NamedTuple, Optional + +from stellar_sdk import Network + +from .errors import AuthenticationError + + +class ResolvedConfig(NamedTuple): + api_key: str + environment: Environment + api_base: Optional[str] + timeout: float + max_retries: int + base_url: str + + +class Config: + """Thread-safe global SDK configuration.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._local = threading.local() + self._global_api_key: Optional[str] = None + self._global_api_base: Optional[str] = None + self._global_environment: Environment = Environment.SANDBOX + self._global_timeout: float = DEFAULT_TIMEOUT + self._global_max_retries: int = DEFAULT_MAX_RETRIES + self._global_debug: bool = False + + def reset(self) -> None: + """Reset configuration to defaults (useful for test teardowns).""" + with self._lock: + self._global_api_key = None + self._global_api_base = None + self._global_environment = Environment.SANDBOX + self._global_timeout = DEFAULT_TIMEOUT + self._global_max_retries = DEFAULT_MAX_RETRIES + self._global_debug = False + if hasattr(self._local, "__dict__"): + self._local.__dict__.clear() + + @property + def api_key(self) -> Optional[str]: + if hasattr(self._local, "api_key"): + return self._local.api_key + with self._lock: + return self._global_api_key + + @api_key.setter + def api_key(self, value: Optional[str]) -> None: + self._local.api_key = value + if threading.current_thread() is threading.main_thread(): + with self._lock: + self._global_api_key = value + + @property + def api_base(self) -> Optional[str]: + if hasattr(self._local, "api_base"): + return self._local.api_base + with self._lock: + return self._global_api_base + + @api_base.setter + def api_base(self, value: Optional[str]) -> None: + self._local.api_base = value + if threading.current_thread() is threading.main_thread(): + with self._lock: + self._global_api_base = value + + @property + def environment(self) -> Environment: + if hasattr(self._local, "environment"): + return self._local.environment + with self._lock: + return self._global_environment + + @environment.setter + def environment(self, value: str | Environment) -> None: + parsed = self.parse_environment(value) + self._local.environment = parsed + if threading.current_thread() is threading.main_thread(): + with self._lock: + self._global_environment = parsed + + @property + def timeout(self) -> float: + if hasattr(self._local, "timeout"): + return self._local.timeout + with self._lock: + return self._global_timeout + + @timeout.setter + def timeout(self, value: float) -> None: + self._local.timeout = value + if threading.current_thread() is threading.main_thread(): + with self._lock: + self._global_timeout = value + + @property + def max_retries(self) -> int: + if hasattr(self._local, "max_retries"): + return self._local.max_retries + with self._lock: + return self._global_max_retries + + @max_retries.setter + def max_retries(self, value: int) -> None: + self._local.max_retries = value + if threading.current_thread() is threading.main_thread(): + with self._lock: + self._global_max_retries = value + + @property + def debug(self) -> bool: + if hasattr(self._local, "debug"): + return self._local.debug + with self._lock: + return self._global_debug + + @debug.setter + def debug(self, value: bool) -> None: + self._local.debug = value + if threading.current_thread() is threading.main_thread(): + with self._lock: + self._global_debug = value + + def parse_environment(self, value: str | Environment) -> Environment: + if isinstance(value, Environment): + return value + if isinstance(value, str): + try: + return Environment(value.lower()) + except ValueError: + pass + raise ValueError("Invalid environment. Valid options are: 'sandbox', 'production'") + + +# Default HTTP client settings. Override via ``shade.timeout`` / ``shade.max_retries`` +# or per-client constructor arguments on ``ShadeClient`` / ``Gateway``. +DEFAULT_TIMEOUT: float = 30.0 +DEFAULT_MAX_RETRIES: int = 3 +MAX_RETRIES_LIMIT: int = 10 + + +def validate_client_settings(timeout: float, max_retries: int) -> None: + """Raise ValueError for out-of-range timeout or retry settings.""" + if timeout <= 0: + raise ValueError(f"timeout must be greater than 0, got {timeout!r}") + if max_retries < 0 or max_retries > MAX_RETRIES_LIMIT: + raise ValueError( + f"max_retries must be between 0 and {MAX_RETRIES_LIMIT}, got {max_retries!r}" + ) + + +class Environment(str, Enum): + SANDBOX = "sandbox" + PRODUCTION = "production" + + @property + def base_url(self) -> str: + _urls: dict[str, str] = { + "sandbox": "https://testnet.api.shadeprotocol.io/v1", + "production": "https://api.shadeprotocol.io/v1", + } + return _urls[self.value] + + @property + def network_passphrase(self) -> str: + _passphrases: dict[str, str] = { + "sandbox": Network.TESTNET_NETWORK_PASSPHRASE, + "production": Network.PUBLIC_NETWORK_PASSPHRASE, + } + return _passphrases[self.value] + + @property + def horizon_url(self) -> str: + _horizons: dict[str, str] = { + "sandbox": "https://horizon-testnet.stellar.org", + "production": "https://horizon.stellar.org", + } + return _horizons[self.value] + + +config = Config() + + +def get_config( + api_key: Optional[str] = None, + environment: Optional[Environment | str] = None, + api_base: Optional[str] = None, + timeout: Optional[float] = None, + max_retries: Optional[int] = None, +) -> ResolvedConfig: + """Merge instance-level overrides with global defaults. + + Parameters + ---------- + api_key : str, optional + Instance API key. If absent/None, uses ``shade.api_key``. + environment : str | Environment, optional + Instance environment. If absent/None, uses ``shade.environment``. + api_base : str, optional + Instance API base URL override. If absent/None, uses ``shade.api_base``. + timeout : float, optional + Instance socket timeout. If absent/None, uses ``shade.timeout``. + max_retries : int, optional + Instance retry limit. If absent/None, uses ``shade.max_retries``. + + Returns + ------- + ResolvedConfig + A named tuple with resolved configuration values. + + Raises + ------ + AuthenticationError + If no valid API key is set globally or at instance level. + ValueError + If timeout or max_retries are invalid. + """ + resolved_api_key = api_key if api_key is not None else config.api_key + if not resolved_api_key: + raise AuthenticationError( + "No API key provided. Set your API key using 'shade.api_key = ' " + "or pass api_key to the client." + ) + + resolved_env = ( + config.parse_environment(environment) + if environment is not None + else config.environment + ) + + resolved_api_base = api_base if api_base is not None else config.api_base + resolved_timeout = timeout if timeout is not None else config.timeout + resolved_max_retries = ( + max_retries if max_retries is not None else config.max_retries + ) + + validate_client_settings(resolved_timeout, resolved_max_retries) + + base_url = (resolved_api_base or resolved_env.base_url).rstrip("/") + + return ResolvedConfig( + api_key=resolved_api_key, + environment=resolved_env, + api_base=resolved_api_base, + timeout=resolved_timeout, + max_retries=resolved_max_retries, + base_url=base_url, + ) \ No newline at end of file From 20c804af85ccfead909cdda909f4b8ff9ea9a9ff Mon Sep 17 00:00:00 2001 From: DioChuks Date: Tue, 28 Jul 2026 08:42:52 +0100 Subject: [PATCH 02/12] feat: Exposed `api_key` getter and setter directly on the top-level `shade` package namespace (`shade.api_key = "sk_live_..."`). --- src/shade/__init__.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/shade/__init__.py b/src/shade/__init__.py index 6c5a209..826414d 100644 --- a/src/shade/__init__.py +++ b/src/shade/__init__.py @@ -3,7 +3,7 @@ from typing import Optional from .client import ShadeClient -from .config import config, Environment +from .config import config, Environment, get_config from .gateway import Gateway from .http import AsyncHTTPClient, SyncHTTPClient from .errors import ( @@ -44,6 +44,8 @@ "Transfer", "TransferStatus", "config", + "get_config", + "api_key", "api_base", "environment", "max_retries", @@ -53,6 +55,16 @@ class _ShadeModule(ModuleType): """Module subclass that exposes config-backed attributes on the shade package.""" + @property + def api_key(self) -> Optional[str]: + from . import config as _config + return _config.api_key + + @api_key.setter + def api_key(self, value: Optional[str]) -> None: + from . import config as _config + _config.api_key = value + @property def api_base(self) -> Optional[str]: from . import config as _config @@ -95,3 +107,4 @@ def environment(self, value: str | Environment) -> None: sys.modules[__name__].__class__ = _ShadeModule + From c49f7bbe03d08d639063cdbd9cdea47e3415a544 Mon Sep 17 00:00:00 2001 From: DioChuks Date: Tue, 28 Jul 2026 08:43:15 +0100 Subject: [PATCH 03/12] feat: Updated `Gateway.__init__` to allow instantiation without passing `api_key` (defaulting to `None`). --- src/shade/gateway.py | 90 +++++++++++++++++++++++++++----------------- 1 file changed, 56 insertions(+), 34 deletions(-) diff --git a/src/shade/gateway.py b/src/shade/gateway.py index 87dacd4..28de7c8 100644 --- a/src/shade/gateway.py +++ b/src/shade/gateway.py @@ -5,7 +5,7 @@ from . import config as _config from .client import ShadeClient as ClientShadeClient -from .config import Environment, validate_client_settings +from .config import Environment, validate_client_settings, get_config from .http import AsyncHTTPClient, SyncHTTPClient, DEFAULT_MAX_RETRIES @@ -15,8 +15,8 @@ class Gateway: Parameters ---------- - api_key : str - Your Shade API key. + api_key : str, optional + Your Shade API key. Defaults to 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`` (``Environment.SANDBOX``). @@ -29,16 +29,16 @@ class Gateway: Deprecated. Prefer ``api_base``. max_retries : int, optional Number of automatic retries on HTTP 429 and transient failures. - Defaults to the module-level ``shade.max_retries`` (3). Set to ``0`` + Defaults to the module-level ``shade.max_retries`` (3). Set to ``0`` to disable auto-retry. timeout : float, optional - Per-request socket timeout in seconds. Defaults to the module-level + Per-request socket timeout in seconds. Defaults to the module-level ``shade.timeout`` (30.0). """ def __init__( self, - api_key: str = "", + api_key: Optional[str] = None, environment: Optional[Environment | str] = None, api_base: Optional[str] = None, base_url: str = "", @@ -47,45 +47,66 @@ def __init__( debug: bool = False, http_client: Optional[httpx.Client] = None, ) -> None: - if not api_key: - raise ValueError("api_key must be a non-empty string") - self.api_key = api_key - - if environment is not None: - self.environment = _config.parse_environment(environment) - else: - self.environment = _config.environment - - resolved_max_retries = ( - _config.max_retries if max_retries is None else max_retries - ) - resolved_timeout = _config.timeout if timeout is None else timeout - validate_client_settings(resolved_timeout, resolved_max_retries) + self._api_key = api_key + self._environment = environment + self._api_base = api_base or (base_url if base_url else None) + self._max_retries = max_retries + self._timeout = timeout + self.debug = debug + + if max_retries is not None or timeout 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, + ) - # Resolution order: explicit api_base > module-level shade.api_base - # > legacy base_url > environment URL - resolved = api_base or _config.api_base or base_url or self.environment.base_url - self._base_url = resolved.rstrip("/") self._http = SyncHTTPClient( - base_url=self._base_url, - api_key=api_key, - max_retries=resolved_max_retries, - timeout=resolved_timeout, + base_url=self._api_base, + api_key=self._api_key, + max_retries=self._max_retries, + timeout=self._timeout, ) self._async_http = AsyncHTTPClient( - base_url=self._base_url, - api_key=api_key, - max_retries=resolved_max_retries, - timeout=resolved_timeout, + base_url=self._api_base, + api_key=self._api_key, + max_retries=self._max_retries, + timeout=self._timeout, ) self._client = ClientShadeClient( - api_key=api_key, - base_url=self._base_url, + api_key=self._api_key, + base_url=self._api_base, debug=debug, http_client=http_client, ) + @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 + + @property + def environment(self) -> Environment: + if self._environment is not None: + return _config.parse_environment(self._environment) + return _config.environment + + @environment.setter + def environment(self, value: str | Environment) -> None: + self._environment = _config.parse_environment(value) + + @property + def _base_url(self) -> str: + if self._api_base: + return self._api_base.rstrip("/") + if _config.api_base: + return _config.api_base.rstrip("/") + return self.environment.base_url.rstrip("/") + + # ------------------------------------------------------------------ # Sync API # ------------------------------------------------------------------ @@ -151,3 +172,4 @@ async def process_payment_async( "/payments", {"amount": amount, "currency": currency}, ) + From 1b99fd9aca7af238a09d425be763f83008ed4a72 Mon Sep 17 00:00:00 2001 From: DioChuks Date: Tue, 28 Jul 2026 08:43:34 +0100 Subject: [PATCH 04/12] feat: Updated `SyncHTTPClient` and `AsyncHTTPClient` to resolve parameters via `get_config()` dynamically during `request()`. --- src/shade/http.py | 1274 ++++++++++++++++++++++++--------------------- 1 file changed, 669 insertions(+), 605 deletions(-) diff --git a/src/shade/http.py b/src/shade/http.py index fbd87a2..950767a 100644 --- a/src/shade/http.py +++ b/src/shade/http.py @@ -1,605 +1,669 @@ -""" -Low-level HTTP transport for the Shade SDK. - -Handles: -* HTTP 429 rate-limit detection and ``Retry-After`` parsing -* Automatic retry with ``Retry-After`` wait (or exponential back-off fallback) -* Sync (``urllib.request``) and async (``asyncio`` + ``aiohttp`` if available, - otherwise raises ``ImportError`` with a helpful message) paths -""" -from __future__ import annotations - -import json -import logging -import math -import random -import time -import urllib.error -import urllib.parse -import urllib.request -from typing import Any, Dict, Optional, Tuple - -from .config import DEFAULT_MAX_RETRIES, validate_client_settings -from . import config as _config -from .errors import ( - AuthenticationError, - HTTPError, - InvalidRequestError, - NetworkError, - NotFoundError, - RateLimitError, - ShadeError, -) - -logger = logging.getLogger(__name__) - -try: # pragma: no cover - optional dependency - import httpx -except ImportError: # pragma: no cover - optional dependency - httpx = None - -# --------------------------------------------------------------------------- -# Constants -# --------------------------------------------------------------------------- - -_BASE_BACKOFF: float = 1.0 # seconds for exponential back-off base -_MAX_BACKOFF: float = 60.0 # cap individual wait at 60 s - - -def _validate_base_url(url: str) -> None: - """Raise ValueError if *url* is not an absolute http/https URL.""" - parsed = urllib.parse.urlparse(url) - if parsed.scheme not in ("http", "https"): - raise ValueError( - f"base_url must use http:// or https://, got: {url!r}" - ) - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _parse_retry_after(headers: Any) -> Optional[int]: - """Return integer seconds from a ``Retry-After`` header, or ``None``.""" - value = None - # urllib HTTPMessage / http.client.HTTPMessage - if hasattr(headers, "get"): - value = headers.get("Retry-After") or headers.get("retry-after") - elif isinstance(headers, dict): - value = headers.get("Retry-After") or headers.get("retry-after") - - if value is None: - return None - try: - return max(0, int(value)) - except (ValueError, TypeError): - return None - - -def _backoff_seconds(attempt: int) -> float: - """Exponential back-off: 1, 2, 4, … capped at ``_MAX_BACKOFF``.""" - return min(_BASE_BACKOFF * math.pow(2, attempt), _MAX_BACKOFF) - - -def _retry_delay(attempt: int, base_delay: float) -> float: - """Return a capped exponential delay with randomized jitter.""" - return min(base_delay * (2**attempt) + random.uniform(0, 0.5), _MAX_BACKOFF) - - -def _is_retryable_transport_error(exc: Exception) -> bool: - """Return True for transient network failures that should be retried.""" - if httpx is not None and isinstance(exc, (httpx.ConnectError, httpx.TimeoutException)): - return True - - try: - import aiohttp - except ImportError: - aiohttp = None - - if aiohttp is not None and isinstance( - exc, - ( - aiohttp.ClientConnectionError, - aiohttp.ClientConnectorError, - aiohttp.ClientOSError, - aiohttp.ServerDisconnectedError, - ), - ): - return True - - if isinstance(exc, (ConnectionResetError, TimeoutError, urllib.error.URLError)): - return True - return False - - -def _is_retryable_status(status: int) -> bool: - return status in {502, 503, 504} - - -def _retry_with_backoff(fn, max_retries: int, base_delay: float): - """Execute *fn* and retry transient failures with exponential back-off.""" - for attempt in range(max_retries + 1): - try: - return fn() - except Exception as exc: - if attempt >= max_retries or not _is_retryable_error(exc): - raise - delay = _retry_delay(attempt, base_delay) - logger.debug( - "Retrying request after transient failure (attempt %s/%s) in %.3fs", - attempt + 1, - max_retries + 1, - delay, - ) - time.sleep(delay) - - -def _is_retryable_error(exc: Exception) -> bool: - if _is_retryable_transport_error(exc): - return True - - if httpx is not None and isinstance(exc, httpx.HTTPStatusError): - return _is_retryable_status(exc.response.status_code) - - if isinstance(exc, HTTPError): - return _is_retryable_status(exc.status_code or 0) - - return False - - -def _raise_for_status( - status: int, - headers: Any, - body: bytes, - attempt: int, - max_retries: int, -) -> Optional[int]: - """ - Inspect *status* and decide what to do. - - Returns - ------- - int | None - Seconds to wait before retrying, or ``None`` if the call succeeded. - - Raises - ------ - RateLimitError - If HTTP 429 and retries are exhausted (or auto-retry is off). - InvalidRequestError - For HTTP 400 responses. - AuthenticationError - For HTTP 401/403 responses. - NotFoundError - For HTTP 404 responses. - NetworkError - For transient 502/503/504 responses after retries are exhausted. - HTTPError - For any other non-2xx status. - """ - if 200 <= status < 300: - return None # success - - if status == 429: - retry_after = _parse_retry_after(headers) - if attempt < max_retries: - wait = retry_after if retry_after is not None else _backoff_seconds(attempt) - return wait # signal: "sleep this long, then retry" - # exhausted - try: - detail = json.loads(body).get("error", {}).get("message", "") - except Exception: - detail = "" - msg = f"Rate limit exceeded. {detail}".strip() - raise RateLimitError(msg, retry_after=retry_after) - - if status == 400: - raise InvalidRequestError("Invalid request", status_code=status) - - if status in {401, 403}: - raise AuthenticationError("Authentication failed", status_code=status) - - if status == 404: - response_body = body.decode("utf-8", errors="replace") - raise NotFoundError( - "Resource not found", - status_code=status, - response_body=response_body, - ) - - if status in {502, 503, 504}: - if attempt < max_retries: - wait = _retry_delay(attempt, _BASE_BACKOFF) - logger.debug( - "Retrying request after server error (attempt %s/%s) in %.3fs", - attempt + 1, - max_retries + 1, - wait, - ) - return wait - raise NetworkError(f"Request failed with transient server error: {status}", status_code=status) - - try: - detail = json.loads(body).get("error", {}).get("message", "") - except Exception: - detail = body.decode("utf-8", errors="replace")[:200] - raise HTTPError(f"HTTP {status}: {detail}".strip(), status_code=status) - - -# --------------------------------------------------------------------------- -# Single response parser -# --------------------------------------------------------------------------- - -def _error_message(data: Any, default: str) -> str: - """Extract a human-readable message from a parsed error body. - - Handles the common shapes ``{"error": {"message": ...}}``, - ``{"error": "..."}`` and ``{"message": ...}``. Falls back to *default* - when nothing usable is present (including when the body failed to decode). - """ - if isinstance(data, dict): - err = data.get("error") - if isinstance(err, dict): - message = err.get("message") - if message: - return str(message) - elif isinstance(err, str) and err: - return err - message = data.get("message") - if message: - return str(message) - return default - - -def _field_errors(data: Any) -> Optional[Any]: - """Extract field-level validation errors from a parsed error body, if any. - - Looks for ``fields``/``field_errors``/``errors`` either nested under - ``error`` or at the top level. Returns ``None`` when absent. - """ - candidates = [] - if isinstance(data, dict): - err = data.get("error") - if isinstance(err, dict): - candidates.append(err) - candidates.append(data) - for source in candidates: - for key in ("fields", "field_errors", "errors"): - fields = source.get(key) - if fields: - return fields - return None - - -def _parse_response(response: "httpx.Response") -> Dict[str, Any]: - """Parse an ``httpx.Response`` into a dict, mapping errors to typed exceptions. - - This is the single funnel every resource method should route responses - through. Centralizing JSON decoding, success detection, and the mapping of - HTTP status codes to the SDK's typed exception hierarchy here keeps error - handling from drifting between resources. - - Parameters - ---------- - response : httpx.Response - The response returned by an httpx request. - - Returns - ------- - dict - The decoded JSON body of a successful (2xx) response. - - Raises - ------ - AuthenticationError - For HTTP 401/403. - InvalidRequestError - For HTTP 400/422, carrying field-level errors when the body provides - them. - NotFoundError - For HTTP 404. - RateLimitError - For HTTP 429. - NetworkError - For HTTP 5xx (subject to retry by callers). - HTTPError - For any other non-2xx status not covered above. - ShadeError - When a 2xx body cannot be decoded as JSON, or a 2xx body itself - carries an ``error`` key. The raw body and HTTP status are attached to - every raised exception. - """ - status = response.status_code - body = response.text - - # Decode up-front so the raw body can drive both error mapping and the - # success path. A decode failure is captured rather than raised here so - # error statuses still produce their typed exception with the raw body. - try: - data: Any = json.loads(body) if body else {} - decoded = True - except (json.JSONDecodeError, ValueError): - data = None - decoded = False - - if 200 <= status < 300: - if not decoded: - raise ShadeError( - "Invalid response from API", - status_code=status, - response_body=body, - ) - if not isinstance(data, dict): - raise ShadeError( - "Invalid response from API", - status_code=status, - response_body=body, - ) - # A 2xx body that still carries an error is treated as a failure. - if data.get("error"): - raise ShadeError( - _error_message(data, "API returned an error"), - status_code=status, - response_body=body, - ) - return data - - if status in (401, 403): - raise AuthenticationError( - _error_message(data, "Authentication failed"), - status_code=status, - response_body=body, - ) - - if status in (400, 422): - raise InvalidRequestError( - _error_message(data, "Invalid request"), - status_code=status, - response_body=body, - field_errors=_field_errors(data), - ) - - if status == 404: - raise NotFoundError( - _error_message(data, "Resource not found"), - status_code=status, - response_body=body, - ) - - if status == 429: - raise RateLimitError( - _error_message(data, "Rate limit exceeded"), - retry_after=_parse_retry_after(response.headers), - status_code=status, - response_body=body, - ) - - if 500 <= status < 600: - raise NetworkError( - _error_message(data, f"Server error: {status}"), - status_code=status, - response_body=body, - ) - - # Any other non-2xx status (e.g. 3xx, uncommon 4xx) still maps to a typed - # exception so nothing escapes the funnel unhandled. - raise HTTPError( - _error_message(data, f"HTTP {status}"), - status_code=status, - response_body=body, - ) - - -# --------------------------------------------------------------------------- -# Synchronous client -# --------------------------------------------------------------------------- - -class SyncHTTPClient: - """ - Thin synchronous HTTP client with built-in 429 handling. - - Parameters - ---------- - base_url : str - Base URL (no trailing slash). - api_key : str - Bearer token sent as ``Authorization: Bearer ``. - max_retries : int - How many times to retry on 429 before raising ``RateLimitError``. - Set to ``0`` to disable auto-retry. - timeout : float - Socket timeout in seconds. - """ - - def __init__( - self, - base_url: str, - api_key: str, - max_retries: Optional[int] = None, - timeout: Optional[float] = None, - ) -> None: - _validate_base_url(base_url) - self.base_url = base_url.rstrip("/") - self.api_key = api_key - self.max_retries = _config.max_retries if max_retries is None else max_retries - self.timeout = _config.timeout if timeout is None else timeout - validate_client_settings(self.timeout, self.max_retries) - - def _build_request( - self, method: str, path: str, payload: Optional[Dict[str, Any]] - ) -> urllib.request.Request: - url = f"{self.base_url}/{path.lstrip('/')}" - data = json.dumps(payload).encode("utf-8") if payload is not None else None - req = urllib.request.Request(url, data=data, method=method.upper()) - req.add_header("Authorization", f"Bearer {self.api_key}") - req.add_header("Content-Type", "application/json") - req.add_header("Accept", "application/json") - return req - - def request( - self, - method: str, - path: str, - payload: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: - """ - Execute an HTTP request, retrying on 429 as configured. - - Returns - ------- - dict - Parsed JSON response body. - - Raises - ------ - RateLimitError - If 429 and ``max_retries`` is exhausted. - HTTPError - For other non-2xx responses. - """ - attempt = 0 - while True: - req = self._build_request(method, path, payload) - try: - status, headers, body = self._execute(req) - except Exception as exc: - if _is_retryable_transport_error(exc): - if attempt >= self.max_retries: - raise NetworkError( - "Request failed after exhausting retries", - status_code=None, - ) from exc - delay = _retry_delay(attempt, _BASE_BACKOFF) - logger.debug( - "Retrying request after transient failure (attempt %s/%s) in %.3fs", - attempt + 1, - self.max_retries + 1, - delay, - ) - time.sleep(delay) - attempt += 1 - continue - raise - wait = _raise_for_status(status, headers, body, attempt, self.max_retries) - if wait is None: - return json.loads(body) if body else {} - time.sleep(wait) - attempt += 1 - - def _execute( - self, req: urllib.request.Request - ) -> Tuple[int, Any, bytes]: - """Send *req* and return (status, headers, body).""" - try: - with urllib.request.urlopen(req, timeout=self.timeout) as resp: - return resp.status, resp.headers, resp.read() - except urllib.error.HTTPError as exc: - body = exc.read() - return exc.code, exc.headers, body - - -# --------------------------------------------------------------------------- -# Asynchronous client -# --------------------------------------------------------------------------- - -class AsyncHTTPClient: - """ - Async counterpart of ``SyncHTTPClient``. Uses ``aiohttp`` under the hood. - - Parameters - ---------- - Same as ``SyncHTTPClient``. - """ - - def __init__( - self, - base_url: str, - api_key: str, - max_retries: Optional[int] = None, - timeout: Optional[float] = None, - ) -> None: - _validate_base_url(base_url) - self.base_url = base_url.rstrip("/") - self.api_key = api_key - self.max_retries = _config.max_retries if max_retries is None else max_retries - self.timeout = _config.timeout if timeout is None else timeout - validate_client_settings(self.timeout, self.max_retries) - - async def request( - self, - method: str, - path: str, - payload: Optional[Dict[str, Any]] = None, - ) -> Dict[str, Any]: - """ - Async HTTP request with 429 retry using ``asyncio.sleep``. - - Returns - ------- - dict - Parsed JSON response body. - - Raises - ------ - RateLimitError, HTTPError - Same semantics as ``SyncHTTPClient.request``. - ImportError - If ``aiohttp`` is not installed. - """ - import asyncio # stdlib — always available - - try: - import aiohttp - except ImportError as exc: - raise ImportError( - "aiohttp is required for async support. " - "Install it with: pip install aiohttp" - ) from exc - - url_base = self.base_url - headers = { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - "Accept": "application/json", - } - connector = aiohttp.TCPConnector() - timeout_cfg = aiohttp.ClientTimeout(total=self.timeout) - - attempt = 0 - async with aiohttp.ClientSession( - connector=connector, timeout=timeout_cfg - ) as session: - while True: - url = f"{url_base}/{path.lstrip('/')}" - try: - resp = await session.request( - method.upper(), - url, - json=payload, - headers=headers, - ) - body = await resp.read() - except Exception as exc: - if _is_retryable_transport_error(exc): - if attempt >= self.max_retries: - raise NetworkError( - "Request failed after exhausting retries", - status_code=None, - ) from exc - delay = _retry_delay(attempt, _BASE_BACKOFF) - logger.debug( - "Retrying request after transient failure (attempt %s/%s) in %.3fs", - attempt + 1, - self.max_retries + 1, - delay, - ) - await asyncio.sleep(delay) - attempt += 1 - continue - raise - wait = _raise_for_status( - resp.status, resp.headers, body, attempt, self.max_retries - ) - if wait is None: - return json.loads(body) if body else {} - await asyncio.sleep(wait) - attempt += 1 \ No newline at end of file +""" +Low-level HTTP transport for the Shade SDK. + +Handles: +* HTTP 429 rate-limit detection and ``Retry-After`` parsing +* Automatic retry with ``Retry-After`` wait (or exponential back-off fallback) +* Sync (``urllib.request``) and async (``asyncio`` + ``aiohttp`` if available, + otherwise raises ``ImportError`` with a helpful message) paths +""" +from __future__ import annotations + +import json +import logging +import math +import random +import time +import urllib.error +import urllib.parse +import urllib.request +from typing import Any, Dict, Optional, Tuple + +from .config import DEFAULT_MAX_RETRIES, validate_client_settings, get_config +from . import config as _config +from .errors import ( + AuthenticationError, + HTTPError, + InvalidRequestError, + NetworkError, + NotFoundError, + RateLimitError, + ShadeError, +) + +logger = logging.getLogger(__name__) + +try: # pragma: no cover - optional dependency + import httpx +except ImportError: # pragma: no cover - optional dependency + httpx = None + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_BASE_BACKOFF: float = 1.0 # seconds for exponential back-off base +_MAX_BACKOFF: float = 60.0 # cap individual wait at 60 s + + +def _validate_base_url(url: str) -> None: + """Raise ValueError if *url* is not an absolute http/https URL.""" + parsed = urllib.parse.urlparse(url) + if parsed.scheme not in ("http", "https"): + raise ValueError( + f"base_url must use http:// or https://, got: {url!r}" + ) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _parse_retry_after(headers: Any) -> Optional[int]: + """Return integer seconds from a ``Retry-After`` header, or ``None``.""" + value = None + # urllib HTTPMessage / http.client.HTTPMessage + if hasattr(headers, "get"): + value = headers.get("Retry-After") or headers.get("retry-after") + elif isinstance(headers, dict): + value = headers.get("Retry-After") or headers.get("retry-after") + + if value is None: + return None + try: + return max(0, int(value)) + except (ValueError, TypeError): + return None + + +def _backoff_seconds(attempt: int) -> float: + """Exponential back-off: 1, 2, 4, … capped at ``_MAX_BACKOFF``.""" + return min(_BASE_BACKOFF * math.pow(2, attempt), _MAX_BACKOFF) + + +def _retry_delay(attempt: int, base_delay: float) -> float: + """Return a capped exponential delay with randomized jitter.""" + return min(base_delay * (2**attempt) + random.uniform(0, 0.5), _MAX_BACKOFF) + + +def _is_retryable_transport_error(exc: Exception) -> bool: + """Return True for transient network failures that should be retried.""" + if httpx is not None and isinstance(exc, (httpx.ConnectError, httpx.TimeoutException)): + return True + + try: + import aiohttp + except ImportError: + aiohttp = None + + if aiohttp is not None and isinstance( + exc, + ( + aiohttp.ClientConnectionError, + aiohttp.ClientConnectorError, + aiohttp.ClientOSError, + aiohttp.ServerDisconnectedError, + ), + ): + return True + + if isinstance(exc, (ConnectionResetError, TimeoutError, urllib.error.URLError)): + return True + return False + + +def _is_retryable_status(status: int) -> bool: + return status in {502, 503, 504} + + +def _retry_with_backoff(fn, max_retries: int, base_delay: float): + """Execute *fn* and retry transient failures with exponential back-off.""" + for attempt in range(max_retries + 1): + try: + return fn() + except Exception as exc: + if attempt >= max_retries or not _is_retryable_error(exc): + raise + delay = _retry_delay(attempt, base_delay) + logger.debug( + "Retrying request after transient failure (attempt %s/%s) in %.3fs", + attempt + 1, + max_retries + 1, + delay, + ) + time.sleep(delay) + + +def _is_retryable_error(exc: Exception) -> bool: + if _is_retryable_transport_error(exc): + return True + + if httpx is not None and isinstance(exc, httpx.HTTPStatusError): + return _is_retryable_status(exc.response.status_code) + + if isinstance(exc, HTTPError): + return _is_retryable_status(exc.status_code or 0) + + return False + + +def _raise_for_status( + status: int, + headers: Any, + body: bytes, + attempt: int, + max_retries: int, +) -> Optional[int]: + """ + Inspect *status* and decide what to do. + + Returns + ------- + int | None + Seconds to wait before retrying, or ``None`` if the call succeeded. + + Raises + ------ + RateLimitError + If HTTP 429 and retries are exhausted (or auto-retry is off). + InvalidRequestError + For HTTP 400 responses. + AuthenticationError + For HTTP 401/403 responses. + NotFoundError + For HTTP 404 responses. + NetworkError + For transient 502/503/504 responses after retries are exhausted. + HTTPError + For any other non-2xx status. + """ + if 200 <= status < 300: + return None # success + + if status == 429: + retry_after = _parse_retry_after(headers) + if attempt < max_retries: + wait = retry_after if retry_after is not None else _backoff_seconds(attempt) + return wait # signal: "sleep this long, then retry" + # exhausted + try: + detail = json.loads(body).get("error", {}).get("message", "") + except Exception: + detail = "" + msg = f"Rate limit exceeded. {detail}".strip() + raise RateLimitError(msg, retry_after=retry_after) + + if status == 400: + raise InvalidRequestError("Invalid request", status_code=status) + + if status in {401, 403}: + raise AuthenticationError("Authentication failed", status_code=status) + + if status == 404: + response_body = body.decode("utf-8", errors="replace") + raise NotFoundError( + "Resource not found", + status_code=status, + response_body=response_body, + ) + + if status in {502, 503, 504}: + if attempt < max_retries: + wait = _retry_delay(attempt, _BASE_BACKOFF) + logger.debug( + "Retrying request after server error (attempt %s/%s) in %.3fs", + attempt + 1, + max_retries + 1, + wait, + ) + return wait + raise NetworkError(f"Request failed with transient server error: {status}", status_code=status) + + try: + detail = json.loads(body).get("error", {}).get("message", "") + except Exception: + detail = body.decode("utf-8", errors="replace")[:200] + raise HTTPError(f"HTTP {status}: {detail}".strip(), status_code=status) + + +# --------------------------------------------------------------------------- +# Single response parser +# --------------------------------------------------------------------------- + +def _error_message(data: Any, default: str) -> str: + """Extract a human-readable message from a parsed error body. + + Handles the common shapes ``{"error": {"message": ...}}``, + ``{"error": "..."}`` and ``{"message": ...}``. Falls back to *default* + when nothing usable is present (including when the body failed to decode). + """ + if isinstance(data, dict): + err = data.get("error") + if isinstance(err, dict): + message = err.get("message") + if message: + return str(message) + elif isinstance(err, str) and err: + return err + message = data.get("message") + if message: + return str(message) + return default + + +def _field_errors(data: Any) -> Optional[Any]: + """Extract field-level validation errors from a parsed error body, if any. + + Looks for ``fields``/``field_errors``/``errors`` either nested under + ``error`` or at the top level. Returns ``None`` when absent. + """ + candidates = [] + if isinstance(data, dict): + err = data.get("error") + if isinstance(err, dict): + candidates.append(err) + candidates.append(data) + for source in candidates: + for key in ("fields", "field_errors", "errors"): + fields = source.get(key) + if fields: + return fields + return None + + +def _parse_response(response: "httpx.Response") -> Dict[str, Any]: + """Parse an ``httpx.Response`` into a dict, mapping errors to typed exceptions. + + This is the single funnel every resource method should route responses + through. Centralizing JSON decoding, success detection, and the mapping of + HTTP status codes to the SDK's typed exception hierarchy here keeps error + handling from drifting between resources. + + Parameters + ---------- + response : httpx.Response + The response returned by an httpx request. + + Returns + ------- + dict + The decoded JSON body of a successful (2xx) response. + + Raises + ------ + AuthenticationError + For HTTP 401/403. + InvalidRequestError + For HTTP 400/422, carrying field-level errors when the body provides + them. + NotFoundError + For HTTP 404. + RateLimitError + For HTTP 429. + NetworkError + For HTTP 5xx (subject to retry by callers). + HTTPError + For any other non-2xx status not covered above. + ShadeError + When a 2xx body cannot be decoded as JSON, or a 2xx body itself + carries an ``error`` key. The raw body and HTTP status are attached to + every raised exception. + """ + status = response.status_code + body = response.text + + # Decode up-front so the raw body can drive both error mapping and the + # success path. A decode failure is captured rather than raised here so + # error statuses still produce their typed exception with the raw body. + try: + data: Any = json.loads(body) if body else {} + decoded = True + except (json.JSONDecodeError, ValueError): + data = None + decoded = False + + if 200 <= status < 300: + if not decoded: + raise ShadeError( + "Invalid response from API", + status_code=status, + response_body=body, + ) + if not isinstance(data, dict): + raise ShadeError( + "Invalid response from API", + status_code=status, + response_body=body, + ) + # A 2xx body that still carries an error is treated as a failure. + if data.get("error"): + raise ShadeError( + _error_message(data, "API returned an error"), + status_code=status, + response_body=body, + ) + return data + + if status in (401, 403): + raise AuthenticationError( + _error_message(data, "Authentication failed"), + status_code=status, + response_body=body, + ) + + if status in (400, 422): + raise InvalidRequestError( + _error_message(data, "Invalid request"), + status_code=status, + response_body=body, + field_errors=_field_errors(data), + ) + + if status == 404: + raise NotFoundError( + _error_message(data, "Resource not found"), + status_code=status, + response_body=body, + ) + + if status == 429: + raise RateLimitError( + _error_message(data, "Rate limit exceeded"), + retry_after=_parse_retry_after(response.headers), + status_code=status, + response_body=body, + ) + + if 500 <= status < 600: + raise NetworkError( + _error_message(data, f"Server error: {status}"), + status_code=status, + response_body=body, + ) + + # Any other non-2xx status (e.g. 3xx, uncommon 4xx) still maps to a typed + # exception so nothing escapes the funnel unhandled. + raise HTTPError( + _error_message(data, f"HTTP {status}"), + status_code=status, + response_body=body, + ) + + +# --------------------------------------------------------------------------- +# Synchronous client +# --------------------------------------------------------------------------- + +class SyncHTTPClient: + """ + Thin synchronous HTTP client with built-in 429 handling. + + Parameters + ---------- + base_url : str, optional + Base URL (no trailing slash). + api_key : str, optional + Bearer token sent as ``Authorization: Bearer ``. + max_retries : int, optional + How many times to retry on 429 before raising ``RateLimitError``. + Set to ``0`` to disable auto-retry. + timeout : float, optional + Socket timeout in seconds. + """ + + def __init__( + self, + base_url: Optional[str] = None, + api_key: Optional[str] = None, + max_retries: Optional[int] = None, + timeout: Optional[float] = None, + ) -> None: + if base_url: + _validate_base_url(base_url) + self._base_url: Optional[str] = base_url.rstrip("/") + else: + self._base_url = None + self.api_key = api_key + self._max_retries = max_retries + self._timeout = timeout + 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, + ) + + @property + def max_retries(self) -> int: + return self._max_retries if self._max_retries is not None else _config.max_retries + + @property + def timeout(self) -> float: + return self._timeout if self._timeout is not None else _config.timeout + + @property + def base_url(self) -> str: + if self._base_url: + return self._base_url + return _config.api_base or _config.environment.base_url.rstrip("/") + + def _build_request( + self, + method: str, + path: str, + payload: Optional[Dict[str, Any]], + resolved_api_key: str, + resolved_base_url: str, + ) -> urllib.request.Request: + url = f"{resolved_base_url}/{path.lstrip('/')}" + data = json.dumps(payload).encode("utf-8") if payload is not None else None + req = urllib.request.Request(url, data=data, method=method.upper()) + req.add_header("Authorization", f"Bearer {resolved_api_key}") + req.add_header("Content-Type", "application/json") + req.add_header("Accept", "application/json") + return req + + def request( + self, + method: str, + path: str, + payload: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """ + Execute an HTTP request, retrying on 429 as configured. + + Returns + ------- + dict + Parsed JSON response body. + + Raises + ------ + RateLimitError + If 429 and ``max_retries`` is exhausted. + HTTPError + For other non-2xx responses. + AuthenticationError + If api_key is missing/None. + """ + cfg = get_config( + api_key=self.api_key, + api_base=self._base_url, + timeout=self._timeout, + max_retries=self._max_retries, + ) + attempt = 0 + while True: + req = self._build_request(method, path, payload, cfg.api_key, cfg.base_url) + try: + status, headers, body = self._execute(req) + except Exception as exc: + if _is_retryable_transport_error(exc): + if attempt >= cfg.max_retries: + raise NetworkError( + "Request failed after exhausting retries", + status_code=None, + ) from exc + delay = _retry_delay(attempt, _BASE_BACKOFF) + logger.debug( + "Retrying request after transient failure (attempt %s/%s) in %.3fs", + attempt + 1, + cfg.max_retries + 1, + delay, + ) + time.sleep(delay) + attempt += 1 + continue + raise + wait = _raise_for_status(status, headers, body, attempt, cfg.max_retries) + if wait is None: + return json.loads(body) if body else {} + time.sleep(wait) + attempt += 1 + + def _execute( + self, req: urllib.request.Request + ) -> Tuple[int, Any, bytes]: + """Send *req* and return (status, headers, body).""" + try: + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + return resp.status, resp.headers, resp.read() + except urllib.error.HTTPError as exc: + body = exc.read() + return exc.code, exc.headers, body + + + +# --------------------------------------------------------------------------- +# Asynchronous client +# --------------------------------------------------------------------------- + +class AsyncHTTPClient: + """ + Async counterpart of ``SyncHTTPClient``. Uses ``aiohttp`` under the hood. + + Parameters + ---------- + Same as ``SyncHTTPClient``. + """ + + def __init__( + self, + base_url: Optional[str] = None, + api_key: Optional[str] = None, + max_retries: Optional[int] = None, + timeout: Optional[float] = None, + ) -> None: + if base_url: + _validate_base_url(base_url) + self._base_url: Optional[str] = base_url.rstrip("/") + else: + self._base_url = None + self.api_key = api_key + self._max_retries = max_retries + self._timeout = timeout + 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, + ) + + @property + def max_retries(self) -> int: + return self._max_retries if self._max_retries is not None else _config.max_retries + + @property + def timeout(self) -> float: + return self._timeout if self._timeout is not None else _config.timeout + + @property + def base_url(self) -> str: + if self._base_url: + return self._base_url + return _config.api_base or _config.environment.base_url.rstrip("/") + + async def request( + self, + method: str, + path: str, + payload: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """ + Async HTTP request with 429 retry using ``asyncio.sleep``. + + Returns + ------- + dict + Parsed JSON response body. + + Raises + ------ + RateLimitError, HTTPError + Same semantics as ``SyncHTTPClient.request``. + AuthenticationError + If api_key is missing/None. + ImportError + If ``aiohttp`` is not installed. + """ + import asyncio # stdlib — always available + + try: + import aiohttp + except ImportError as exc: + raise ImportError( + "aiohttp is required for async support. " + "Install it with: pip install aiohttp" + ) from exc + + cfg = get_config( + api_key=self.api_key, + api_base=self._base_url, + timeout=self._timeout, + max_retries=self._max_retries, + ) + + headers = { + "Authorization": f"Bearer {cfg.api_key}", + "Content-Type": "application/json", + "Accept": "application/json", + } + connector = aiohttp.TCPConnector() + timeout_cfg = aiohttp.ClientTimeout(total=cfg.timeout) + + attempt = 0 + async with aiohttp.ClientSession( + connector=connector, timeout=timeout_cfg + ) as session: + while True: + url = f"{cfg.base_url}/{path.lstrip('/')}" + try: + resp = await session.request( + method.upper(), + url, + json=payload, + headers=headers, + ) + body = await resp.read() + except Exception as exc: + if _is_retryable_transport_error(exc): + if attempt >= cfg.max_retries: + raise NetworkError( + "Request failed after exhausting retries", + status_code=None, + ) from exc + delay = _retry_delay(attempt, _BASE_BACKOFF) + logger.debug( + "Retrying request after transient failure (attempt %s/%s) in %.3fs", + attempt + 1, + cfg.max_retries + 1, + delay, + ) + await asyncio.sleep(delay) + attempt += 1 + continue + raise + wait = _raise_for_status( + resp.status, resp.headers, body, attempt, cfg.max_retries + ) + if wait is None: + return json.loads(body) if body else {} + await asyncio.sleep(wait) + attempt += 1 \ No newline at end of file From a28902d481f9940eeaee5faea9ffc3bd3c9dbe28 Mon Sep 17 00:00:00 2001 From: DioChuks Date: Tue, 28 Jul 2026 08:43:53 +0100 Subject: [PATCH 05/12] feat: Updated `ShadeClient` `request()` method to evaluate `get_config()` and validate `api_key`. --- src/shade/client.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/shade/client.py b/src/shade/client.py index 9557a5c..e5e818e 100644 --- a/src/shade/client.py +++ b/src/shade/client.py @@ -3,7 +3,7 @@ import httpx from shade._debug import log_request, log_response -from shade.config import config +from shade.config import config, get_config class ShadeClient: @@ -11,17 +11,23 @@ class ShadeClient: def __init__( self, - api_key: str, - base_url: str = "https://api.shadeprotocol.io", + api_key: Optional[str] = None, + base_url: Optional[str] = None, debug: bool = False, http_client: Optional[httpx.Client] = None, ): self.api_key = api_key - self.base_url = base_url.rstrip("/") + self._base_url = base_url.rstrip("/") if base_url else None self.debug = debug self._http = http_client or httpx.Client() self._owns_http_client = http_client is None + @property + def base_url(self) -> str: + if self._base_url: + return self._base_url + return config.api_base or config.environment.base_url.rstrip("/") + def close(self) -> None: if self._owns_http_client: self._http.close() @@ -35,9 +41,6 @@ def __exit__(self, *args: Any) -> None: def _should_debug(self) -> bool: return self.debug or config.debug - def _default_headers(self) -> dict[str, str]: - return {"Authorization": f"Bearer {self.api_key}"} - def request( self, method: str, @@ -47,9 +50,13 @@ def request( json: Any = None, content: Optional[bytes] = None, ) -> httpx.Response: + cfg = get_config( + api_key=self.api_key, + api_base=self._base_url, + ) normalized_path = path if path.startswith("/") else f"/{path}" - url = f"{self.base_url}{normalized_path}" - request_headers = {**self._default_headers(), **(headers or {})} + url = f"{cfg.base_url}{normalized_path}" + request_headers = {"Authorization": f"Bearer {cfg.api_key}", **(headers or {})} if self._should_debug(): log_request(method, url, request_headers, content if content is not None else json) @@ -66,3 +73,4 @@ def request( log_response(response.status_code, response.headers, response.text) return response + From 777a12614c50a60bf266f442cb504b7b65f057c1 Mon Sep 17 00:00:00 2001 From: DioChuks Date: Tue, 28 Jul 2026 08:45:04 +0100 Subject: [PATCH 06/12] test: global assignment, active environ switching, multi-threaded execution, instance-level overrides --- tests/test_global_config.py | 147 ++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 tests/test_global_config.py diff --git a/tests/test_global_config.py b/tests/test_global_config.py new file mode 100644 index 0000000..b1ed954 --- /dev/null +++ b/tests/test_global_config.py @@ -0,0 +1,147 @@ +""" +Tests for global shade module configuration (issue: global shade module configuration). + +Covers: +* shade.api_key = "sk_live_xxx" sets global key accessible across resource calls +* shade.environment = "sandbox" / "production" switches active environment +* Setting shade.api_key = None and calling resource raises AuthenticationError +* Concurrent threads modifying config do not bleed into each other +* Instance-level overrides take precedence over global settings +""" + +from __future__ import annotations + +import concurrent.futures +from unittest.mock import patch + +import pytest + +import shade +from shade import Gateway, ShadeClient, Environment, AuthenticationError +from shade.config import config + + +@pytest.fixture(autouse=True) +def _reset_global_config(): + config.reset() + yield + config.reset() + + +class TestGlobalConfigAssignments: + def test_api_key_global_assignment(self): + assert shade.api_key is None + shade.api_key = "sk_live_12345" + assert shade.api_key == "sk_live_12345" + assert config.api_key == "sk_live_12345" + + def test_environment_string_assignment(self): + assert shade.environment == Environment.SANDBOX + shade.environment = "production" + assert shade.environment == Environment.PRODUCTION + assert config.environment == Environment.PRODUCTION + + def test_environment_enum_assignment(self): + shade.environment = Environment.SANDBOX + assert shade.environment == Environment.SANDBOX + + def test_invalid_environment_raises(self): + with pytest.raises(ValueError, match="Invalid environment"): + shade.environment = "invalid_env" + + +class TestAuthenticationErrorOnMissingKey: + def test_none_api_key_raises_authentication_error_on_gateway_call(self): + shade.api_key = None + gateway = Gateway() + + with pytest.raises(AuthenticationError, match="No API key provided"): + gateway.process_payment(100.0, "USD") + + def test_none_api_key_raises_authentication_error_on_client_request(self): + shade.api_key = None + client = ShadeClient() + + with pytest.raises(AuthenticationError, match="No API key provided"): + client.request("GET", "/test") + + def test_setting_api_key_none_after_init_raises(self): + shade.api_key = "sk_test_init" + gateway = Gateway() + + shade.api_key = None + + with pytest.raises(AuthenticationError, match="No API key provided"): + gateway.process_payment(50.0, "USD") + + +class TestGlobalConfigResourceCalls: + def test_gateway_uses_global_api_key_and_environment(self): + shade.api_key = "sk_live_global" + shade.environment = "production" + + gateway = Gateway() + + with patch.object(gateway._http, "_execute") as mock_exec: + mock_exec.return_value = (200, {}, b'{"id": "pay_1", "status": "success"}') + res = gateway.process_payment(200.0, "USD") + + assert res == {"id": "pay_1", "status": "success"} + mock_exec.assert_called_once() + req = mock_exec.call_args[0][0] + assert req.headers["Authorization"] == "Bearer sk_live_global" + assert req.full_url.startswith("https://api.shadeprotocol.io/v1") + + +class TestInstanceOverridesBeatsGlobalConfig: + def test_instance_api_key_beats_global(self): + shade.api_key = "sk_global" + gateway = Gateway(api_key="sk_instance_override") + + with patch.object(gateway._http, "_execute") as mock_exec: + mock_exec.return_value = (200, {}, b'{"ok": true}') + gateway.process_payment(10.0, "USD") + + req = mock_exec.call_args[0][0] + assert req.headers["Authorization"] == "Bearer sk_instance_override" + + def test_instance_api_base_beats_global(self): + shade.api_base = "https://global-base.example.com" + gateway = Gateway(api_key="sk_test", api_base="https://override-base.example.com") + + with patch.object(gateway._http, "_execute") as mock_exec: + mock_exec.return_value = (200, {}, b'{"ok": true}') + gateway.process_payment(10.0, "USD") + + req = mock_exec.call_args[0][0] + assert req.full_url.startswith("https://override-base.example.com") + + + +class TestThreadSafety: + def test_concurrent_threads_do_not_bleed(self): + shade.api_key = "sk_main_thread" + + results = {} + + def worker(thread_id: int, key: str): + shade.api_key = key + # Simulate work + gateway = Gateway() + # Inspect resolved key for this thread + resolved_key = gateway.api_key + results[thread_id] = resolved_key + + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor: + f1 = executor.submit(worker, 1, "sk_thread_1") + f2 = executor.submit(worker, 2, "sk_thread_2") + f3 = executor.submit(worker, 3, "sk_thread_3") + f4 = executor.submit(worker, 4, "sk_thread_4") + concurrent.futures.wait([f1, f2, f3, f4]) + + assert results[1] == "sk_thread_1" + assert results[2] == "sk_thread_2" + assert results[3] == "sk_thread_3" + assert results[4] == "sk_thread_4" + # Main thread key should remain untouched + assert shade.api_key == "sk_main_thread" From 708cabf79b15a511eb0d0fef7a835d4fa8d40061 Mon Sep 17 00:00:00 2001 From: DioChuks Date: Tue, 28 Jul 2026 08:45:41 +0100 Subject: [PATCH 07/12] test: refactor `_reset_client_settings` fixture to use `_config.reset()` --- tests/test_client_settings.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/test_client_settings.py b/tests/test_client_settings.py index cff56e7..2a2b885 100644 --- a/tests/test_client_settings.py +++ b/tests/test_client_settings.py @@ -25,11 +25,10 @@ @pytest.fixture(autouse=True) def _reset_client_settings(): - original_timeout = _config.timeout - original_max_retries = _config.max_retries + _config.reset() yield - _config.timeout = original_timeout - _config.max_retries = original_max_retries + _config.reset() + # --------------------------------------------------------------------------- From cce66c89e33114cf7c9c5aad83dd12623c7f535c Mon Sep 17 00:00:00 2001 From: DioChuks Date: Tue, 28 Jul 2026 09:05:57 +0100 Subject: [PATCH 08/12] fix: Functional Correctness --- src/shade/config.py | 536 +++++++++++++++++++----------------- tests/test_global_config.py | 23 ++ 2 files changed, 303 insertions(+), 256 deletions(-) diff --git a/src/shade/config.py b/src/shade/config.py index 3530016..7c6496b 100644 --- a/src/shade/config.py +++ b/src/shade/config.py @@ -1,256 +1,280 @@ -from __future__ import annotations - -from enum import Enum - -import threading -from typing import NamedTuple, Optional - -from stellar_sdk import Network - -from .errors import AuthenticationError - - -class ResolvedConfig(NamedTuple): - api_key: str - environment: Environment - api_base: Optional[str] - timeout: float - max_retries: int - base_url: str - - -class Config: - """Thread-safe global SDK configuration.""" - - def __init__(self) -> None: - self._lock = threading.Lock() - self._local = threading.local() - self._global_api_key: Optional[str] = None - self._global_api_base: Optional[str] = None - self._global_environment: Environment = Environment.SANDBOX - self._global_timeout: float = DEFAULT_TIMEOUT - self._global_max_retries: int = DEFAULT_MAX_RETRIES - self._global_debug: bool = False - - def reset(self) -> None: - """Reset configuration to defaults (useful for test teardowns).""" - with self._lock: - self._global_api_key = None - self._global_api_base = None - self._global_environment = Environment.SANDBOX - self._global_timeout = DEFAULT_TIMEOUT - self._global_max_retries = DEFAULT_MAX_RETRIES - self._global_debug = False - if hasattr(self._local, "__dict__"): - self._local.__dict__.clear() - - @property - def api_key(self) -> Optional[str]: - if hasattr(self._local, "api_key"): - return self._local.api_key - with self._lock: - return self._global_api_key - - @api_key.setter - def api_key(self, value: Optional[str]) -> None: - self._local.api_key = value - if threading.current_thread() is threading.main_thread(): - with self._lock: - self._global_api_key = value - - @property - def api_base(self) -> Optional[str]: - if hasattr(self._local, "api_base"): - return self._local.api_base - with self._lock: - return self._global_api_base - - @api_base.setter - def api_base(self, value: Optional[str]) -> None: - self._local.api_base = value - if threading.current_thread() is threading.main_thread(): - with self._lock: - self._global_api_base = value - - @property - def environment(self) -> Environment: - if hasattr(self._local, "environment"): - return self._local.environment - with self._lock: - return self._global_environment - - @environment.setter - def environment(self, value: str | Environment) -> None: - parsed = self.parse_environment(value) - self._local.environment = parsed - if threading.current_thread() is threading.main_thread(): - with self._lock: - self._global_environment = parsed - - @property - def timeout(self) -> float: - if hasattr(self._local, "timeout"): - return self._local.timeout - with self._lock: - return self._global_timeout - - @timeout.setter - def timeout(self, value: float) -> None: - self._local.timeout = value - if threading.current_thread() is threading.main_thread(): - with self._lock: - self._global_timeout = value - - @property - def max_retries(self) -> int: - if hasattr(self._local, "max_retries"): - return self._local.max_retries - with self._lock: - return self._global_max_retries - - @max_retries.setter - def max_retries(self, value: int) -> None: - self._local.max_retries = value - if threading.current_thread() is threading.main_thread(): - with self._lock: - self._global_max_retries = value - - @property - def debug(self) -> bool: - if hasattr(self._local, "debug"): - return self._local.debug - with self._lock: - return self._global_debug - - @debug.setter - def debug(self, value: bool) -> None: - self._local.debug = value - if threading.current_thread() is threading.main_thread(): - with self._lock: - self._global_debug = value - - def parse_environment(self, value: str | Environment) -> Environment: - if isinstance(value, Environment): - return value - if isinstance(value, str): - try: - return Environment(value.lower()) - except ValueError: - pass - raise ValueError("Invalid environment. Valid options are: 'sandbox', 'production'") - - -# Default HTTP client settings. Override via ``shade.timeout`` / ``shade.max_retries`` -# or per-client constructor arguments on ``ShadeClient`` / ``Gateway``. -DEFAULT_TIMEOUT: float = 30.0 -DEFAULT_MAX_RETRIES: int = 3 -MAX_RETRIES_LIMIT: int = 10 - - -def validate_client_settings(timeout: float, max_retries: int) -> None: - """Raise ValueError for out-of-range timeout or retry settings.""" - if timeout <= 0: - raise ValueError(f"timeout must be greater than 0, got {timeout!r}") - if max_retries < 0 or max_retries > MAX_RETRIES_LIMIT: - raise ValueError( - f"max_retries must be between 0 and {MAX_RETRIES_LIMIT}, got {max_retries!r}" - ) - - -class Environment(str, Enum): - SANDBOX = "sandbox" - PRODUCTION = "production" - - @property - def base_url(self) -> str: - _urls: dict[str, str] = { - "sandbox": "https://testnet.api.shadeprotocol.io/v1", - "production": "https://api.shadeprotocol.io/v1", - } - return _urls[self.value] - - @property - def network_passphrase(self) -> str: - _passphrases: dict[str, str] = { - "sandbox": Network.TESTNET_NETWORK_PASSPHRASE, - "production": Network.PUBLIC_NETWORK_PASSPHRASE, - } - return _passphrases[self.value] - - @property - def horizon_url(self) -> str: - _horizons: dict[str, str] = { - "sandbox": "https://horizon-testnet.stellar.org", - "production": "https://horizon.stellar.org", - } - return _horizons[self.value] - - -config = Config() - - -def get_config( - api_key: Optional[str] = None, - environment: Optional[Environment | str] = None, - api_base: Optional[str] = None, - timeout: Optional[float] = None, - max_retries: Optional[int] = None, -) -> ResolvedConfig: - """Merge instance-level overrides with global defaults. - - Parameters - ---------- - api_key : str, optional - Instance API key. If absent/None, uses ``shade.api_key``. - environment : str | Environment, optional - Instance environment. If absent/None, uses ``shade.environment``. - api_base : str, optional - Instance API base URL override. If absent/None, uses ``shade.api_base``. - timeout : float, optional - Instance socket timeout. If absent/None, uses ``shade.timeout``. - max_retries : int, optional - Instance retry limit. If absent/None, uses ``shade.max_retries``. - - Returns - ------- - ResolvedConfig - A named tuple with resolved configuration values. - - Raises - ------ - AuthenticationError - If no valid API key is set globally or at instance level. - ValueError - If timeout or max_retries are invalid. - """ - resolved_api_key = api_key if api_key is not None else config.api_key - if not resolved_api_key: - raise AuthenticationError( - "No API key provided. Set your API key using 'shade.api_key = ' " - "or pass api_key to the client." - ) - - resolved_env = ( - config.parse_environment(environment) - if environment is not None - else config.environment - ) - - resolved_api_base = api_base if api_base is not None else config.api_base - resolved_timeout = timeout if timeout is not None else config.timeout - resolved_max_retries = ( - max_retries if max_retries is not None else config.max_retries - ) - - validate_client_settings(resolved_timeout, resolved_max_retries) - - base_url = (resolved_api_base or resolved_env.base_url).rstrip("/") - - return ResolvedConfig( - api_key=resolved_api_key, - environment=resolved_env, - api_base=resolved_api_base, - timeout=resolved_timeout, - max_retries=resolved_max_retries, - base_url=base_url, - ) \ No newline at end of file +from __future__ import annotations + +from enum import Enum + +import threading +from typing import NamedTuple, Optional + +from stellar_sdk import Network + +from .errors import AuthenticationError + + +class ResolvedConfig(NamedTuple): + api_key: str + environment: Environment + api_base: Optional[str] + timeout: float + max_retries: int + base_url: str + + +class Config: + """Thread-safe global SDK configuration.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._local = threading.local() + self._generation: int = 0 + self._global_api_key: Optional[str] = None + self._global_api_base: Optional[str] = None + self._global_environment: Environment = Environment.SANDBOX + self._global_timeout: float = DEFAULT_TIMEOUT + self._global_max_retries: int = DEFAULT_MAX_RETRIES + self._global_debug: bool = False + + def reset(self) -> None: + """Reset configuration to defaults (useful for test teardowns).""" + with self._lock: + self._generation += 1 + self._global_api_key = None + self._global_api_base = None + self._global_environment = Environment.SANDBOX + self._global_timeout = DEFAULT_TIMEOUT + self._global_max_retries = DEFAULT_MAX_RETRIES + self._global_debug = False + self._local.__dict__.clear() + + def _get_local(self, attr_name: str) -> tuple[bool, any]: + with self._lock: + current_gen = self._generation + if getattr(self._local, "generation", None) == current_gen: + if attr_name in self._local.__dict__: + return True, getattr(self._local, attr_name) + return False, None + + def _set_local(self, attr_name: str, value: any) -> None: + with self._lock: + current_gen = self._generation + if getattr(self._local, "generation", None) != current_gen: + self._local.__dict__.clear() + self._local.generation = current_gen + setattr(self._local, attr_name, value) + + @property + def api_key(self) -> Optional[str]: + has_local, val = self._get_local("api_key") + if has_local: + return val + with self._lock: + return self._global_api_key + + @api_key.setter + def api_key(self, value: Optional[str]) -> None: + self._set_local("api_key", value) + if threading.current_thread() is threading.main_thread(): + with self._lock: + self._global_api_key = value + + @property + def api_base(self) -> Optional[str]: + has_local, val = self._get_local("api_base") + if has_local: + return val + with self._lock: + return self._global_api_base + + @api_base.setter + def api_base(self, value: Optional[str]) -> None: + self._set_local("api_base", value) + if threading.current_thread() is threading.main_thread(): + with self._lock: + self._global_api_base = value + + @property + def environment(self) -> Environment: + has_local, val = self._get_local("environment") + if has_local: + return val + with self._lock: + return self._global_environment + + @environment.setter + def environment(self, value: str | Environment) -> None: + parsed = self.parse_environment(value) + self._set_local("environment", parsed) + if threading.current_thread() is threading.main_thread(): + with self._lock: + self._global_environment = parsed + + @property + def timeout(self) -> float: + has_local, val = self._get_local("timeout") + if has_local: + return val + with self._lock: + return self._global_timeout + + @timeout.setter + def timeout(self, value: float) -> None: + self._set_local("timeout", value) + if threading.current_thread() is threading.main_thread(): + with self._lock: + self._global_timeout = value + + @property + def max_retries(self) -> int: + has_local, val = self._get_local("max_retries") + if has_local: + return val + with self._lock: + return self._global_max_retries + + @max_retries.setter + def max_retries(self, value: int) -> None: + self._set_local("max_retries", value) + if threading.current_thread() is threading.main_thread(): + with self._lock: + self._global_max_retries = value + + @property + def debug(self) -> bool: + has_local, val = self._get_local("debug") + if has_local: + return val + with self._lock: + return self._global_debug + + @debug.setter + def debug(self, value: bool) -> None: + self._set_local("debug", value) + if threading.current_thread() is threading.main_thread(): + with self._lock: + self._global_debug = value + + + def parse_environment(self, value: str | Environment) -> Environment: + if isinstance(value, Environment): + return value + if isinstance(value, str): + try: + return Environment(value.lower()) + except ValueError: + pass + raise ValueError("Invalid environment. Valid options are: 'sandbox', 'production'") + + +# Default HTTP client settings. Override via ``shade.timeout`` / ``shade.max_retries`` +# or per-client constructor arguments on ``ShadeClient`` / ``Gateway``. +DEFAULT_TIMEOUT: float = 30.0 +DEFAULT_MAX_RETRIES: int = 3 +MAX_RETRIES_LIMIT: int = 10 + + +def validate_client_settings(timeout: float, max_retries: int) -> None: + """Raise ValueError for out-of-range timeout or retry settings.""" + if timeout <= 0: + raise ValueError(f"timeout must be greater than 0, got {timeout!r}") + if max_retries < 0 or max_retries > MAX_RETRIES_LIMIT: + raise ValueError( + f"max_retries must be between 0 and {MAX_RETRIES_LIMIT}, got {max_retries!r}" + ) + + +class Environment(str, Enum): + SANDBOX = "sandbox" + PRODUCTION = "production" + + @property + def base_url(self) -> str: + _urls: dict[str, str] = { + "sandbox": "https://testnet.api.shadeprotocol.io/v1", + "production": "https://api.shadeprotocol.io/v1", + } + return _urls[self.value] + + @property + def network_passphrase(self) -> str: + _passphrases: dict[str, str] = { + "sandbox": Network.TESTNET_NETWORK_PASSPHRASE, + "production": Network.PUBLIC_NETWORK_PASSPHRASE, + } + return _passphrases[self.value] + + @property + def horizon_url(self) -> str: + _horizons: dict[str, str] = { + "sandbox": "https://horizon-testnet.stellar.org", + "production": "https://horizon.stellar.org", + } + return _horizons[self.value] + + +config = Config() + + +def get_config( + api_key: Optional[str] = None, + environment: Optional[Environment | str] = None, + api_base: Optional[str] = None, + timeout: Optional[float] = None, + max_retries: Optional[int] = None, +) -> ResolvedConfig: + """Merge instance-level overrides with global defaults. + + Parameters + ---------- + api_key : str, optional + Instance API key. If absent/None, uses ``shade.api_key``. + environment : str | Environment, optional + Instance environment. If absent/None, uses ``shade.environment``. + api_base : str, optional + Instance API base URL override. If absent/None, uses ``shade.api_base``. + timeout : float, optional + Instance socket timeout. If absent/None, uses ``shade.timeout``. + max_retries : int, optional + Instance retry limit. If absent/None, uses ``shade.max_retries``. + + Returns + ------- + ResolvedConfig + A named tuple with resolved configuration values. + + Raises + ------ + AuthenticationError + If no valid API key is set globally or at instance level. + ValueError + If timeout or max_retries are invalid. + """ + resolved_api_key = api_key if api_key is not None else config.api_key + if not resolved_api_key: + raise AuthenticationError( + "No API key provided. Set your API key using 'shade.api_key = ' " + "or pass api_key to the client." + ) + + resolved_env = ( + config.parse_environment(environment) + if environment is not None + else config.environment + ) + + resolved_api_base = api_base if api_base is not None else config.api_base + resolved_timeout = timeout if timeout is not None else config.timeout + resolved_max_retries = ( + max_retries if max_retries is not None else config.max_retries + ) + + validate_client_settings(resolved_timeout, resolved_max_retries) + + base_url = (resolved_api_base or resolved_env.base_url).rstrip("/") + + return ResolvedConfig( + api_key=resolved_api_key, + environment=resolved_env, + api_base=resolved_api_base, + timeout=resolved_timeout, + max_retries=resolved_max_retries, + base_url=base_url, + ) diff --git a/tests/test_global_config.py b/tests/test_global_config.py index b1ed954..74f8e53 100644 --- a/tests/test_global_config.py +++ b/tests/test_global_config.py @@ -145,3 +145,26 @@ def worker(thread_id: int, key: str): assert results[4] == "sk_thread_4" # Main thread key should remain untouched assert shade.api_key == "sk_main_thread" + + def test_reset_invalidates_thread_local_overrides_across_reused_workers(self): + worker_results = {} + + def set_override(key: str): + shade.api_key = key + return shade.api_key + + def read_override(): + return shade.api_key + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + # Step 1: Set thread-local override in worker thread + future_set = executor.submit(set_override, "sk_worker_override") + assert future_set.result() == "sk_worker_override" + + # Step 2: Reset config from main thread (e.g. between tests) + config.reset() + + # Step 3: Worker thread re-used; check that stale thread-local override is invalidated + future_read = executor.submit(read_override) + assert future_read.result() is None + From f009b1d1f33c63909bdb16b7d4cf73ab8633378b Mon Sep 17 00:00:00 2001 From: DioChuks Date: Tue, 28 Jul 2026 09:09:03 +0100 Subject: [PATCH 09/12] fix: Maintainability & Code Quality --- src/shade/config.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/shade/config.py b/src/shade/config.py index 7c6496b..1f8e10b 100644 --- a/src/shade/config.py +++ b/src/shade/config.py @@ -3,7 +3,7 @@ from enum import Enum import threading -from typing import NamedTuple, Optional +from typing import Any, NamedTuple, Optional from stellar_sdk import Network @@ -20,7 +20,15 @@ class ResolvedConfig(NamedTuple): class Config: - """Thread-safe global SDK configuration.""" + """Thread-safe global SDK configuration. + + Note: + Configuration assignments made on the main thread (e.g. ``shade.api_key = "..."``) + update both process-wide defaults and thread-local state. Assignments made outside + the main thread update ONLY thread-local state for the calling thread and do not + alter process-wide defaults for other threads. Global configuration setup should be + performed from the main thread during application startup. + """ def __init__(self) -> None: self._lock = threading.Lock() @@ -45,7 +53,7 @@ def reset(self) -> None: self._global_debug = False self._local.__dict__.clear() - def _get_local(self, attr_name: str) -> tuple[bool, any]: + def _get_local(self, attr_name: str) -> tuple[bool, Any]: with self._lock: current_gen = self._generation if getattr(self._local, "generation", None) == current_gen: @@ -53,7 +61,7 @@ def _get_local(self, attr_name: str) -> tuple[bool, any]: return True, getattr(self._local, attr_name) return False, None - def _set_local(self, attr_name: str, value: any) -> None: + def _set_local(self, attr_name: str, value: Any) -> None: with self._lock: current_gen = self._generation if getattr(self._local, "generation", None) != current_gen: @@ -71,6 +79,7 @@ def api_key(self) -> Optional[str]: @api_key.setter def api_key(self, value: Optional[str]) -> None: + """Set the API key. Updates process-wide default if called from main thread.""" self._set_local("api_key", value) if threading.current_thread() is threading.main_thread(): with self._lock: @@ -86,6 +95,7 @@ def api_base(self) -> Optional[str]: @api_base.setter def api_base(self, value: Optional[str]) -> None: + """Set the API base URL override. Updates process-wide default if called from main thread.""" self._set_local("api_base", value) if threading.current_thread() is threading.main_thread(): with self._lock: @@ -101,6 +111,7 @@ def environment(self) -> Environment: @environment.setter def environment(self, value: str | Environment) -> None: + """Set the active environment. Updates process-wide default if called from main thread.""" parsed = self.parse_environment(value) self._set_local("environment", parsed) if threading.current_thread() is threading.main_thread(): @@ -117,6 +128,7 @@ def timeout(self) -> float: @timeout.setter def timeout(self, value: float) -> None: + """Set the socket timeout. Updates process-wide default if called from main thread.""" self._set_local("timeout", value) if threading.current_thread() is threading.main_thread(): with self._lock: @@ -132,11 +144,13 @@ def max_retries(self) -> int: @max_retries.setter def max_retries(self, value: int) -> None: + """Set the max retries limit. Updates process-wide default if called from main thread.""" self._set_local("max_retries", value) if threading.current_thread() is threading.main_thread(): with self._lock: self._global_max_retries = value + @property def debug(self) -> bool: has_local, val = self._get_local("debug") From a23f3464bdc1803c6550e78a991023670a6dd97f Mon Sep 17 00:00:00 2001 From: DioChuks Date: Tue, 28 Jul 2026 09:13:15 +0100 Subject: [PATCH 10/12] fix: Instance-level environment is never propagated --- src/shade/client.py | 9 +++++++-- src/shade/gateway.py | 4 ++++ src/shade/http.py | 21 ++++++++++++++++----- tests/test_global_config.py | 12 ++++++++++++ 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/shade/client.py b/src/shade/client.py index e5e818e..45c7544 100644 --- a/src/shade/client.py +++ b/src/shade/client.py @@ -3,7 +3,7 @@ import httpx from shade._debug import log_request, log_response -from shade.config import config, get_config +from shade.config import Environment, config, get_config class ShadeClient: @@ -13,11 +13,13 @@ def __init__( self, api_key: Optional[str] = None, base_url: Optional[str] = None, + environment: Optional[Environment | str] = None, debug: bool = False, http_client: Optional[httpx.Client] = None, ): self.api_key = api_key self._base_url = base_url.rstrip("/") if base_url else None + self.environment = environment self.debug = debug self._http = http_client or httpx.Client() self._owns_http_client = http_client is None @@ -26,7 +28,8 @@ def __init__( def base_url(self) -> str: if self._base_url: return self._base_url - return config.api_base or config.environment.base_url.rstrip("/") + env = config.parse_environment(self.environment) if self.environment is not None else config.environment + return config.api_base or env.base_url.rstrip("/") def close(self) -> None: if self._owns_http_client: @@ -52,8 +55,10 @@ def request( ) -> httpx.Response: cfg = get_config( api_key=self.api_key, + environment=self.environment, api_base=self._base_url, ) + normalized_path = path if path.startswith("/") else f"/{path}" url = f"{cfg.base_url}{normalized_path}" request_headers = {"Authorization": f"Bearer {cfg.api_key}", **(headers or {})} diff --git a/src/shade/gateway.py b/src/shade/gateway.py index 28de7c8..4d747bf 100644 --- a/src/shade/gateway.py +++ b/src/shade/gateway.py @@ -63,12 +63,14 @@ def __init__( self._http = SyncHTTPClient( base_url=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, ) @@ -76,10 +78,12 @@ def __init__( self._client = ClientShadeClient( api_key=self._api_key, base_url=self._api_base, + environment=self._environment, debug=debug, http_client=http_client, ) + @property def api_key(self) -> Optional[str]: return self._api_key if self._api_key is not None else _config.api_key diff --git a/src/shade/http.py b/src/shade/http.py index 950767a..50554bd 100644 --- a/src/shade/http.py +++ b/src/shade/http.py @@ -19,7 +19,7 @@ import urllib.request from typing import Any, Dict, Optional, Tuple -from .config import DEFAULT_MAX_RETRIES, validate_client_settings, get_config +from .config import DEFAULT_MAX_RETRIES, Environment, validate_client_settings, get_config from . import config as _config from .errors import ( AuthenticationError, @@ -403,6 +403,8 @@ class SyncHTTPClient: Base URL (no trailing slash). api_key : str, optional Bearer token sent as ``Authorization: Bearer ``. + environment : str | Environment, optional + Controls default API URL when base_url is omitted. max_retries : int, optional How many times to retry on 429 before raising ``RateLimitError``. Set to ``0`` to disable auto-retry. @@ -414,6 +416,7 @@ def __init__( self, base_url: Optional[str] = None, api_key: Optional[str] = None, + environment: Optional[Environment | str] = None, max_retries: Optional[int] = None, timeout: Optional[float] = None, ) -> None: @@ -423,6 +426,7 @@ def __init__( else: self._base_url = None self.api_key = api_key + self.environment = environment self._max_retries = max_retries self._timeout = timeout if timeout is not None or max_retries is not None: @@ -443,7 +447,8 @@ def timeout(self) -> float: def base_url(self) -> str: if self._base_url: return self._base_url - return _config.api_base or _config.environment.base_url.rstrip("/") + env = _config.parse_environment(self.environment) if self.environment is not None else _config.environment + return _config.api_base or env.base_url.rstrip("/") def _build_request( self, @@ -486,6 +491,7 @@ def request( """ cfg = get_config( api_key=self.api_key, + environment=self.environment, api_base=self._base_url, timeout=self._timeout, max_retries=self._max_retries, @@ -531,7 +537,6 @@ def _execute( return exc.code, exc.headers, body - # --------------------------------------------------------------------------- # Asynchronous client # --------------------------------------------------------------------------- @@ -549,6 +554,7 @@ def __init__( self, base_url: Optional[str] = None, api_key: Optional[str] = None, + environment: Optional[Environment | str] = None, max_retries: Optional[int] = None, timeout: Optional[float] = None, ) -> None: @@ -558,6 +564,7 @@ def __init__( else: self._base_url = None self.api_key = api_key + self.environment = environment self._max_retries = max_retries self._timeout = timeout if timeout is not None or max_retries is not None: @@ -578,7 +585,8 @@ def timeout(self) -> float: def base_url(self) -> str: if self._base_url: return self._base_url - return _config.api_base or _config.environment.base_url.rstrip("/") + env = _config.parse_environment(self.environment) if self.environment is not None else _config.environment + return _config.api_base or env.base_url.rstrip("/") async def request( self, @@ -615,6 +623,7 @@ async def request( cfg = get_config( api_key=self.api_key, + environment=self.environment, api_base=self._base_url, timeout=self._timeout, max_retries=self._max_retries, @@ -666,4 +675,6 @@ async def request( if wait is None: return json.loads(body) if body else {} await asyncio.sleep(wait) - attempt += 1 \ No newline at end of file + attempt += 1 + + \ No newline at end of file diff --git a/tests/test_global_config.py b/tests/test_global_config.py index 74f8e53..a9de06c 100644 --- a/tests/test_global_config.py +++ b/tests/test_global_config.py @@ -116,6 +116,18 @@ def test_instance_api_base_beats_global(self): req = mock_exec.call_args[0][0] assert req.full_url.startswith("https://override-base.example.com") + def test_gateway_environment_override_propagates_to_subclients(self): + shade.environment = "sandbox" + gateway = Gateway(api_key="sk_test", environment="production") + + with patch.object(gateway._http, "_execute") as mock_exec: + mock_exec.return_value = (200, {}, b'{"ok": true}') + gateway.process_payment(10.0, "USD") + + req = mock_exec.call_args[0][0] + assert req.full_url.startswith(Environment.PRODUCTION.base_url) + + class TestThreadSafety: From 7fe9bb51286c5de78eadd2fa09e3a6ec77d3ee05 Mon Sep 17 00:00:00 2001 From: DioChuks Date: Tue, 28 Jul 2026 09:15:35 +0100 Subject: [PATCH 11/12] fix: api_key setter does not reach the underlying clients. --- src/shade/gateway.py | 10 +++++++++- tests/test_global_config.py | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/shade/gateway.py b/src/shade/gateway.py index 4d747bf..d6fecf8 100644 --- a/src/shade/gateway.py +++ b/src/shade/gateway.py @@ -91,6 +91,9 @@ def api_key(self) -> Optional[str]: @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: @@ -100,7 +103,12 @@ def environment(self) -> Environment: @environment.setter def environment(self, value: str | Environment) -> None: - self._environment = _config.parse_environment(value) + parsed = _config.parse_environment(value) + self._environment = parsed + self._http.environment = parsed + self._async_http.environment = parsed + self._client.environment = parsed + @property def _base_url(self) -> str: diff --git a/tests/test_global_config.py b/tests/test_global_config.py index a9de06c..b5fd89d 100644 --- a/tests/test_global_config.py +++ b/tests/test_global_config.py @@ -127,6 +127,21 @@ def test_gateway_environment_override_propagates_to_subclients(self): req = mock_exec.call_args[0][0] assert req.full_url.startswith(Environment.PRODUCTION.base_url) + def test_gateway_setter_updates_propagate_to_subclients(self): + gateway = Gateway(api_key="sk_initial", environment="sandbox") + + gateway.api_key = "sk_updated_setter" + gateway.environment = "production" + + with patch.object(gateway._http, "_execute") as mock_exec: + mock_exec.return_value = (200, {}, b'{"ok": true}') + gateway.process_payment(10.0, "USD") + + req = mock_exec.call_args[0][0] + assert req.headers["Authorization"] == "Bearer sk_updated_setter" + assert req.full_url.startswith(Environment.PRODUCTION.base_url) + + From 73627c287e8b67d5ff5e352139eb4dbc4bc040b0 Mon Sep 17 00:00:00 2001 From: DioChuks Date: Tue, 28 Jul 2026 09:17:44 +0100 Subject: [PATCH 12/12] fix: from . import config as _config resolves to the singleton only by import-order luck --- src/shade/http.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/shade/http.py b/src/shade/http.py index 50554bd..4775db4 100644 --- a/src/shade/http.py +++ b/src/shade/http.py @@ -19,8 +19,10 @@ import urllib.request from typing import Any, Dict, Optional, Tuple -from .config import DEFAULT_MAX_RETRIES, Environment, validate_client_settings, get_config -from . import config as _config + +from .config import DEFAULT_MAX_RETRIES, Environment, config as _config, get_config, validate_client_settings + + from .errors import ( AuthenticationError, HTTPError,