fazercards-python-sdk 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fazercards_python_sdk-0.1.1.dist-info/METADATA +169 -0
- fazercards_python_sdk-0.1.1.dist-info/RECORD +18 -0
- fazercards_python_sdk-0.1.1.dist-info/WHEEL +4 -0
- fazercards_python_sdk-0.1.1.dist-info/licenses/LICENSE +21 -0
- fazercards_sdk/__init__.py +102 -0
- fazercards_sdk/_core.py +308 -0
- fazercards_sdk/_version.py +1 -0
- fazercards_sdk/client.py +58 -0
- fazercards_sdk/config.py +104 -0
- fazercards_sdk/enums.py +90 -0
- fazercards_sdk/errors.py +104 -0
- fazercards_sdk/messages.py +60 -0
- fazercards_sdk/messages.yaml +48 -0
- fazercards_sdk/models.py +515 -0
- fazercards_sdk/money.py +41 -0
- fazercards_sdk/py.typed +0 -0
- fazercards_sdk/resources.py +313 -0
- fazercards_sdk/webhooks.py +63 -0
fazercards_sdk/config.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
from urllib.parse import quote, urlencode, urlsplit
|
|
5
|
+
|
|
6
|
+
from goodconf import Field, GoodConf, GoodConfConfigDict
|
|
7
|
+
from pydantic import BaseModel, SecretStr, field_validator
|
|
8
|
+
|
|
9
|
+
from ._version import __version__
|
|
10
|
+
from .errors import ConfigurationError
|
|
11
|
+
from .messages import text
|
|
12
|
+
|
|
13
|
+
LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Endpoints(BaseModel):
|
|
17
|
+
me: str = "/me"
|
|
18
|
+
balance: str = "/balance"
|
|
19
|
+
webhook: str = "/account/webhook"
|
|
20
|
+
topup_categories: str = "/topups"
|
|
21
|
+
topup_offers: str = "/topups/offers"
|
|
22
|
+
topup_order: str = "/topups/order"
|
|
23
|
+
validate_id: str = "/topups/validate-id"
|
|
24
|
+
giftcard_categories: str = "/giftcards"
|
|
25
|
+
giftcard_offers: str = "/giftcards/cards"
|
|
26
|
+
giftcard_order: str = "/giftcards/order"
|
|
27
|
+
orders: str = "/orders"
|
|
28
|
+
order: str = "/orders/{order_id}"
|
|
29
|
+
stars_quote: str = "/telegram/stars"
|
|
30
|
+
stars_buy: str = "/telegram/stars/buy"
|
|
31
|
+
premium_quote: str = "/telegram/premium"
|
|
32
|
+
premium_buy: str = "/telegram/premium/buy"
|
|
33
|
+
steam_rates: str = "/steam-topup/rates"
|
|
34
|
+
steam_login: str = "/steam-topup/check-login"
|
|
35
|
+
steam_order: str = "/steam-topup/order"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Settings(GoodConf):
|
|
39
|
+
model_config = GoodConfConfigDict(
|
|
40
|
+
env_prefix="FAZERCARDS_",
|
|
41
|
+
file_env_var="FAZERCARDS_CONFIG",
|
|
42
|
+
default_files=["fazercards.toml", "fazercards.yaml", "fazercards.yml", "fazercards.json"],
|
|
43
|
+
extra="ignore",
|
|
44
|
+
env_nested_delimiter="__",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
api_key: SecretStr = Field(default=SecretStr(""), description=text("settings.api_key"))
|
|
48
|
+
base_url: str = Field(default="https://api.fzr.cards/api/v2", description=text("settings.base_url"))
|
|
49
|
+
endpoints: Endpoints = Field(default_factory=Endpoints, description=text("settings.endpoints"))
|
|
50
|
+
webhook_secret: SecretStr = Field(default=SecretStr(""), description=text("settings.webhook_secret"))
|
|
51
|
+
idempotency_prefix: str = Field(default="", description=text("settings.idempotency_prefix"))
|
|
52
|
+
timeout: float = Field(default=30.0, gt=0, description=text("settings.timeout"))
|
|
53
|
+
connect_timeout: float = Field(default=10.0, gt=0, description=text("settings.connect_timeout"))
|
|
54
|
+
max_retries: int = Field(default=2, ge=0, le=10, description=text("settings.max_retries"))
|
|
55
|
+
backoff_base: float = Field(default=0.5, ge=0, description=text("settings.backoff_base"))
|
|
56
|
+
backoff_cap: float = Field(default=8.0, ge=0, description=text("settings.backoff_cap"))
|
|
57
|
+
resend_keyed_orders: bool = Field(default=False, description=text("settings.resend_keyed_orders"))
|
|
58
|
+
code_checks: int = Field(default=3, ge=0, le=20, description=text("settings.code_checks"))
|
|
59
|
+
code_check_wait: float = Field(default=1.0, ge=0, description=text("settings.code_check_wait"))
|
|
60
|
+
user_agent: str = Field(default="fazercards-python-sdk/{version}", description=text("settings.user_agent"))
|
|
61
|
+
allow_insecure_http: bool = Field(default=False, description=text("settings.allow_insecure_http"))
|
|
62
|
+
|
|
63
|
+
@field_validator("base_url")
|
|
64
|
+
@classmethod
|
|
65
|
+
def _base_url(cls, value: str) -> str:
|
|
66
|
+
parts = urlsplit(value.strip())
|
|
67
|
+
if parts.scheme not in ("http", "https") or not parts.hostname:
|
|
68
|
+
raise ValueError(text("error.base_url", url=value))
|
|
69
|
+
return value.strip().rstrip("/")
|
|
70
|
+
|
|
71
|
+
def check_transport(self) -> None:
|
|
72
|
+
parts = urlsplit(self.base_url)
|
|
73
|
+
if parts.scheme == "https" or self.allow_insecure_http or parts.hostname in LOCAL_HOSTS:
|
|
74
|
+
return
|
|
75
|
+
raise ConfigurationError(text("error.insecure", url=self.base_url))
|
|
76
|
+
|
|
77
|
+
def path(self, endpoint: str, **params: Any) -> str:
|
|
78
|
+
template = endpoint if endpoint.startswith("/") else getattr(self.endpoints, endpoint)
|
|
79
|
+
return str(template.format(**{k: quote(str(v), safe="") for k, v in params.items()}))
|
|
80
|
+
|
|
81
|
+
def url(self, endpoint: str, query: Optional[dict[str, Any]] = None, **params: Any) -> str:
|
|
82
|
+
address = self.base_url + self.path(endpoint, **params)
|
|
83
|
+
given = {k: v for k, v in (query or {}).items() if v is not None and v != ""}
|
|
84
|
+
return f"{address}?{urlencode(given)}" if given else address
|
|
85
|
+
|
|
86
|
+
def resolved_user_agent(self) -> str:
|
|
87
|
+
return self.user_agent.replace("{version}", __version__)
|
|
88
|
+
|
|
89
|
+
def key(self) -> str:
|
|
90
|
+
value = self.api_key.get_secret_value().strip()
|
|
91
|
+
if not value:
|
|
92
|
+
raise ConfigurationError(text("error.missing_key"))
|
|
93
|
+
return value
|
|
94
|
+
|
|
95
|
+
def scoped(self, key: str) -> str:
|
|
96
|
+
return f"{self.idempotency_prefix}:{key}" if self.idempotency_prefix else key
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
Settings.__doc__ = text("settings.description")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load_settings(config_file: Optional[str] = None, **overrides: Any) -> Settings:
|
|
103
|
+
given = {key: value for key, value in overrides.items() if value is not None}
|
|
104
|
+
return Settings(load=True, config_file=config_file, **given)
|
fazercards_sdk/enums.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class _Text(str, Enum):
|
|
8
|
+
def __str__(self) -> str:
|
|
9
|
+
return str(self.value)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OrderStatus(_Text):
|
|
13
|
+
CREATED = "created"
|
|
14
|
+
PENDING = "pending"
|
|
15
|
+
PROCESSING = "processing"
|
|
16
|
+
COMPLETED = "completed"
|
|
17
|
+
DELIVERED = "delivered"
|
|
18
|
+
SUCCESS = "success"
|
|
19
|
+
FAILED = "failed"
|
|
20
|
+
REFUND = "refund"
|
|
21
|
+
REFUNDED = "refunded"
|
|
22
|
+
CANCELLED = "cancelled"
|
|
23
|
+
CANCELED = "canceled"
|
|
24
|
+
ERROR = "error"
|
|
25
|
+
UNKNOWN = "unknown"
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def of(cls, value: Any) -> OrderStatus:
|
|
29
|
+
text = str(value or "").strip().lower()
|
|
30
|
+
try:
|
|
31
|
+
return cls(text)
|
|
32
|
+
except ValueError:
|
|
33
|
+
return cls.UNKNOWN
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def delivered(self) -> bool:
|
|
37
|
+
return self in _DELIVERED
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def failed(self) -> bool:
|
|
41
|
+
return self in _FAILED
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def in_flight(self) -> bool:
|
|
45
|
+
return self in _IN_FLIGHT
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def final(self) -> bool:
|
|
49
|
+
return self.delivered or self.failed
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_DELIVERED = frozenset({OrderStatus.COMPLETED, OrderStatus.DELIVERED, OrderStatus.SUCCESS})
|
|
53
|
+
_FAILED = frozenset(
|
|
54
|
+
{
|
|
55
|
+
OrderStatus.FAILED,
|
|
56
|
+
OrderStatus.REFUND,
|
|
57
|
+
OrderStatus.REFUNDED,
|
|
58
|
+
OrderStatus.CANCELLED,
|
|
59
|
+
OrderStatus.CANCELED,
|
|
60
|
+
OrderStatus.ERROR,
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
_IN_FLIGHT = frozenset({OrderStatus.CREATED, OrderStatus.PENDING, OrderStatus.PROCESSING})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class OrderKind(_Text):
|
|
67
|
+
TOPUP = "topup"
|
|
68
|
+
GIFT_CARD = "gift_card"
|
|
69
|
+
TELEGRAM_STARS = "telegram_stars"
|
|
70
|
+
TELEGRAM_PREMIUM = "telegram_premium"
|
|
71
|
+
STEAM_TOPUP = "steam_topup"
|
|
72
|
+
UNKNOWN = "unknown"
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def of(cls, value: Any) -> OrderKind:
|
|
76
|
+
text = str(value or "").strip().lower()
|
|
77
|
+
try:
|
|
78
|
+
return cls(text)
|
|
79
|
+
except ValueError:
|
|
80
|
+
return cls.UNKNOWN
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class SteamCurrency(_Text):
|
|
84
|
+
USD = "USD"
|
|
85
|
+
RUB = "RUB"
|
|
86
|
+
UAH = "UAH"
|
|
87
|
+
KZT = "KZT"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
PREMIUM_MONTHS = (3, 6, 12)
|
fazercards_sdk/errors.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FazerCardsError(Exception):
|
|
7
|
+
def __init__(self, message: str) -> None:
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.message = message
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfigurationError(FazerCardsError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InvalidRequestError(FazerCardsError, ValueError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SignatureError(FazerCardsError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TransportError(FazerCardsError):
|
|
25
|
+
def __init__(self, message: str, *, path: str = "", idempotency_key: Optional[str] = None) -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.path = path
|
|
28
|
+
self.idempotency_key = idempotency_key
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NotSentError(TransportError):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class OutcomeUnknownError(TransportError):
|
|
36
|
+
def __init__(self, message: str, *, status_code: Optional[int] = None, **kwargs: Any) -> None:
|
|
37
|
+
super().__init__(message, **kwargs)
|
|
38
|
+
self.status_code = status_code
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class APIError(FazerCardsError):
|
|
42
|
+
def __init__(self, message: str, *, status_code: int, body: Any = None, path: str = "") -> None:
|
|
43
|
+
super().__init__(message)
|
|
44
|
+
self.status_code = status_code
|
|
45
|
+
self.body = body
|
|
46
|
+
self.path = path
|
|
47
|
+
|
|
48
|
+
def __str__(self) -> str:
|
|
49
|
+
return f"[{self.status_code}] {self.message}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class BadRequestError(APIError):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AuthenticationError(APIError):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class NotFoundError(APIError):
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ConflictError(APIError):
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class RateLimitError(APIError):
|
|
69
|
+
def __init__(self, message: str, *, retry_after: Optional[float] = None, **kwargs: Any) -> None:
|
|
70
|
+
super().__init__(message, **kwargs)
|
|
71
|
+
self.retry_after = retry_after
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ServerError(APIError):
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class KeyCollisionError(FazerCardsError):
|
|
79
|
+
def __init__(self, message: str, *, idempotency_key: str, order: Any = None) -> None:
|
|
80
|
+
super().__init__(message)
|
|
81
|
+
self.idempotency_key = idempotency_key
|
|
82
|
+
self.order = order
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class WaitTimeoutError(FazerCardsError):
|
|
86
|
+
def __init__(self, message: str, *, order: Any = None) -> None:
|
|
87
|
+
super().__init__(message)
|
|
88
|
+
self.order = order
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def for_status(status: int) -> type[APIError]:
|
|
92
|
+
if status in (401, 403):
|
|
93
|
+
return AuthenticationError
|
|
94
|
+
if status == 404:
|
|
95
|
+
return NotFoundError
|
|
96
|
+
if status == 409:
|
|
97
|
+
return ConflictError
|
|
98
|
+
if status == 429:
|
|
99
|
+
return RateLimitError
|
|
100
|
+
if status >= 500:
|
|
101
|
+
return ServerError
|
|
102
|
+
if status >= 400:
|
|
103
|
+
return BadRequestError
|
|
104
|
+
return APIError
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from importlib import resources
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Union
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
PathLike = Union[str, Path]
|
|
11
|
+
|
|
12
|
+
_PACKAGE_FILE = "messages.yaml"
|
|
13
|
+
_catalog: dict[str, str] = {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _flatten(node: Mapping[str, Any], prefix: str = "") -> dict[str, str]:
|
|
17
|
+
flat: dict[str, str] = {}
|
|
18
|
+
for key, value in node.items():
|
|
19
|
+
path = f"{prefix}{key}"
|
|
20
|
+
if isinstance(value, Mapping):
|
|
21
|
+
flat.update(_flatten(value, f"{path}."))
|
|
22
|
+
else:
|
|
23
|
+
flat[path] = str(value)
|
|
24
|
+
return flat
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse(raw: str) -> dict[str, str]:
|
|
28
|
+
data = yaml.safe_load(raw) or {}
|
|
29
|
+
if not isinstance(data, Mapping):
|
|
30
|
+
raise ValueError("messages file must contain a mapping")
|
|
31
|
+
return _flatten(data)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _read_packaged() -> dict[str, str]:
|
|
35
|
+
raw = resources.files(__package__).joinpath(_PACKAGE_FILE).read_text(encoding="utf-8")
|
|
36
|
+
return _parse(raw)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def catalog() -> Mapping[str, str]:
|
|
40
|
+
if not _catalog:
|
|
41
|
+
_catalog.update(_read_packaged())
|
|
42
|
+
return _catalog
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def use(source: PathLike | Mapping[str, Any]) -> None:
|
|
46
|
+
overrides = _flatten(source) if isinstance(source, Mapping) else _parse(Path(source).read_text(encoding="utf-8"))
|
|
47
|
+
unknown = set(overrides) - set(_read_packaged())
|
|
48
|
+
if unknown:
|
|
49
|
+
raise KeyError(", ".join(sorted(unknown)))
|
|
50
|
+
catalog()
|
|
51
|
+
_catalog.update(overrides)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def reset() -> None:
|
|
55
|
+
_catalog.clear()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def text(key: str, **params: Any) -> str:
|
|
59
|
+
template = catalog()[key]
|
|
60
|
+
return template.format(**params) if params else template
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
settings:
|
|
2
|
+
description: Settings for the FazerCards client. Values come from code, FAZERCARDS_* environment variables or a config file.
|
|
3
|
+
api_key: Reseller API key, sent as X-API-Key
|
|
4
|
+
base_url: API address, including the version path
|
|
5
|
+
endpoints: Path of every API call, relative to base_url
|
|
6
|
+
webhook_secret: Secret used to check the X-Webhook-Signature header of webhooks
|
|
7
|
+
idempotency_prefix: Text put in front of every Idempotency-Key, to keep two shops on one account apart
|
|
8
|
+
timeout: Seconds to wait for an answer
|
|
9
|
+
connect_timeout: Seconds to wait for a connection
|
|
10
|
+
max_retries: Extra attempts for calls that are safe to repeat
|
|
11
|
+
backoff_base: First wait between retries, in seconds
|
|
12
|
+
backoff_cap: Longest wait between retries, in seconds
|
|
13
|
+
resend_keyed_orders: Resend an order once with the same Idempotency-Key after a timeout or a 5xx
|
|
14
|
+
code_checks: Extra reads of a gift card order that came back completed without codes
|
|
15
|
+
code_check_wait: Seconds between those reads
|
|
16
|
+
user_agent: User-Agent header
|
|
17
|
+
allow_insecure_http: Allow a plain http base_url outside localhost, for a test server on a private network only
|
|
18
|
+
|
|
19
|
+
value:
|
|
20
|
+
empty: "{field} cannot be empty"
|
|
21
|
+
too_long: "{field} is longer than {limit} characters"
|
|
22
|
+
range: "{field} must be a whole number from {low} to {high}, got {value!r}"
|
|
23
|
+
choice: "{field} must be one of {choices}, got {value!r}"
|
|
24
|
+
amount_type: "{field} must be a number, got {value!r}"
|
|
25
|
+
amount_positive: "{field} must be greater than zero, got {value}"
|
|
26
|
+
amount_scale: "{field} {value} has more than two decimal places"
|
|
27
|
+
amount_finite: "{field} must be a finite number, got {value}"
|
|
28
|
+
order_id: "order_id must be a FazerCards order id such as ord-123, got {value!r}"
|
|
29
|
+
fields: "fields must map names to text, got {value!r}"
|
|
30
|
+
username: "username must be a Telegram username of 4 to 32 letters, digits or _, got {value!r}"
|
|
31
|
+
url: "url must be an absolute https address, got {value!r}"
|
|
32
|
+
|
|
33
|
+
error:
|
|
34
|
+
missing_key: FazerCards api_key is required
|
|
35
|
+
missing_secret: FazerCards webhook_secret is required to check webhooks
|
|
36
|
+
base_url: "base_url must be an absolute http or https address, got {url}"
|
|
37
|
+
insecure: "Refusing to send the API key over plain http to {url}. Use https, or set allow_insecure_http for a private test server."
|
|
38
|
+
connect: "Could not reach FazerCards, nothing was sent: {error}"
|
|
39
|
+
timeout: "FazerCards did not answer: {error}"
|
|
40
|
+
unknown_outcome: "The order may or may not have been placed ({reason}). Look it up before trying again."
|
|
41
|
+
body: "FazerCards sent an answer that is not JSON: {body}"
|
|
42
|
+
status: "FazerCards answered with HTTP {status}"
|
|
43
|
+
refused: FazerCards refused the request
|
|
44
|
+
collision: "Idempotency-Key {idempotency_key} already belongs to order {order_id} for a different item"
|
|
45
|
+
signature: Webhook signature does not match
|
|
46
|
+
payload_size: "Webhook body is larger than {limit} bytes"
|
|
47
|
+
payload: Webhook body is not a JSON object
|
|
48
|
+
wait_timeout: "Order {order_id} is still {status} after {seconds} seconds"
|