diff --git a/README.md b/README.md index 569248b..78d5148 100644 --- a/README.md +++ b/README.md @@ -2,3 +2,22 @@ A Python-based payment gateway system for Shade Protocol. +## Environments + +Shade defaults to the sandbox environment so local development uses Stellar +testnet and the staging Shade backend. + +```python +from shade import Gateway + +shade = Gateway() +shade.environment = "production" + +print(shade.horizon_url) +print(shade.network_passphrase) +print(shade.api_base_url) +``` + +Supported values are `"sandbox"` and `"production"`. Invalid values raise a +`ValueError` that lists the accepted options. + diff --git a/src/shade/__init__.py b/src/shade/__init__.py index af2d261..e316050 100644 --- a/src/shade/__init__.py +++ b/src/shade/__init__.py @@ -1,8 +1,7 @@ +from .config import Environment, EnvironmentConfig, parse_environment 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 ( @@ -24,6 +23,7 @@ "AsyncHTTPClient", "AuthenticationError", "Environment", + "EnvironmentConfig", "Gateway", "HTTPError", "InvalidRequestError", @@ -34,6 +34,7 @@ "ShadeError", "SyncHTTPClient", "api_base", + "parse_environment", ] diff --git a/src/shade/config.py b/src/shade/config.py index f29a6af..016f23b 100644 --- a/src/shade/config.py +++ b/src/shade/config.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import dataclass from enum import Enum from typing import Optional @@ -10,10 +11,11 @@ api_base: Optional[str] = None -class Environment(str, Enum): - MAINNET = "mainnet" - TESTNET = "testnet" +class Environment(str, Enum): + SANDBOX = "sandbox" + PRODUCTION = "production" + @property def base_url(self) -> str: _urls: dict[str, str] = { @@ -29,3 +31,44 @@ def network_passphrase(self) -> str: "testnet": Network.TESTNET_NETWORK_PASSPHRASE, } return _passphrases[self.value] + + +@dataclass(frozen=True) +class EnvironmentConfig: + horizon_url: str + network_passphrase: str + api_base_url: str + + +_ENVIRONMENT_CONFIGS = { + Environment.SANDBOX: EnvironmentConfig( + horizon_url="https://horizon-testnet.stellar.org", + network_passphrase="Test SDF Network ; September 2015", + api_base_url="https://api.sandbox.shadeprotocol.io", + ), + Environment.PRODUCTION: EnvironmentConfig( + horizon_url="https://horizon.stellar.org", + network_passphrase="Public Global Stellar Network ; September 2015", + api_base_url="https://api.shadeprotocol.io", + ), +} + + +def parse_environment(value: Environment | str) -> Environment: + if isinstance(value, Environment): + return value + + if isinstance(value, str): + normalized = value.strip().lower() + for environment in Environment: + if environment.value == normalized: + return environment + + valid_options = ", ".join(environment.value for environment in Environment) + raise ValueError( + f"Invalid Shade environment {value!r}. Valid options: {valid_options}" + ) + + +def get_environment_config(value: Environment | str) -> EnvironmentConfig: + return _ENVIRONMENT_CONFIGS[parse_environment(value)] diff --git a/src/shade/gateway.py b/src/shade/gateway.py index ed71b4a..6bcd824 100644 --- a/src/shade/gateway.py +++ b/src/shade/gateway.py @@ -1,105 +1,125 @@ -from __future__ import annotations - -from typing import Any, Dict, Optional - -from . import config as _config -from .config import Environment -from .http import AsyncHTTPClient, SyncHTTPClient, DEFAULT_MAX_RETRIES - - -class Gateway: - """ - Main entry point for the Shade Payment Gateway. - - Parameters - ---------- - 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 - 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. - """ - - 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, - ) -> None: - if not api_key: - raise ValueError("api_key must be a non-empty string") - self.api_key = api_key - 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, - max_retries=max_retries, - timeout=timeout, - ) - self._async_http = AsyncHTTPClient( - base_url=self._base_url, - api_key=api_key, - max_retries=max_retries, - timeout=timeout, - ) - - # ------------------------------------------------------------------ - # Sync API - # ------------------------------------------------------------------ - - def process_payment(self, amount: float, currency: str) -> Dict[str, Any]: - """ - Process a payment (sync). - - Parameters - ---------- - amount : float - Payment amount. - currency : str - ISO 4217 currency code (e.g. ``"USD"``). - - Returns - ------- - dict - API response body. - """ - return self._http.request( - "POST", - "/payments", - {"amount": amount, "currency": currency}, - ) - - # ------------------------------------------------------------------ - # Async API - # ------------------------------------------------------------------ - - async def process_payment_async( - self, amount: float, currency: str - ) -> Dict[str, Any]: - """Async variant of :meth:`process_payment`.""" - return await self._async_http.request( - "POST", - "/payments", - {"amount": amount, "currency": currency}, - ) +from __future__ import annotations + +from typing import Any, Dict, Optional + +from . import config as _config +from .config import Environment, get_environment_config, parse_environment +from .http import AsyncHTTPClient, SyncHTTPClient, DEFAULT_MAX_RETRIES + + +class Gateway: + """ + Main entry point for the Shade Payment Gateway. + + Parameters + ---------- + 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 + 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. + """ + @property + def environment(self) -> Environment: + return self._environment + + @environment.setter + def environment(self, value: Environment | str) -> None: + self._environment = parse_environment(value) + self._environment_config = get_environment_config(self._environment) + + @property + def horizon_url(self) -> str: + return self._environment_config.horizon_url + + @property + def network_passphrase(self) -> str: + return self._environment_config.network_passphrase + + @property + def api_base_url(self) -> str: + return self._environment_config.api_base_url + + 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, + ) -> None: + if not api_key: + raise ValueError("api_key must be a non-empty string") + self.api_key = api_key + 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, + max_retries=max_retries, + timeout=timeout, + ) + self._async_http = AsyncHTTPClient( + base_url=self._base_url, + api_key=api_key, + max_retries=max_retries, + timeout=timeout, + ) + + # ------------------------------------------------------------------ + # Sync API + # ------------------------------------------------------------------ + + def process_payment(self, amount: float, currency: str) -> Dict[str, Any]: + """ + Process a payment (sync). + + Parameters + ---------- + amount : float + Payment amount. + currency : str + ISO 4217 currency code (e.g. ``"USD"``). + + Returns + ------- + dict + API response body. + """ + return self._http.request( + "POST", + "/payments", + {"amount": amount, "currency": currency}, + ) + + # ------------------------------------------------------------------ + # Async API + # ------------------------------------------------------------------ + + async def process_payment_async( + self, amount: float, currency: str + ) -> Dict[str, Any]: + """Async variant of :meth:`process_payment`.""" + return await self._async_http.request( + "POST", + "/payments", + {"amount": amount, "currency": currency}, + ) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..11678a9 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,20 @@ +import pytest + +from shade import Environment +from shade.config import parse_environment + + +def test_parse_environment_accepts_string_shorthands(): + assert parse_environment("sandbox") is Environment.SANDBOX + assert parse_environment("production") is Environment.PRODUCTION + + +def test_parse_environment_rejects_invalid_strings_with_valid_options(): + with pytest.raises(ValueError) as exc: + parse_environment("staging") + + message = str(exc.value) + assert "staging" in message + assert "sandbox" in message + assert "production" in message + diff --git a/tests/test_gateway.py b/tests/test_gateway.py index 68222f0..3209fbd 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -1,6 +1,6 @@ import pytest from unittest.mock import patch -from shade import Gateway +from shade import Environment, Gateway def test_gateway_initialization(): @@ -20,6 +20,27 @@ def test_process_payment(): "POST", "/payments", {"amount": 100.0, "currency": "USD"} ) + +def test_gateway_defaults_to_sandbox_environment(): + gateway = Gateway() + + assert gateway.environment is Environment.SANDBOX + assert gateway.horizon_url == "https://horizon-testnet.stellar.org" + assert gateway.network_passphrase == "Test SDF Network ; September 2015" + assert gateway.api_base_url == "https://api.sandbox.shadeprotocol.io" + + +def test_gateway_environment_string_updates_stellar_and_api_config(): + gateway = Gateway() + + gateway.environment = "production" + + assert gateway.environment is Environment.PRODUCTION + assert gateway.horizon_url == "https://horizon.stellar.org" + assert gateway.network_passphrase == "Public Global Stellar Network ; September 2015" + assert gateway.api_base_url == "https://api.shadeprotocol.io" + + def test_process_payment_async(): import asyncio from unittest.mock import AsyncMock @@ -33,4 +54,4 @@ def test_process_payment_async(): assert result == mock_response mock_req.assert_called_once_with( "POST", "/payments", {"amount": 50.0, "currency": "EUR"} - ) \ No newline at end of file + )