Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/shade/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -44,6 +44,8 @@
"Transfer",
"TransferStatus",
"config",
"get_config",
"api_key",
"api_base",
"environment",
"max_retries",
Expand All @@ -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
Expand Down Expand Up @@ -95,3 +107,4 @@ def environment(self, value: str | Environment) -> None:


sys.modules[__name__].__class__ = _ShadeModule

31 changes: 22 additions & 9 deletions src/shade/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,34 @@
import httpx

from shade._debug import log_request, log_response
from shade.config import config
from shade.config import Environment, config, get_config


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

def __init__(
self,
api_key: str,
base_url: str = "https://api.shadeprotocol.io",
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("/")
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

@property
def base_url(self) -> str:
if self._base_url:
return self._base_url
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:
self._http.close()
Expand All @@ -35,9 +44,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,
Expand All @@ -47,9 +53,15 @@ def request(
json: Any = None,
content: Optional[bytes] = None,
) -> 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"{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)
Expand All @@ -66,3 +78,4 @@ def request(
log_response(response.status_code, response.headers, response.text)

return response

235 changes: 224 additions & 11 deletions src/shade/config.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,171 @@
from __future__ import annotations

from enum import Enum
from typing import Optional

import threading
from typing import Any, 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:
"""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()
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)

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_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:
"""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:
self._global_api_key = value

@property
def api_base(self) -> Optional[str]:
return self._api_base
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._api_base = value
"""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:
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:
"""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():
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:
"""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:
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:
"""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")
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):
Expand All @@ -40,6 +184,7 @@ def parse_environment(self, value: str | Environment) -> Environment:
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:
Expand Down Expand Up @@ -78,4 +223,72 @@ def horizon_url(self) -> str:
}
return _horizons[self.value]

config = Config()

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 = <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,
)
Loading
Loading