diff --git a/src/shade/__init__.py b/src/shade/__init__.py index b6c419c..af2d261 100644 --- a/src/shade/__init__.py +++ b/src/shade/__init__.py @@ -1,3 +1,8 @@ +import sys +from types import ModuleType +from typing import Optional + +from .config import Environment from .gateway import Gateway from .http import AsyncHTTPClient, SyncHTTPClient from .errors import ( @@ -12,15 +17,38 @@ __version__ = "0.1.0" +# ShadeClient is an alias for Gateway. +ShadeClient = Gateway + __all__ = [ "AsyncHTTPClient", "AuthenticationError", + "Environment", "Gateway", "HTTPError", "InvalidRequestError", "NetworkError", "NotFoundError", "RateLimitError", + "ShadeClient", "ShadeError", "SyncHTTPClient", -] \ No newline at end of file + "api_base", +] + + +class _ShadeModule(ModuleType): + """Module subclass that exposes api_base as a settable attribute backed by config.""" + + @property + def api_base(self) -> Optional[str]: + from . import config as _config + return _config.api_base + + @api_base.setter + def api_base(self, value: Optional[str]) -> None: + from . import config as _config + _config.api_base = value + + +sys.modules[__name__].__class__ = _ShadeModule diff --git a/src/shade/config.py b/src/shade/config.py new file mode 100644 index 0000000..f29a6af --- /dev/null +++ b/src/shade/config.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from enum import Enum +from typing import Optional + +from stellar_sdk import Network + +# Module-level API base URL override. Intended for development and testing only. +# Set this before creating any client to route all requests to a custom host. +api_base: Optional[str] = None + + +class Environment(str, Enum): + MAINNET = "mainnet" + TESTNET = "testnet" + + @property + def base_url(self) -> str: + _urls: dict[str, str] = { + "mainnet": "https://api.shadeprotocol.io/v1", + "testnet": "https://testnet.api.shadeprotocol.io/v1", + } + return _urls[self.value] + + @property + def network_passphrase(self) -> str: + _passphrases: dict[str, str] = { + "mainnet": Network.PUBLIC_NETWORK_PASSPHRASE, + "testnet": Network.TESTNET_NETWORK_PASSPHRASE, + } + return _passphrases[self.value] diff --git a/src/shade/gateway.py b/src/shade/gateway.py index a18741a..ed71b4a 100644 --- a/src/shade/gateway.py +++ b/src/shade/gateway.py @@ -2,6 +2,8 @@ from typing import Any, Dict, Optional +from . import config as _config +from .config import Environment from .http import AsyncHTTPClient, SyncHTTPClient, DEFAULT_MAX_RETRIES @@ -13,8 +15,16 @@ class Gateway: ---------- api_key : str Your Shade API key. + environment : Environment + Controls the Stellar network passphrase and the default API URL. + Defaults to ``Environment.MAINNET``. + 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 - Override the default API base URL (useful for testing). + Deprecated. Prefer ``api_base``. max_retries : int Number of automatic retries on HTTP 429. Defaults to ``DEFAULT_MAX_RETRIES`` (3). Set to ``0`` to disable. @@ -22,11 +32,11 @@ class Gateway: Per-request socket timeout in seconds. """ - _DEFAULT_BASE_URL = "https://api.shadeprotocol.io/v1" - def __init__( self, api_key: str = "", + environment: Environment = Environment.MAINNET, + api_base: Optional[str] = None, base_url: str = "", max_retries: int = DEFAULT_MAX_RETRIES, timeout: float = 30.0, @@ -34,7 +44,13 @@ def __init__( if not api_key: raise ValueError("api_key must be a non-empty string") self.api_key = api_key - self._base_url = base_url or self._DEFAULT_BASE_URL + self.environment = environment + + # 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 environment.base_url + self._base_url = resolved.rstrip("/") + self._http = SyncHTTPClient( base_url=self._base_url, api_key=api_key, @@ -86,4 +102,4 @@ async def process_payment_async( "POST", "/payments", {"amount": amount, "currency": currency}, - ) \ No newline at end of file + ) diff --git a/tests/test_api_base.py b/tests/test_api_base.py new file mode 100644 index 0000000..8e78810 --- /dev/null +++ b/tests/test_api_base.py @@ -0,0 +1,175 @@ +""" +Tests for api_base override support (issue #6). + +Covers: +* shade.api_base module-level attribute reads and writes +* Gateway(api_base=...) per-client override +* Trailing slash normalisation +* Precedence: explicit api_base > module-level shade.api_base > environment URL +* Environment still controls Stellar network passphrase when api_base is set +""" +from __future__ import annotations + +import pytest + +import shade +from shade import Gateway +from shade import config as _config +from shade.config import Environment + + +@pytest.fixture(autouse=True) +def _reset_api_base(): + original = _config.api_base + yield + _config.api_base = original + + +# --------------------------------------------------------------------------- +# Module-level shade.api_base +# --------------------------------------------------------------------------- + +class TestModuleLevelApiBase: + def test_defaults_to_none(self): + assert shade.api_base is None + + def test_assignment_is_readable(self): + shade.api_base = "https://staging.shadeprotocol.io" + assert shade.api_base == "https://staging.shadeprotocol.io" + + def test_assignment_updates_config(self): + shade.api_base = "https://staging.shadeprotocol.io" + assert _config.api_base == "https://staging.shadeprotocol.io" + + def test_used_when_no_per_client_override(self): + shade.api_base = "https://staging.shadeprotocol.io" + gw = Gateway(api_key="test-key") + assert gw._base_url == "https://staging.shadeprotocol.io" + + def test_trailing_slash_normalised(self): + shade.api_base = "https://staging.shadeprotocol.io/" + gw = Gateway(api_key="test-key") + assert gw._base_url == "https://staging.shadeprotocol.io" + + def test_reset_to_none_restores_environment_url(self): + shade.api_base = "https://staging.shadeprotocol.io" + shade.api_base = None + gw = Gateway(api_key="test-key") + assert gw._base_url == Environment.MAINNET.base_url + + +# --------------------------------------------------------------------------- +# Per-client api_base +# --------------------------------------------------------------------------- + +class TestPerClientApiBase: + def test_overrides_environment_url(self): + gw = Gateway(api_key="test-key", api_base="http://localhost:8000") + assert gw._base_url == "http://localhost:8000" + + def test_trailing_slash_normalised(self): + gw = Gateway(api_key="test-key", api_base="http://localhost:8000/") + assert gw._base_url == "http://localhost:8000" + + def test_takes_precedence_over_module_level(self): + shade.api_base = "https://staging.shadeprotocol.io" + gw = Gateway(api_key="test-key", api_base="http://localhost:8000") + assert gw._base_url == "http://localhost:8000" + + def test_http_client_uses_resolved_base_url(self): + gw = Gateway(api_key="test-key", api_base="http://localhost:8000") + assert gw._http.base_url == "http://localhost:8000" + assert gw._async_http.base_url == "http://localhost:8000" + + +# --------------------------------------------------------------------------- +# Environment passphrase independence +# --------------------------------------------------------------------------- + +class TestEnvironmentPassphrase: + def test_mainnet_passphrase_unchanged_when_api_base_set(self): + from stellar_sdk import Network + gw = Gateway( + api_key="test-key", + api_base="http://localhost:8000", + environment=Environment.MAINNET, + ) + assert gw.environment.network_passphrase == Network.PUBLIC_NETWORK_PASSPHRASE + + def test_testnet_passphrase_unchanged_when_api_base_set(self): + from stellar_sdk import Network + gw = Gateway( + api_key="test-key", + api_base="http://localhost:8000", + environment=Environment.TESTNET, + ) + assert gw.environment.network_passphrase == Network.TESTNET_NETWORK_PASSPHRASE + + def test_api_base_overrides_url_not_passphrase(self): + from stellar_sdk import Network + gw = Gateway( + api_key="test-key", + api_base="http://localhost:8000", + environment=Environment.MAINNET, + ) + assert gw._base_url == "http://localhost:8000" + assert gw.environment.network_passphrase == Network.PUBLIC_NETWORK_PASSPHRASE + + +# --------------------------------------------------------------------------- +# URL resolution precedence +# --------------------------------------------------------------------------- + +class TestUrlResolutionPrecedence: + def test_environment_url_is_default(self): + gw = Gateway(api_key="test-key", environment=Environment.MAINNET) + assert gw._base_url == Environment.MAINNET.base_url + + def test_module_level_beats_environment(self): + shade.api_base = "https://staging.shadeprotocol.io" + gw = Gateway(api_key="test-key", environment=Environment.MAINNET) + assert gw._base_url == "https://staging.shadeprotocol.io" + + def test_per_client_beats_module_level(self): + shade.api_base = "https://staging.shadeprotocol.io" + gw = Gateway(api_key="test-key", api_base="http://localhost:8000") + assert gw._base_url == "http://localhost:8000" + + def test_testnet_environment_url_used_by_default(self): + gw = Gateway(api_key="test-key", environment=Environment.TESTNET) + assert gw._base_url == Environment.TESTNET.base_url + + +# --------------------------------------------------------------------------- +# Environment enum +# --------------------------------------------------------------------------- + +class TestEnvironment: + def test_mainnet_base_url(self): + assert Environment.MAINNET.base_url == "https://api.shadeprotocol.io/v1" + + def test_testnet_base_url(self): + assert Environment.TESTNET.base_url == "https://testnet.api.shadeprotocol.io/v1" + + def test_mainnet_network_passphrase(self): + from stellar_sdk import Network + assert Environment.MAINNET.network_passphrase == Network.PUBLIC_NETWORK_PASSPHRASE + + def test_testnet_network_passphrase(self): + from stellar_sdk import Network + assert Environment.TESTNET.network_passphrase == Network.TESTNET_NETWORK_PASSPHRASE + + +# --------------------------------------------------------------------------- +# ShadeClient alias +# --------------------------------------------------------------------------- + +class TestShadeClientAlias: + def test_shade_client_is_gateway(self): + from shade import ShadeClient + assert ShadeClient is Gateway + + def test_shade_client_accepts_api_base(self): + from shade import ShadeClient + client = ShadeClient(api_key="test-key", api_base="http://localhost:8000") + assert client._base_url == "http://localhost:8000"