-
Notifications
You must be signed in to change notification settings - Fork 17
feat(client): implement ShadeClient for per-instance configuration (#2) #55
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,81 +1,264 @@ | ||
| 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 | ||
|
|
||
| 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() | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.