From 3a92d729b032bfabcc8977fed0e982d0a805c646 Mon Sep 17 00:00:00 2001 From: dave Date: Tue, 28 Jul 2026 14:34:41 +0100 Subject: [PATCH 1/2] feat(client): implement ShadeClient for per-instance configuration Implements issue #2. ShadeClient binds credentials and connection settings to a single object so a multi-tenant application can hold one client per merchant instead of mutating the global shade module config. - ShadeClient takes api_key, environment, api_base, timeout and max_retries, each falling back to the matching global setting when omitted. The fallback resolves once at construction, so later global changes never mutate an existing client. - Adds a global shade.api_key setting backing that fallback. - ShadeClient.from_env() builds a client from SHADE_API_KEY and SHADE_ENVIRONMENT, with keyword arguments overriding either. - A missing api_key with no global key set now raises AuthenticationError instead of ValueError, naming all three ways to supply one. - BaseResource gives resources the optional client= kwarg, resolving to the shared global client when omitted. The client is resolved per access, so a resource built before shade.api_key was assigned still picks it up. ShadeClient was previously an alias for Gateway, with a separate unrelated ShadeClient in client.py. Gateway is now a ShadeClient subclass carrying the payment methods, and the httpx-backed transport that occupied client.py moves to http.py as HTTPXTransport, alongside the other transports. The two tests asserting the old alias now assert the subclass relationship. Also fixes http.py resolving `from . import config` to the config module rather than the Config instance. That only worked because of the order of imports in __init__.py, and broke as soon as client.py imported http.py earlier in the chain. --- src/shade/__init__.py | 20 +- src/shade/client.py | 229 +++++++++++++++++--- src/shade/config.py | 1 + src/shade/gateway.py | 118 +---------- src/shade/http.py | 77 ++++++- src/shade/resources/__init__.py | 6 + src/shade/resources/base.py | 62 ++++++ tests/test_api_base.py | 8 +- tests/test_client_settings.py | 11 +- tests/test_shade_client.py | 360 ++++++++++++++++++++++++++++++++ 10 files changed, 734 insertions(+), 158 deletions(-) create mode 100644 src/shade/resources/__init__.py create mode 100644 src/shade/resources/base.py create mode 100644 tests/test_shade_client.py diff --git a/src/shade/__init__.py b/src/shade/__init__.py index 6c5a209..7dee489 100644 --- a/src/shade/__init__.py +++ b/src/shade/__init__.py @@ -2,10 +2,11 @@ from types import ModuleType from typing import Optional -from .client import ShadeClient +from .client import ShadeClient, default_client, reset_default_client from .config import config, Environment from .gateway import Gateway from .http import AsyncHTTPClient, SyncHTTPClient +from .resources import BaseResource from .errors import ( AuthenticationError, InvalidRequestError, @@ -20,14 +21,12 @@ __version__ = "0.1.0" -# ShadeClient is an alias for Gateway. -ShadeClient = Gateway - __all__ = [ "AssetBalance", "AsyncHTTPClient", "AuthenticationError", "Balance", + "BaseResource", "Environment", "Gateway", "HTTPError", @@ -45,14 +44,27 @@ "TransferStatus", "config", "api_base", + "api_key", + "default_client", "environment", "max_retries", + "reset_default_client", "timeout", ] 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 diff --git a/src/shade/client.py b/src/shade/client.py index 9557a5c..d2a7da0 100644 --- a/src/shade/client.py +++ b/src/shade/client.py @@ -1,30 +1,160 @@ -from typing import Any, Mapping, Optional +""" +Per-instance SDK configuration. + +``ShadeClient`` binds a set of credentials and connection settings to a single +object, so an application acting on behalf of several merchants can hold one +client per tenant instead of mutating the global ``shade`` module config. +Anything left unset falls back to the global config at construction time. +""" +from __future__ import annotations + +import os +from typing import Any, Dict, Optional import httpx -from shade._debug import log_request, log_response -from shade.config import config +from .config import Environment, validate_client_settings +from .config import config as _config +from .errors import AuthenticationError +from .http import AsyncHTTPClient, HTTPXTransport, SyncHTTPClient + +API_KEY_ENV_VAR = "SHADE_API_KEY" +ENVIRONMENT_ENV_VAR = "SHADE_ENVIRONMENT" class ShadeClient: - """HTTP client for the Shade Payment Gateway API.""" + """An isolated Shade API client carrying its own credentials and settings. + + Two clients built with different API keys never share state, so a + multi-tenant application can keep one per merchant:: + + acme = ShadeClient(api_key="sk_live_acme") + globex = ShadeClient(api_key="sk_live_globex") + + Every parameter falls back to the matching global setting + (``shade.api_key``, ``shade.environment``, …) when omitted, and the fallback + is resolved once at construction — later changes to the global config do not + retroactively alter an existing client. + + Parameters + ---------- + api_key : str, optional + Your Shade API key. Defaults to the module-level ``shade.api_key``. + environment : str | Environment, optional + Controls the Stellar network passphrase and the default API URL. + Defaults to the module-level ``shade.environment``. + api_base : str, optional + Override the API host for this client (local dev, staging, or a + self-hosted backend). Takes precedence over the module-level + ``shade.api_base`` and the URL derived from ``environment``. Trailing + slashes are trimmed. + timeout : float, optional + Per-request socket timeout in seconds. Defaults to ``shade.timeout``. + max_retries : int, optional + Automatic retries on HTTP 429 and transient failures. Defaults to + ``shade.max_retries``. Set to ``0`` to disable auto-retry. + base_url : str + Deprecated. Prefer ``api_base``. + debug : bool + Log requests and responses for this client. The global + ``shade.config.debug`` enables logging regardless of this flag. + http_client : httpx.Client, optional + Reuse an existing httpx client instead of creating one. The caller + keeps ownership: :meth:`close` will not close a client it was given. + + Raises + ------ + AuthenticationError + If no API key is given and no global ``shade.api_key`` is set. + ValueError + If ``timeout`` or ``max_retries`` is out of range, or ``environment`` + is not a recognised value. + """ def __init__( self, - api_key: str, - base_url: str = "https://api.shadeprotocol.io", + api_key: Optional[str] = None, + environment: Optional[Environment | str] = None, + api_base: Optional[str] = None, + timeout: Optional[float] = None, + max_retries: Optional[int] = None, + base_url: str = "", debug: bool = False, http_client: Optional[httpx.Client] = None, - ): - self.api_key = api_key - self.base_url = base_url.rstrip("/") + ) -> None: + resolved_api_key = api_key or _config.api_key + if not resolved_api_key: + raise AuthenticationError( + "No API key provided. Pass api_key= to ShadeClient, set " + f"shade.api_key, or set the {API_KEY_ENV_VAR} environment variable." + ) + self.api_key = resolved_api_key + + if environment is not None: + self.environment = _config.parse_environment(environment) + else: + self.environment = _config.environment + + 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) + + # 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.debug = debug - self._http = http_client or httpx.Client() - self._owns_http_client = http_client is None + + self._http = SyncHTTPClient( + base_url=self._base_url, + api_key=self.api_key, + max_retries=self.max_retries, + timeout=self.timeout, + ) + self._async_http = AsyncHTTPClient( + base_url=self._base_url, + api_key=self.api_key, + max_retries=self.max_retries, + timeout=self.timeout, + ) + self._client = HTTPXTransport( + api_key=self.api_key, + base_url=self._base_url, + debug=debug, + http_client=http_client, + ) + + @classmethod + def from_env(cls, **overrides: Any) -> "ShadeClient": + """Build a client from ``SHADE_API_KEY`` and ``SHADE_ENVIRONMENT``. + + Either variable may be absent, in which case the usual global-config + fallback applies — so a missing ``SHADE_API_KEY`` with no + ``shade.api_key`` set raises :class:`~shade.errors.AuthenticationError`. + + Any keyword argument overrides the corresponding environment variable, + letting callers take the key from the environment while setting the rest + explicitly:: + + client = ShadeClient.from_env(timeout=5.0) + """ + env_kwargs: Dict[str, Any] = {} + api_key = os.environ.get(API_KEY_ENV_VAR) + if api_key: + env_kwargs["api_key"] = api_key + environment = os.environ.get(ENVIRONMENT_ENV_VAR) + if environment: + env_kwargs["environment"] = environment + env_kwargs.update(overrides) + return cls(**env_kwargs) + + @property + def api_base(self) -> str: + """The resolved API base URL this client sends requests to.""" + return self._base_url def close(self) -> None: - if self._owns_http_client: - self._http.close() + self._client.close() def __enter__(self) -> "ShadeClient": return self @@ -32,37 +162,70 @@ def __enter__(self) -> "ShadeClient": def __exit__(self, *args: Any) -> None: self.close() - 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, path: str, *, - headers: Optional[Mapping[str, str]] = None, + headers: Optional[Dict[str, str]] = None, json: Any = None, content: Optional[bytes] = None, ) -> httpx.Response: - normalized_path = path if path.startswith("/") else f"/{path}" - url = f"{self.base_url}{normalized_path}" - request_headers = {**self._default_headers(), **(headers or {})} - - if self._should_debug(): - log_request(method, url, request_headers, content if content is not None else json) - - response = self._http.request( + """Send a request and return the raw ``httpx.Response``.""" + return self._client.request( method, - url, - headers=request_headers, + path, + headers=headers, json=json, content=content, ) - if self._should_debug(): - log_response(response.status_code, response.headers, response.text) + def __repr__(self) -> str: + return ( + f"<{type(self).__name__} api_key={_mask_api_key(self.api_key)!r} " + f"environment={self.environment.value!r} api_base={self._base_url!r}>" + ) + + +def _mask_api_key(api_key: str) -> str: + """Show only the last four characters of a key, for use in reprs.""" + if len(api_key) <= 4: + return "****" + return "*" * (len(api_key) - 4) + api_key[-4:] + + +_default_client: Optional[ShadeClient] = None +_default_client_settings: Optional[tuple] = None + + +def default_client() -> ShadeClient: + """Return the shared client built from the global ``shade`` config. + + Resources fall back to this when constructed without an explicit + ``client=``. The instance is cached, but rebuilt whenever a global setting + changes, so assigning ``shade.api_key`` after the first call still takes + effect. + + Raises: + AuthenticationError: If no global ``shade.api_key`` has been set. + """ + global _default_client, _default_client_settings + + settings = ( + _config.api_key, + _config.environment, + _config.api_base, + _config.timeout, + _config.max_retries, + ) + if _default_client is None or _default_client_settings != settings: + _default_client = ShadeClient() + _default_client_settings = settings + return _default_client + - return response +def reset_default_client() -> None: + """Drop the cached global client. Primarily useful in tests.""" + global _default_client, _default_client_settings + _default_client = None + _default_client_settings = None diff --git a/src/shade/config.py b/src/shade/config.py index a96b469..8908f97 100644 --- a/src/shade/config.py +++ b/src/shade/config.py @@ -10,6 +10,7 @@ class Config: def __init__(self): self.debug: bool = False + self.api_key: Optional[str] = None self._api_base: Optional[str] = None self.timeout: float = DEFAULT_TIMEOUT self.max_retries: int = DEFAULT_MAX_RETRIES diff --git a/src/shade/gateway.py b/src/shade/gateway.py index 87dacd4..e847eef 100644 --- a/src/shade/gateway.py +++ b/src/shade/gateway.py @@ -1,121 +1,19 @@ from __future__ import annotations -import httpx -from typing import Any, Dict, Optional +from typing import Any, Dict -from . import config as _config -from .client import ShadeClient as ClientShadeClient -from .config import Environment, validate_client_settings -from .http import AsyncHTTPClient, SyncHTTPClient, DEFAULT_MAX_RETRIES +from .client import ShadeClient -class Gateway: +class Gateway(ShadeClient): """ Main entry point for the Shade Payment Gateway. - Parameters - ---------- - api_key : str - Your 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``). - api_base : str, optional - Override the API host for this client (useful for local dev or staging). - Takes precedence over the module-level ``shade.api_base`` and the - URL derived from ``environment``. Trailing slashes are trimmed. - Intended for development and testing only. - base_url : str - 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`` - to disable auto-retry. - timeout : float, optional - Per-request socket timeout in seconds. Defaults to the module-level - ``shade.timeout`` (30.0). + A :class:`~shade.client.ShadeClient` with the payment operations attached. + See :class:`~shade.client.ShadeClient` for the constructor parameters and + how each one falls back to the global ``shade`` config. """ - def __init__( - self, - api_key: str = "", - environment: Optional[Environment | str] = None, - api_base: Optional[str] = None, - base_url: str = "", - max_retries: Optional[int] = None, - timeout: Optional[float] = None, - debug: bool = False, - http_client: Optional[httpx.Client] = None, - ) -> None: - 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) - - # 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, - ) - self._async_http = AsyncHTTPClient( - base_url=self._base_url, - api_key=api_key, - max_retries=resolved_max_retries, - timeout=resolved_timeout, - ) - - self._client = ClientShadeClient( - api_key=api_key, - base_url=self._base_url, - debug=debug, - http_client=http_client, - ) - - # ------------------------------------------------------------------ - # Sync API - # ------------------------------------------------------------------ - - def close(self) -> None: - self._client.close() - - def __enter__(self) -> "Gateway": - return self - - def __exit__(self, *args: Any) -> None: - self.close() - - def request( - self, - method: str, - path: str, - *, - headers: Optional[Dict[str, str]] = None, - json: Any = None, - content: Optional[bytes] = None, - ) -> httpx.Response: - return self._client.request( - method, - path, - headers=headers, - json=json, - content=content, - ) - def process_payment(self, amount: float, currency: str) -> Dict[str, Any]: """ Process a payment (sync). @@ -138,10 +36,6 @@ def process_payment(self, amount: float, currency: str) -> Dict[str, Any]: {"amount": amount, "currency": currency}, ) - # ------------------------------------------------------------------ - # Async API - # ------------------------------------------------------------------ - async def process_payment_async( self, amount: float, currency: str ) -> Dict[str, Any]: diff --git a/src/shade/http.py b/src/shade/http.py index fbd87a2..33ddc64 100644 --- a/src/shade/http.py +++ b/src/shade/http.py @@ -17,10 +17,11 @@ import urllib.error import urllib.parse import urllib.request -from typing import Any, Dict, Optional, Tuple +from typing import Any, Dict, Mapping, Optional, Tuple +from ._debug import log_request, log_response from .config import DEFAULT_MAX_RETRIES, validate_client_settings -from . import config as _config +from .config import config as _config from .errors import ( AuthenticationError, HTTPError, @@ -389,6 +390,78 @@ def _parse_response(response: "httpx.Response") -> Dict[str, Any]: ) +# --------------------------------------------------------------------------- +# httpx-backed transport +# --------------------------------------------------------------------------- + +class HTTPXTransport: + """httpx-backed transport returning raw responses, with debug logging. + + Used by :class:`~shade.client.ShadeClient` for calls that need the whole + response (headers, streaming, non-JSON bodies) rather than a decoded body. + Logging is enabled per-instance via ``debug`` or globally via + ``shade.config.debug``, and the ``Authorization`` header is masked either way. + """ + + def __init__( + self, + api_key: str, + base_url: str = "https://api.shadeprotocol.io", + debug: bool = False, + http_client: Optional["httpx.Client"] = None, + ) -> None: + self.api_key = api_key + self.base_url = base_url.rstrip("/") + self.debug = debug + self._http = http_client or httpx.Client() + self._owns_http_client = http_client is None + + def close(self) -> None: + if self._owns_http_client: + self._http.close() + + def __enter__(self) -> "HTTPXTransport": + return self + + def __exit__(self, *args: Any) -> None: + self.close() + + 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, + path: str, + *, + headers: Optional[Mapping[str, str]] = None, + json: Any = None, + content: Optional[bytes] = None, + ) -> "httpx.Response": + normalized_path = path if path.startswith("/") else f"/{path}" + url = f"{self.base_url}{normalized_path}" + request_headers = {**self._default_headers(), **(headers or {})} + + if self._should_debug(): + log_request(method, url, request_headers, content if content is not None else json) + + response = self._http.request( + method, + url, + headers=request_headers, + json=json, + content=content, + ) + + if self._should_debug(): + log_response(response.status_code, response.headers, response.text) + + return response + + # --------------------------------------------------------------------------- # Synchronous client # --------------------------------------------------------------------------- diff --git a/src/shade/resources/__init__.py b/src/shade/resources/__init__.py new file mode 100644 index 0000000..087c888 --- /dev/null +++ b/src/shade/resources/__init__.py @@ -0,0 +1,6 @@ +""" +Shade API resource classes. +""" +from .base import BaseResource + +__all__ = ["BaseResource"] diff --git a/src/shade/resources/base.py b/src/shade/resources/base.py new file mode 100644 index 0000000..bca2548 --- /dev/null +++ b/src/shade/resources/base.py @@ -0,0 +1,62 @@ +""" +Base class shared by every Shade API resource. +""" +from __future__ import annotations + +from typing import Any, Dict, Optional + +from ..client import ShadeClient, default_client + + +class BaseResource: + """Base class for API resources. + + Every resource takes an optional ``client=``. When one is given the resource + uses that client's credentials and settings; when it is omitted the resource + falls back to the shared client built from the global ``shade`` config:: + + shade.api_key = "sk_live_default" + Payments().retrieve("pay_1") # global credentials + Payments(client=acme_client).retrieve("pay_1") # acme's credentials + + The client is resolved on each access rather than captured at construction, + so a resource built before ``shade.api_key`` was assigned still picks it up. + """ + + def __init__(self, client: Optional[ShadeClient] = None) -> None: + self._explicit_client = client + + @property + def client(self) -> ShadeClient: + """The client backing this resource. + + Raises: + AuthenticationError: If no client was supplied and no global + ``shade.api_key`` has been set. + """ + if self._explicit_client is not None: + return self._explicit_client + return default_client() + + def _request( + self, + method: str, + path: str, + payload: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """Send a request through this resource's client and return the body.""" + return self.client._http.request(method, path, payload) + + async def _request_async( + self, + method: str, + path: str, + payload: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """Async counterpart of :meth:`_request`.""" + return await self.client._async_http.request(method, path, payload) + + def __repr__(self) -> str: + if self._explicit_client is None: + return f"<{type(self).__name__} client=global>" + return f"<{type(self).__name__} client={self._explicit_client!r}>" diff --git a/tests/test_api_base.py b/tests/test_api_base.py index e2be65b..1560a97 100644 --- a/tests/test_api_base.py +++ b/tests/test_api_base.py @@ -201,13 +201,13 @@ def test_horizon_urls(self): # --------------------------------------------------------------------------- -# ShadeClient alias +# ShadeClient # --------------------------------------------------------------------------- -class TestShadeClientAlias: - def test_shade_client_is_gateway(self): +class TestShadeClient: + def test_gateway_is_a_shade_client(self): from shade import ShadeClient - assert ShadeClient is Gateway + assert issubclass(Gateway, ShadeClient) def test_shade_client_accepts_api_base(self): from shade import ShadeClient diff --git a/tests/test_client_settings.py b/tests/test_client_settings.py index cff56e7..f63be78 100644 --- a/tests/test_client_settings.py +++ b/tests/test_client_settings.py @@ -179,6 +179,11 @@ def fake_execute(req): mock_sleep.assert_not_called() -class TestShadeClientAlias: - def test_shade_client_is_gateway(self): - assert ShadeClient is Gateway +class TestShadeClientRelationship: + def test_gateway_is_a_shade_client(self): + assert issubclass(Gateway, ShadeClient) + + def test_gateway_inherits_client_settings(self): + gateway = Gateway(api_key="test-key", timeout=7.0, max_retries=1) + assert gateway._http.timeout == 7.0 + assert gateway._http.max_retries == 1 diff --git a/tests/test_shade_client.py b/tests/test_shade_client.py new file mode 100644 index 0000000..0c8fc44 --- /dev/null +++ b/tests/test_shade_client.py @@ -0,0 +1,360 @@ +""" +Tests for per-instance ShadeClient configuration (issue #2). + +Acceptance criteria covered: +* ShadeClient(api_key=...) creates an isolated client. +* Resource calls use their client's credentials, not the global config. +* Two clients with different keys coexist without interfering. +* ShadeClient.from_env() reads SHADE_API_KEY from the environment. +* A missing api_key with no global key set raises AuthenticationError. +""" +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +import shade +from shade import BaseResource, Gateway, ShadeClient +from shade.client import ( + API_KEY_ENV_VAR, + ENVIRONMENT_ENV_VAR, + default_client, + reset_default_client, +) +from shade.config import Environment +from shade.config import config as _config +from shade.errors import AuthenticationError + + +@pytest.fixture(autouse=True) +def _reset_global_config(monkeypatch): + """Isolate every test from global config and environment leakage.""" + monkeypatch.delenv(API_KEY_ENV_VAR, raising=False) + monkeypatch.delenv(ENVIRONMENT_ENV_VAR, raising=False) + original = ( + _config.api_key, + _config.api_base, + _config.environment, + _config.timeout, + _config.max_retries, + ) + _config.api_key = None + reset_default_client() + yield + ( + _config.api_key, + _config.api_base, + _config.environment, + _config.timeout, + _config.max_retries, + ) = original + reset_default_client() + + +class Payments(BaseResource): + """Minimal resource standing in for the real ones, which do not exist yet.""" + + def retrieve(self, payment_id: str) -> dict: + return self._request("GET", f"/payments/{payment_id}") + + +def _capture_requests(client: ShadeClient): + """Patch a client's sync transport, returning the list of sent requests.""" + sent = [] + + def fake_execute(req): + sent.append(req) + return 200, {}, b'{"id": "pay_1"}' + + return patch.object(client._http, "_execute", side_effect=fake_execute), sent + + +# --------------------------------------------------------------------------- +# Isolated instances +# --------------------------------------------------------------------------- + +class TestIsolatedClient: + def test_api_key_binds_to_the_instance(self): + client = ShadeClient(api_key="sk_test_xxx") + + assert client.api_key == "sk_test_xxx" + assert client._http.api_key == "sk_test_xxx" + assert client._async_http.api_key == "sk_test_xxx" + + def test_accepts_the_same_parameters_as_global_config(self): + client = ShadeClient( + api_key="sk_test_xxx", + environment="production", + api_base="http://localhost:8000/", + timeout=5.0, + max_retries=1, + ) + + assert client.environment is Environment.PRODUCTION + assert client.api_base == "http://localhost:8000" + assert client.timeout == 5.0 + assert client.max_retries == 1 + + def test_falls_back_to_global_settings(self): + _config.api_key = "sk_live_global" + _config.timeout = 12.0 + _config.max_retries = 2 + + client = ShadeClient() + + assert client.api_key == "sk_live_global" + assert client.timeout == 12.0 + assert client.max_retries == 2 + + def test_instance_settings_beat_global_ones(self): + _config.api_key = "sk_live_global" + _config.timeout = 12.0 + + client = ShadeClient(api_key="sk_test_instance", timeout=3.0) + + assert client.api_key == "sk_test_instance" + assert client.timeout == 3.0 + assert _config.api_key == "sk_live_global" + + def test_later_global_changes_do_not_mutate_an_existing_client(self): + _config.api_key = "sk_live_first" + client = ShadeClient() + + _config.api_key = "sk_live_second" + _config.timeout = 99.0 + + assert client.api_key == "sk_live_first" + assert client.timeout != 99.0 + + def test_repr_masks_the_api_key(self): + client = ShadeClient(api_key="sk_test_secret_1234") + + assert "sk_test_secret_1234" not in repr(client) + assert "1234" in repr(client) + + +# --------------------------------------------------------------------------- +# Coexisting clients +# --------------------------------------------------------------------------- + +class TestCoexistingClients: + def test_two_clients_keep_separate_credentials(self): + acme = ShadeClient(api_key="sk_live_acme") + globex = ShadeClient(api_key="sk_live_globex") + + assert acme.api_key == "sk_live_acme" + assert globex.api_key == "sk_live_globex" + assert acme._http is not globex._http + + def test_two_clients_keep_separate_settings(self): + sandbox = ShadeClient( + api_key="sk_test_a", environment="sandbox", timeout=5.0, max_retries=0 + ) + production = ShadeClient( + api_key="sk_live_b", environment="production", timeout=20.0, max_retries=5 + ) + + assert sandbox.environment is Environment.SANDBOX + assert production.environment is Environment.PRODUCTION + assert sandbox.api_base != production.api_base + assert (sandbox.timeout, sandbox.max_retries) == (5.0, 0) + assert (production.timeout, production.max_retries) == (20.0, 5) + + def test_requests_carry_each_clients_own_key(self): + acme = ShadeClient(api_key="sk_live_acme") + globex = ShadeClient(api_key="sk_live_globex") + + acme_patch, acme_sent = _capture_requests(acme) + globex_patch, globex_sent = _capture_requests(globex) + + with acme_patch: + acme._http.request("GET", "/payments/pay_1") + with globex_patch: + globex._http.request("GET", "/payments/pay_1") + + assert acme_sent[0].get_header("Authorization") == "Bearer sk_live_acme" + assert globex_sent[0].get_header("Authorization") == "Bearer sk_live_globex" + + +# --------------------------------------------------------------------------- +# Resources +# --------------------------------------------------------------------------- + +class TestResourceClientBinding: + def test_resource_uses_its_clients_credentials_not_the_global_ones(self): + _config.api_key = "sk_live_global" + acme = ShadeClient(api_key="sk_live_acme") + payments = Payments(client=acme) + + request_patch, sent = _capture_requests(acme) + with request_patch: + result = payments.retrieve("pay_1") + + assert result == {"id": "pay_1"} + assert sent[0].get_header("Authorization") == "Bearer sk_live_acme" + assert payments.client is acme + + def test_resource_falls_back_to_global_config_when_client_omitted(self): + _config.api_key = "sk_live_global" + payments = Payments() + + assert payments.client.api_key == "sk_live_global" + + def test_two_resources_on_different_clients_do_not_interfere(self): + acme = ShadeClient(api_key="sk_live_acme") + globex = ShadeClient(api_key="sk_live_globex") + + assert Payments(client=acme).client.api_key == "sk_live_acme" + assert Payments(client=globex).client.api_key == "sk_live_globex" + + def test_resource_picks_up_a_global_key_set_after_construction(self): + payments = Payments() + _config.api_key = "sk_live_late" + + assert payments.client.api_key == "sk_live_late" + + def test_resource_without_client_or_global_key_raises(self): + payments = Payments() + + with pytest.raises(AuthenticationError): + payments.client + + def test_repr_distinguishes_global_from_explicit_clients(self): + assert repr(Payments()) == "" + assert "Payments client= Date: Wed, 29 Jul 2026 10:18:23 +0100 Subject: [PATCH 2/2] fix(client): guard the default client cache and wire timeout to the transport Two findings from review on the merge: default_client() had an unsynchronized check-then-set, so concurrent first calls could each build a client and leak the loser's httpx connection pool. Guard it with a lock, and close the client that reset_default_client() drops. HTTPXTransport created its httpx client without a timeout, so ShadeClient's timeout was silently ignored for requests going through it -- httpx applied its own 5s default instead. Resolve the timeout with the rest of the config and pass it per request. Wiring max_retries into the transport needs a retry loop of its own and is left alone, as is debug reaching SyncHTTPClient/AsyncHTTPClient; both gaps predate this branch. --- src/shade/client.py | 16 ++++++++++++---- src/shade/http.py | 4 ++++ tests/test_client_settings.py | 17 +++++++++++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/shade/client.py b/src/shade/client.py index 8beec00..e626ec1 100644 --- a/src/shade/client.py +++ b/src/shade/client.py @@ -10,6 +10,7 @@ from __future__ import annotations import os +import threading from typing import Any, Dict, Optional import httpx @@ -114,6 +115,7 @@ def __init__( api_key=self._api_key, base_url=self._api_base, environment=self._environment, + timeout=self._timeout, debug=debug, http_client=http_client, ) @@ -233,6 +235,7 @@ def _mask_api_key(api_key: Optional[str]) -> str: _default_client: Optional[ShadeClient] = None +_default_client_lock = threading.Lock() def default_client() -> ShadeClient: @@ -245,12 +248,17 @@ def default_client() -> ShadeClient: """ global _default_client - if _default_client is None: - _default_client = ShadeClient() - return _default_client + with _default_client_lock: + if _default_client is None: + _default_client = ShadeClient() + return _default_client def reset_default_client() -> None: """Drop the cached global client. Primarily useful in tests.""" global _default_client - _default_client = None + + with _default_client_lock: + client, _default_client = _default_client, None + if client is not None: + client.close() diff --git a/src/shade/http.py b/src/shade/http.py index 4c381ab..d27bb22 100644 --- a/src/shade/http.py +++ b/src/shade/http.py @@ -412,12 +412,14 @@ def __init__( api_key: Optional[str] = None, base_url: Optional[str] = None, environment: Optional[Environment | str] = None, + timeout: Optional[float] = None, debug: bool = False, http_client: Optional["httpx.Client"] = None, ) -> None: self.api_key = api_key self._base_url = base_url.rstrip("/") if base_url else None self.environment = environment + self._timeout = timeout self.debug = debug self._http = http_client or httpx.Client() self._owns_http_client = http_client is None @@ -442,6 +444,7 @@ def request( api_key=self.api_key, environment=self.environment, api_base=self._base_url, + timeout=self._timeout, ) normalized_path = path if path.startswith("/") else f"/{path}" @@ -457,6 +460,7 @@ def request( headers=request_headers, json=json, content=content, + timeout=cfg.timeout, ) if self._should_debug(): diff --git a/tests/test_client_settings.py b/tests/test_client_settings.py index c81e93e..77c002b 100644 --- a/tests/test_client_settings.py +++ b/tests/test_client_settings.py @@ -106,6 +106,23 @@ def test_per_client_beats_module_level(self): assert client._http.timeout == 5.0 assert client._http.max_retries == 1 + def test_timeout_reaches_the_httpx_transport(self): + client = ShadeClient(api_key="test-key", timeout=5.0) + + with patch.object(client._client._http, "request") as mock_request: + client.request("GET", "/payments") + + assert mock_request.call_args.kwargs["timeout"] == 5.0 + + def test_module_level_timeout_reaches_the_httpx_transport(self): + shade.timeout = 7.0 + client = ShadeClient(api_key="test-key") + + with patch.object(client._client._http, "request") as mock_request: + client.request("GET", "/payments") + + assert mock_request.call_args.kwargs["timeout"] == 7.0 + def test_invalid_timeout_on_client_raises(self): with pytest.raises(ValueError, match="timeout must be greater than 0"): ShadeClient(api_key="test-key", timeout=-1.0)