Skip to content

Commit 3ad7931

Browse files
authored
Merge pull request #29 from Tijesunimi004/feature/api-base-override
feat(client): add shade.api_base override support
2 parents ed3c131 + 3d21f6d commit 3ad7931

4 files changed

Lines changed: 256 additions & 6 deletions

File tree

src/shade/__init__.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import sys
2+
from types import ModuleType
3+
from typing import Optional
4+
5+
from .config import Environment
16
from .gateway import Gateway
27
from .http import AsyncHTTPClient, SyncHTTPClient
38
from .errors import (
@@ -12,15 +17,38 @@
1217

1318
__version__ = "0.1.0"
1419

20+
# ShadeClient is an alias for Gateway.
21+
ShadeClient = Gateway
22+
1523
__all__ = [
1624
"AsyncHTTPClient",
1725
"AuthenticationError",
26+
"Environment",
1827
"Gateway",
1928
"HTTPError",
2029
"InvalidRequestError",
2130
"NetworkError",
2231
"NotFoundError",
2332
"RateLimitError",
33+
"ShadeClient",
2434
"ShadeError",
2535
"SyncHTTPClient",
26-
]
36+
"api_base",
37+
]
38+
39+
40+
class _ShadeModule(ModuleType):
41+
"""Module subclass that exposes api_base as a settable attribute backed by config."""
42+
43+
@property
44+
def api_base(self) -> Optional[str]:
45+
from . import config as _config
46+
return _config.api_base
47+
48+
@api_base.setter
49+
def api_base(self, value: Optional[str]) -> None:
50+
from . import config as _config
51+
_config.api_base = value
52+
53+
54+
sys.modules[__name__].__class__ = _ShadeModule

src/shade/config.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from __future__ import annotations
2+
3+
from enum import Enum
4+
from typing import Optional
5+
6+
from stellar_sdk import Network
7+
8+
# Module-level API base URL override. Intended for development and testing only.
9+
# Set this before creating any client to route all requests to a custom host.
10+
api_base: Optional[str] = None
11+
12+
13+
class Environment(str, Enum):
14+
MAINNET = "mainnet"
15+
TESTNET = "testnet"
16+
17+
@property
18+
def base_url(self) -> str:
19+
_urls: dict[str, str] = {
20+
"mainnet": "https://api.shadeprotocol.io/v1",
21+
"testnet": "https://testnet.api.shadeprotocol.io/v1",
22+
}
23+
return _urls[self.value]
24+
25+
@property
26+
def network_passphrase(self) -> str:
27+
_passphrases: dict[str, str] = {
28+
"mainnet": Network.PUBLIC_NETWORK_PASSPHRASE,
29+
"testnet": Network.TESTNET_NETWORK_PASSPHRASE,
30+
}
31+
return _passphrases[self.value]

src/shade/gateway.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from typing import Any, Dict, Optional
44

5+
from . import config as _config
6+
from .config import Environment
57
from .http import AsyncHTTPClient, SyncHTTPClient, DEFAULT_MAX_RETRIES
68

79

