diff --git a/src/shade/__init__.py b/src/shade/__init__.py index 536e59f..50ec3ee 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, get_config from .gateway import Gateway from .http import AsyncHTTPClient, SyncHTTPClient +from .resources import BaseResource from .errors import ( AuthenticationError, InvalidRequestError, @@ -31,14 +32,12 @@ __version__ = "0.1.0" -# ShadeClient is an alias for Gateway. -ShadeClient = Gateway - __all__ = [ "AssetBalance", "AsyncHTTPClient", "AuthenticationError", "Balance", + "BaseResource", "Environment", "Gateway", "HTTPError", @@ -59,10 +58,12 @@ "WebhookEventType", "config", "get_config", - "api_key", "api_base", + "api_key", + "default_client", "environment", "max_retries", + "reset_default_client", "timeout", "wrap_stellar_errors", ] diff --git a/src/shade/client.py b/src/shade/client.py index 45c7544..e626ec1 100644 --- a/src/shade/client.py +++ b/src/shade/client.py @@ -1,39 +1,198 @@ -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, resolved per request so a +client on the defaults follows later changes to ``shade.api_key`` and friends. +""" +from __future__ import annotations + +import os +import threading +from typing import Any, Dict, Optional import httpx -from shade._debug import log_request, log_response -from shade.config import Environment, config, get_config +from .config import Environment, validate_client_settings +from .config import config as _config +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. Explicit + arguments are pinned to the instance; omitted ones track the global config, + which is read at request time rather than captured at construction. + + 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. + base_url : str + Deprecated. Prefer ``api_base``. + max_retries : int, optional + Automatic retries on HTTP 429 and transient failures. Defaults to + ``shade.max_retries``. Set to ``0`` to disable auto-retry. + timeout : float, optional + Per-request socket timeout in seconds. Defaults to ``shade.timeout``. + 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 + ------ + ValueError + If ``timeout`` or ``max_retries`` is out of range, or ``environment`` + is not a recognised value. + """ def __init__( self, api_key: Optional[str] = None, - base_url: Optional[str] = None, 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, - ): - self.api_key = api_key - self._base_url = base_url.rstrip("/") if base_url else None - self.environment = environment + ) -> None: + self._api_key = api_key + self._environment = ( + _config.parse_environment(environment) if environment is not None else None + ) + api_base = api_base or (base_url if base_url else None) + self._api_base = api_base.rstrip("/") if api_base else None + self._timeout = timeout + self._max_retries = max_retries self.debug = debug - self._http = http_client or httpx.Client() - self._owns_http_client = http_client is None + + 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, + ) + + 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, + ) + self._client = HTTPXTransport( + api_key=self._api_key, + base_url=self._api_base, + environment=self._environment, + timeout=self._timeout, + 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 leaves the client without credentials, and its + requests raise :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 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 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 + self._http.api_key = value + self._async_http.api_key = value + self._client.api_key = value + + @property + def environment(self) -> Environment: + if self._environment is not None: + return self._environment + return _config.environment + + @environment.setter + def environment(self, value: str | Environment) -> None: + parsed = _config.parse_environment(value) + self._environment = parsed + self._http.environment = parsed + self._async_http.environment = parsed + self._client.environment = parsed + + @property + def timeout(self) -> float: + return self._timeout if self._timeout is not None else _config.timeout + + @property + def max_retries(self) -> int: + return self._max_retries if self._max_retries is not None else _config.max_retries + + @property + def _base_url(self) -> str: + if self._api_base: + return self._api_base + if _config.api_base: + return _config.api_base.rstrip("/") + return self.environment.base_url.rstrip("/") + + @property + def api_base(self) -> str: + """The API base URL this client currently 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 @@ -41,41 +200,65 @@ def __enter__(self) -> "ShadeClient": def __exit__(self, *args: Any) -> None: self.close() - def _should_debug(self) -> bool: - return self.debug or config.debug - 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: - 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 {})} - - 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: Optional[str]) -> str: + """Show only the last four characters of a key, for use in reprs.""" + if not api_key: + return "unset" + if len(api_key) <= 4: + return "****" + return "*" * (len(api_key) - 4) + api_key[-4:] + + +_default_client: Optional[ShadeClient] = None +_default_client_lock = threading.Lock() + + +def default_client() -> ShadeClient: + """Return the shared client backed by the global ``shade`` config. + + Resources fall back to this when constructed without an explicit + ``client=``. It pins no settings of its own, so every global change — + including a ``shade.api_key`` assigned after the first call — is picked up + on the next request. + """ + global _default_client + + with _default_client_lock: + if _default_client is None: + _default_client = ShadeClient() + return _default_client + - return response +def reset_default_client() -> None: + """Drop the cached global client. Primarily useful in tests.""" + global _default_client + with _default_client_lock: + client, _default_client = _default_client, None + if client is not None: + client.close() diff --git a/src/shade/config.py b/src/shade/config.py index 1f8e10b..9af0a83 100644 --- a/src/shade/config.py +++ b/src/shade/config.py @@ -264,8 +264,8 @@ def get_config( 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." + "No API key provided. Pass api_key= to ShadeClient, set " + "shade.api_key, or set the SHADE_API_KEY environment variable." ) resolved_env = ( diff --git a/src/shade/gateway.py b/src/shade/gateway.py index d6fecf8..e847eef 100644 --- a/src/shade/gateway.py +++ b/src/shade/gateway.py @@ -1,154 +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, get_config -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, 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``). - 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: Optional[str] = None, - 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: - 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, - ) - - 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, - ) - - 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 - - @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: - if self._environment is not None: - return _config.parse_environment(self._environment) - return _config.environment - - @environment.setter - def environment(self, value: str | Environment) -> None: - 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: - 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 - # ------------------------------------------------------------------ - - 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). @@ -171,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]: @@ -184,4 +45,3 @@ async def process_payment_async( "/payments", {"amount": amount, "currency": currency}, ) - diff --git a/src/shade/http.py b/src/shade/http.py index 4775db4..d27bb22 100644 --- a/src/shade/http.py +++ b/src/shade/http.py @@ -17,9 +17,10 @@ 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, Environment, config as _config, get_config, validate_client_settings @@ -391,6 +392,83 @@ 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. + Credentials and the target host are resolved per request, so a client left + on the global defaults follows later changes to ``shade.api_key`` and + friends. 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: 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 + + def close(self) -> None: + if self._owns_http_client: + self._http.close() + + def _should_debug(self) -> bool: + return self.debug or _config.debug + + def request( + self, + method: str, + path: str, + *, + headers: Optional[Mapping[str, str]] = None, + 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, + timeout=self._timeout, + ) + + 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 {})} + + 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, + timeout=cfg.timeout, + ) + + 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..6d9f5f3 --- /dev/null +++ b/src/shade/resources/base.py @@ -0,0 +1,57 @@ +""" +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.""" + 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 2a2b885..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) @@ -178,6 +195,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..5d22749 --- /dev/null +++ b/tests/test_shade_client.py @@ -0,0 +1,353 @@ +""" +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. +* Requesting with no api_key and 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) + _config.reset() + reset_default_client() + yield + _config.reset() + 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_instance_settings_survive_later_global_changes(self): + client = ShadeClient(api_key="sk_test_instance", timeout=3.0) + + _config.api_key = "sk_live_global" + _config.timeout = 99.0 + + assert client.api_key == "sk_test_instance" + assert client.timeout == 3.0 + + def test_unset_settings_follow_later_global_changes(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_second" + 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, match="No API key provided"): + payments.retrieve("pay_1") + + def test_repr_distinguishes_global_from_explicit_clients(self): + assert repr(Payments()) == "" + assert "Payments client=