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
30 changes: 29 additions & 1 deletion src/shade/__init__.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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",
]
"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
31 changes: 31 additions & 0 deletions src/shade/config.py
Original file line number Diff line number Diff line change
@@ -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]
26 changes: 21 additions & 5 deletions src/shade/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -13,28 +15,42 @@ 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.
timeout : float
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,
Comment on lines 35 to +39
base_url: str = "",
max_retries: int = DEFAULT_MAX_RETRIES,
timeout: float = 30.0,
) -> None:
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,
Expand Down Expand Up @@ -86,4 +102,4 @@ async def process_payment_async(
"POST",
"/payments",
{"amount": amount, "currency": currency},
)
)
175 changes: 175 additions & 0 deletions tests/test_api_base.py
Original file line number Diff line number Diff line change
@@ -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

Comment on lines +123 to +141

# ---------------------------------------------------------------------------
# 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"
Loading