@@ -13,28 +15,42 @@ class Gateway:
1315
----------
1416
api_key : str
1517
Your Shade API key.
18+
environment : Environment
19+
Controls the Stellar network passphrase and the default API URL.
20+
Defaults to ``Environment.MAINNET``.
21+
api_base : str, optional
22+
Override the API host for this client (useful for local dev or staging).
23+
Takes precedence over the module-level ``shade.api_base`` and the
24+
URL derived from ``environment``. Trailing slashes are trimmed.
25+
Intended for development and testing only.
1626
base_url : str
17-
Override the default API base URL (useful for testing).
27+
Deprecated. Prefer ``api_base``.
1828
max_retries : int
1929
Number of automatic retries on HTTP 429. Defaults to
2030
``DEFAULT_MAX_RETRIES`` (3). Set to ``0`` to disable.
2131
timeout : float
2232
Per-request socket timeout in seconds.
2333
"""
2434

25-
_DEFAULT_BASE_URL = "https://api.shadeprotocol.io/v1"
26-
2735
def __init__(
2836
self,
2937
api_key: str = "",
38+
environment: Environment = Environment.MAINNET,
39+
api_base: Optional[str] = None,
3040
base_url: str = "",
3141
max_retries: int = DEFAULT_MAX_RETRIES,
3242
timeout: float = 30.0,
3343
) -> None:
3444
if not api_key:
3545
raise ValueError("api_key must be a non-empty string")
3646
self.api_key = api_key
37-
self._base_url = base_url or self._DEFAULT_BASE_URL
47+
self.environment = environment
48+
49+
# Resolution order: explicit api_base > module-level shade.api_base
50+
# > legacy base_url > environment URL
51+
resolved = api_base or _config.api_base or base_url or environment.base_url
52+
self._base_url = resolved.rstrip("/")
53+
3854
self._http = SyncHTTPClient(
3955
base_url=self._base_url,
4056
api_key=api_key,
@@ -86,4 +102,4 @@ async def process_payment_async(
86102
"POST",
87103
"/payments",
88104
{"amount": amount, "currency": currency},
89-
)
105+
)

tests/test_api_base.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
"""
2+
Tests for api_base override support (issue #6).
3+
4+
Covers:
5+
* shade.api_base module-level attribute reads and writes
6+
* Gateway(api_base=...) per-client override
7+
* Trailing slash normalisation
8+
* Precedence: explicit api_base > module-level shade.api_base > environment URL
9+
* Environment still controls Stellar network passphrase when api_base is set
10+
"""
11+
from __future__ import annotations
12+
13+
import pytest
14+
15+
import shade
16+
from shade import Gateway
17+
from shade import config as _config
18+
from shade.config import Environment
19+
20+
21+
@pytest.fixture(autouse=True)
22+
def _reset_api_base():
23+
original = _config.api_base
24+
yield
25+
_config.api_base = original
26+
27+
28+
# ---------------------------------------------------------------------------
29+
# Module-level shade.api_base
30+
# ---------------------------------------------------------------------------
31+
32+
class TestModuleLevelApiBase:
33+
def test_defaults_to_none(self):
34+
assert shade.api_base is None
35+
36+
def test_assignment_is_readable(self):
37+
shade.api_base = "https://staging.shadeprotocol.io"
38+
assert shade.api_base == "https://staging.shadeprotocol.io"
39+
40+
def test_assignment_updates_config(self):
41+
shade.api_base = "https://staging.shadeprotocol.io"
42+
assert _config.api_base == "https://staging.shadeprotocol.io"
43+
44+
def test_used_when_no_per_client_override(self):
45+
shade.api_base = "https://staging.shadeprotocol.io"
46+
gw = Gateway(api_key="test-key")
47+
assert gw._base_url == "https://staging.shadeprotocol.io"
48+
49+
def test_trailing_slash_normalised(self):
50+
shade.api_base = "https://staging.shadeprotocol.io/"
51+
gw = Gateway(api_key="test-key")
52+
assert gw._base_url == "https://staging.shadeprotocol.io"
53+
54+
def test_reset_to_none_restores_environment_url(self):
55+
shade.api_base = "https://staging.shadeprotocol.io"
56+
shade.api_base = None
57+
gw = Gateway(api_key="test-key")
58+
assert gw._base_url == Environment.MAINNET.base_url
59+
60+
61+
# ---------------------------------------------------------------------------
62+
# Per-client api_base
63+
# ---------------------------------------------------------------------------
64+
65+
class TestPerClientApiBase:
66+
def test_overrides_environment_url(self):
67+
gw = Gateway(api_key="test-key", api_base="http://localhost:8000")
68+
assert gw._base_url == "http://localhost:8000"
69+
70+
def test_trailing_slash_normalised(self):
71+
gw = Gateway(api_key="test-key", api_base="http://localhost:8000/")
72+
assert gw._base_url == "http://localhost:8000"
73+
74+
def test_takes_precedence_over_module_level(self):
75+
shade.api_base = "https://staging.shadeprotocol.io"
76+
gw = Gateway(api_key="test-key", api_base="http://localhost:8000")
77+
assert gw._base_url == "http://localhost:8000"
78+
79+
def test_http_client_uses_resolved_base_url(self):
80+
gw = Gateway(api_key="test-key", api_base="http://localhost:8000")
81+
assert gw._http.base_url == "http://localhost:8000"
82+
assert gw._async_http.base_url == "http://localhost:8000"
83+
84+
85+
# ---------------------------------------------------------------------------
86+
# Environment passphrase independence
87+
# ---------------------------------------------------------------------------
88+
89+
class TestEnvironmentPassphrase:
90+
def test_mainnet_passphrase_unchanged_when_api_base_set(self):
91+
from stellar_sdk import Network
92+
gw = Gateway(
93+
api_key="test-key",
94+
api_base="http://localhost:8000",
95+
environment=Environment.MAINNET,
96+
)
97+
assert gw.environment.network_passphrase == Network.PUBLIC_NETWORK_PASSPHRASE
98+
99+
def test_testnet_passphrase_unchanged_when_api_base_set(self):
100+
from stellar_sdk import Network
101+
gw = Gateway(
102+
api_key="test-key",
103+
api_base="http://localhost:8000",
104+
environment=Environment.TESTNET,
105+
)
106+
assert gw.environment.network_passphrase == Network.TESTNET_NETWORK_PASSPHRASE
107+
108+
def test_api_base_overrides_url_not_passphrase(self):
109+
from stellar_sdk import Network
110+
gw = Gateway(
111+
api_key="test-key",
112+
api_base="http://localhost:8000",
113+
environment=Environment.MAINNET,
114+
)
115+
assert gw._base_url == "http://localhost:8000"
116+
assert gw.environment.network_passphrase == Network.PUBLIC_NETWORK_PASSPHRASE
117+
118+
119+
# ---------------------------------------------------------------------------
120+
# URL resolution precedence
121+
# ---------------------------------------------------------------------------
122+
123+
class TestUrlResolutionPrecedence:
124+
def test_environment_url_is_default(self):
125+
gw = Gateway(api_key="test-key", environment=Environment.MAINNET)
126+
assert gw._base_url == Environment.MAINNET.base_url
127+
128+
def test_module_level_beats_environment(self):
129+
shade.api_base = "https://staging.shadeprotocol.io"
130+
gw = Gateway(api_key="test-key", environment=Environment.MAINNET)
131+
assert gw._base_url == "https://staging.shadeprotocol.io"
132+
133+
def test_per_client_beats_module_level(self):
134+
shade.api_base = "https://staging.shadeprotocol.io"
135+
gw = Gateway(api_key="test-key", api_base="http://localhost:8000")
136+
assert gw._base_url == "http://localhost:8000"
137+
138+
def test_testnet_environment_url_used_by_default(self):
139+
gw = Gateway(api_key="test-key", environment=Environment.TESTNET)
140+
assert gw._base_url == Environment.TESTNET.base_url
141+
142+
143+
# ---------------------------------------------------------------------------
144+
# Environment enum
145+
# ---------------------------------------------------------------------------
146+
147+
class TestEnvironment:
148+
def test_mainnet_base_url(self):
149+
assert Environment.MAINNET.base_url == "https://api.shadeprotocol.io/v1"
150+
151+
def test_testnet_base_url(self):
152+
assert Environment.TESTNET.base_url == "https://testnet.api.shadeprotocol.io/v1"
153+
154+
def test_mainnet_network_passphrase(self):
155+
from stellar_sdk import Network
156+
assert Environment.MAINNET.network_passphrase == Network.PUBLIC_NETWORK_PASSPHRASE
157+
158+
def test_testnet_network_passphrase(self):
159+
from stellar_sdk import Network
160+
assert Environment.TESTNET.network_passphrase == Network.TESTNET_NETWORK_PASSPHRASE
161+
162+
163+
# ---------------------------------------------------------------------------
164+
# ShadeClient alias
165+
# ---------------------------------------------------------------------------
166+
167+
class TestShadeClientAlias:
168+
def test_shade_client_is_gateway(self):
169+
from shade import ShadeClient
170+
assert ShadeClient is Gateway
171+
172+
def test_shade_client_accepts_api_base(self):
173+
from shade import ShadeClient
174+
client = ShadeClient(api_key="test-key", api_base="http://localhost:8000")
175+
assert client._base_url == "http://localhost:8000"

0 commit comments

Comments
 (0)