Skip to content
Open
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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

5 changes: 3 additions & 2 deletions src/shade/__init__.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -24,6 +23,7 @@
"AsyncHTTPClient",
"AuthenticationError",
"Environment",
"EnvironmentConfig",
"Gateway",
"HTTPError",
"InvalidRequestError",
Expand All @@ -34,6 +34,7 @@
"ShadeError",
"SyncHTTPClient",
"api_base",
"parse_environment",
]


Expand Down
49 changes: 46 additions & 3 deletions src/shade/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from dataclasses import dataclass
from enum import Enum
from typing import Optional

Expand All @@ -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] = {
Expand All @@ -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)]
230 changes: 125 additions & 105 deletions src/shade/gateway.py
Original file line number Diff line number Diff line change
@@ -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},
)
20 changes: 20 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -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

Loading
Loading