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.
@@ -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)
@@ -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)
@@ -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"