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,169 @@
1
+ Metadata-Version: 2.5
2
+ Name: fazercards-python-sdk
3
+ Version: 0.1.1
4
+ Summary: Typed Python client for the FazerCards reseller API: game top-ups, gift cards, Telegram Stars and Premium, Steam wallet. Sync and async.
5
+ Project-URL: Homepage, https://theqiyas.cc/projects/fazercards-sdk/
6
+ Project-URL: Source, https://github.com/qiyascc/fazercards-python-sdk
7
+ Project-URL: Author, https://theqiyas.cc
8
+ Project-URL: Issues, https://github.com/qiyascc/fazercards-python-sdk/issues
9
+ Project-URL: Changelog, https://github.com/qiyascc/fazercards-python-sdk/blob/main/CHANGELOG.md
10
+ Author-email: qiyascc <qiyasturk@gmail.com>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: api,fazercards,gift cards,reseller,sdk,steam,telegram stars,top-up
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Office/Business :: Financial
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Requires-Dist: goodconf>=7.1
26
+ Requires-Dist: httpx<1,>=0.24
27
+ Requires-Dist: pydantic>=2.7
28
+ Requires-Dist: pyyaml>=6
29
+ Provides-Extra: dev
30
+ Requires-Dist: goodconf[toml]>=7.1; extra == 'dev'
31
+ Requires-Dist: goodconf[yaml]>=7.1; extra == 'dev'
32
+ Requires-Dist: mypy>=1.8; extra == 'dev'
33
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
34
+ Requires-Dist: pytest>=7; extra == 'dev'
35
+ Requires-Dist: ruff>=0.4; extra == 'dev'
36
+ Requires-Dist: types-pyyaml; extra == 'dev'
37
+ Provides-Extra: toml
38
+ Requires-Dist: goodconf[toml]>=7.1; extra == 'toml'
39
+ Provides-Extra: yaml
40
+ Requires-Dist: goodconf[yaml]>=7.1; extra == 'yaml'
41
+ Description-Content-Type: text/markdown
42
+
43
+ # fazercards-python-sdk
44
+
45
+ [![Python](https://img.shields.io/badge/python-3.10%20to%203.13-3776AB?logo=python&logoColor=white)](https://www.python.org)
46
+ [![FazerCards](https://img.shields.io/badge/api-FazerCards%20v2-7C3AED)](https://api.fzr.cards/api/v2)
47
+ [![Typed](https://img.shields.io/badge/typing-mypy-2A6DB2)](https://mypy-lang.org)
48
+ [![Async](https://img.shields.io/badge/sync%20%2B%20async-httpx-5A45FF)](https://www.python-httpx.org)
49
+ [![Ruff](https://img.shields.io/badge/lint-ruff-D7FF64?logo=ruff&logoColor=black)](https://docs.astral.sh/ruff)
50
+ [![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/qiyascc/fazercards-python-sdk/blob/main/LICENSE)
51
+
52
+ Typed Python client for the FazerCards reseller API. Game top-ups, gift cards, Telegram Stars and Premium, Steam wallet, orders and signed webhooks. Sync and async, with retry rules built so an order is never placed twice.
53
+
54
+ ```bash
55
+ pip install fazercards-python-sdk
56
+ ```
57
+
58
+ ## Top up a game
59
+
60
+ ```python
61
+ from fazercards_sdk import FazerCards
62
+
63
+ with FazerCards("API_KEY") as fc:
64
+ check = fc.topups.validate("free_fire", "123456789")
65
+ if check.valid:
66
+ order = fc.topups.order("free_fire_cis", "110_diamonds", "123456789", idempotency_key="ORDER-1042")
67
+ ```
68
+
69
+ `order.status` moves from `created` to `processing` to `completed`, or to `failed` / `refund` with `order.fail_reason`. Follow it by webhook, or with `fc.orders.wait(order.id)`.
70
+
71
+ ## Sell a gift card
72
+
73
+ ```python
74
+ order = fc.giftcards.order("playstation_tr", "250_try", quantity=1, idempotency_key="ORDER-1043")
75
+ order.codes
76
+ ```
77
+
78
+ FazerCards can answer a gift card order with `completed` and an empty `cards` list, then fill the list a moment later. The client reads the order again before it returns (`code_checks`, default 3). If the codes still have not arrived, `order.awaiting_codes` is `True`: keep the order open and read it later, never show the customer an empty result.
79
+
80
+ ## Telegram and Steam
81
+
82
+ ```python
83
+ fc.telegram.stars_quote().price(500)
84
+ fc.telegram.buy_stars("@username", 500)
85
+ fc.telegram.buy_premium("username", months=12)
86
+
87
+ if fc.steam.check_login("steam_login").valid:
88
+ fc.steam.order("steam_login", "19.50", "USD", idempotency_key="ORDER-1044")
89
+ ```
90
+
91
+ ## Catalog
92
+
93
+ ```python
94
+ games = fc.topups.all_categories()
95
+ cards = fc.giftcards.all_categories()
96
+ offers = fc.topups.offers("free_fire_cis")
97
+ offers.get("110_diamonds").price
98
+ offers.fields
99
+ fc.giftcards.cards("playstation_tr").offers[0].stock
100
+ ```
101
+
102
+ Categories come in cursor pages of up to 500 (`fc.topups.categories(limit, cursor)`); `all_categories()` follows the cursor. Orders come in numbered pages of up to 100 (`fc.orders.list(page, limit)`, `fc.orders.recent(count)`).
103
+
104
+ ## Webhooks
105
+
106
+ ```python
107
+ event = fc.verify_webhook(request.body, headers=request.headers)
108
+ if event.needs_lookup:
109
+ order = fc.orders.get(event.order_id)
110
+ ```
111
+
112
+ The body is checked against `X-Webhook-Signature` (HMAC SHA256 of the raw body, `sha256=` prefix optional) in constant time. Set the address with `fc.account.set_webhook("https://shop.example/webhook/fazercards")`. See [examples/fastapi_webhook.py](https://github.com/qiyascc/fazercards-python-sdk/blob/main/examples/fastapi_webhook.py).
113
+
114
+ ## Safety
115
+
116
+ | Situation | What the client does |
117
+ | --- | --- |
118
+ | Connection refused, nothing sent | Retries, then `NotSentError` |
119
+ | Timeout, cut connection or 5xx on an order | No retry, `OutcomeUnknownError` with the `idempotency_key` |
120
+ | Same failure on a read | Retries with backoff, then `TransportError` |
121
+ | 429 | Waits `Retry-After` and retries, the request was not processed |
122
+ | `resend_keyed_orders=True` | An order with an Idempotency-Key is resent once with the same key |
123
+ | Telegram purchase | Takes no key on the API side, so it is never resent |
124
+ | Reused key that returns another item | `KeyCollisionError`, never counted as success |
125
+ | Gift card `completed` without codes | Read again, then `order.awaiting_codes` |
126
+ | Quantity, months, amount like `1.005` or `True` | `InvalidRequestError` before anything is sent |
127
+ | `base_url` over plain http | `ConfigurationError`, except localhost or `allow_insecure_http` |
128
+ | Webhook body over 256 KB | Rejected before hashing |
129
+
130
+ Every top-up, gift card and Steam order carries an `Idempotency-Key`. Pass your own order number, so a double click or a crash can not buy twice. With several shops on one account set `idempotency_prefix`. Prices are `Decimal` end to end. `fc.rate_limit` keeps the last `X-RateLimit-*` headers.
131
+
132
+ ## Endpoints
133
+
134
+ | Namespace | Methods |
135
+ | --- | --- |
136
+ | `topups` | `categories` `all_categories` `offers` `validation_games` `validate` `order` |
137
+ | `giftcards` | `categories` `all_categories` `cards` `order` |
138
+ | `orders` | `get` `list` `recent` `wait` |
139
+ | `telegram` | `stars_quote` `premium_quote` `buy_stars` `buy_premium` |
140
+ | `steam` | `rates` `check_login` `order` |
141
+ | `account` | `me` `balance` `webhook` `set_webhook` |
142
+ | client | `balance` `verify_webhook` `request` |
143
+
144
+ `AsyncFazerCards` has the same methods, awaited.
145
+
146
+ ## Errors
147
+
148
+ `FazerCardsError` is the base. API answers raise `APIError` subclasses by status: `BadRequestError` (400), `AuthenticationError` (401, 403), `NotFoundError` (404), `ConflictError` (409), `RateLimitError` (429), `ServerError` (5xx). Each carries `status_code`, `message` and the raw `body`.
149
+
150
+ ## Config
151
+
152
+ Pass values in code, or set `FAZERCARDS_API_KEY`, `FAZERCARDS_WEBHOOK_SECRET`, `FAZERCARDS_BASE_URL`, `FAZERCARDS_IDEMPOTENCY_PREFIX`, `FAZERCARDS_TIMEOUT`, `FAZERCARDS_MAX_RETRIES`, or put them in `fazercards.toml` ([example](https://github.com/qiyascc/fazercards-python-sdk/blob/main/examples/fazercards.toml)). Every path is a setting (`FAZERCARDS_ENDPOINTS__BALANCE=...`). Messages live in `messages.yaml` and can be replaced with `messages.use(...)`.
153
+
154
+ ## Develop
155
+
156
+ ```bash
157
+ pip install -e ".[dev]" && pytest && ruff check . && mypy src
158
+ ```
159
+
160
+ ## Links
161
+
162
+ - Source code: [github.com/qiyascc/fazercards-python-sdk](https://github.com/qiyascc/fazercards-python-sdk)
163
+ - PyPI: [pypi.org/project/fazercards-python-sdk](https://pypi.org/project/fazercards-python-sdk/)
164
+ - Project page: [theqiyas.cc/projects/fazercards-sdk](https://theqiyas.cc/projects/fazercards-sdk/)
165
+ - Author: [Qiyas Sadıqov](https://theqiyas.cc), more projects at [theqiyas.cc](https://theqiyas.cc)
166
+
167
+ ## License
168
+
169
+ MIT
@@ -0,0 +1,18 @@
1
+ fazercards_sdk/__init__.py,sha256=AysBsfdOd4jDhLYXs1tQjdyPARFrui8PZKgFsDHVOi0,2052
2
+ fazercards_sdk/_core.py,sha256=Y0tnk8n-5tCOcRjgSXkSaj2m8C6qe2lMqmY27fWrMTE,11875
3
+ fazercards_sdk/_version.py,sha256=rnObPjuBcEStqSO0S6gsdS_ot8ITOQjVj_-P1LUUYpg,22
4
+ fazercards_sdk/client.py,sha256=GyEvSad-98dJfQp3VLLSDd7DOp4vO5cCZhoBIfhX6t8,2357
5
+ fazercards_sdk/config.py,sha256=MiwX819rKAyCasBuK0EDpqgzdnDdYlWOFtvXqi2Wko0,4836
6
+ fazercards_sdk/enums.py,sha256=dvkzzKWyCMmHTQcfJruUjj9wU0jzJnq_IlO5EHNukfE,1997
7
+ fazercards_sdk/errors.py,sha256=NDW807_MKJxpOC6R-9uYrlKxNENJztZJ8QwhWJH73Ms,2493
8
+ fazercards_sdk/messages.py,sha256=GqqTi5PvVlt2G-F6llTb3_gMTlRgVj4DHMUXZQzr7ZY,1601
9
+ fazercards_sdk/messages.yaml,sha256=5rcfGJE22gQytpqEk15umXvXjGRIC7uOAmEWwSm7i5U,2970
10
+ fazercards_sdk/models.py,sha256=PIt4-ToSq4dazC79LCcgPf4vnUZMoSA0qSmzFfLHmRE,17466
11
+ fazercards_sdk/money.py,sha256=MVwXYsrAvR1Hfyd44ePf-pXyNonaNY9n6smuF9dVFFw,1431
12
+ fazercards_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ fazercards_sdk/resources.py,sha256=vgzF_2wNi-W9c3t5V9p95aggapvZV9wSeqLVCYyW7Lw,12361
14
+ fazercards_sdk/webhooks.py,sha256=xuGA4sepDpbvOwvon4Ofi60TIsTgCyPJxLymD8zsN6U,2003
15
+ fazercards_python_sdk-0.1.1.dist-info/METADATA,sha256=naMxzWFEwEaios1wfbECAziEd19dMNbijpGv5VYjiek,8130
16
+ fazercards_python_sdk-0.1.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
17
+ fazercards_python_sdk-0.1.1.dist-info/licenses/LICENSE,sha256=ENVL-_Eh4n1SumZuLuTjO2K0OOL_eXh4pkeme35b02I,1064
18
+ fazercards_python_sdk-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 qiyascc
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,102 @@
1
+ from . import messages, webhooks
2
+ from ._version import __version__
3
+ from .client import AsyncFazerCards, FazerCards
4
+ from .config import Endpoints, Settings, load_settings
5
+ from .enums import PREMIUM_MONTHS, OrderKind, OrderStatus, SteamCurrency
6
+ from .errors import (
7
+ APIError,
8
+ AuthenticationError,
9
+ BadRequestError,
10
+ ConfigurationError,
11
+ ConflictError,
12
+ FazerCardsError,
13
+ InvalidRequestError,
14
+ KeyCollisionError,
15
+ NotFoundError,
16
+ NotSentError,
17
+ OutcomeUnknownError,
18
+ RateLimitError,
19
+ ServerError,
20
+ SignatureError,
21
+ TransportError,
22
+ WaitTimeoutError,
23
+ )
24
+ from .models import (
25
+ Account,
26
+ Balance,
27
+ Category,
28
+ CategoryPage,
29
+ InputField,
30
+ Offer,
31
+ Offers,
32
+ Order,
33
+ OrderPage,
34
+ PlayerCheck,
35
+ PremiumPlan,
36
+ PremiumQuote,
37
+ RateLimit,
38
+ StarsQuote,
39
+ StatusChange,
40
+ SteamLogin,
41
+ SteamRates,
42
+ ValidationGame,
43
+ Webhook,
44
+ WebhookEvent,
45
+ card_code,
46
+ )
47
+ from .webhooks import SIGNATURE_HEADER, parse_webhook, verify_webhook
48
+
49
+ __all__ = [
50
+ "__version__",
51
+ "FazerCards",
52
+ "AsyncFazerCards",
53
+ "Settings",
54
+ "Endpoints",
55
+ "load_settings",
56
+ "messages",
57
+ "webhooks",
58
+ "verify_webhook",
59
+ "parse_webhook",
60
+ "SIGNATURE_HEADER",
61
+ "OrderStatus",
62
+ "OrderKind",
63
+ "SteamCurrency",
64
+ "PREMIUM_MONTHS",
65
+ "Account",
66
+ "Balance",
67
+ "Category",
68
+ "CategoryPage",
69
+ "InputField",
70
+ "Offer",
71
+ "Offers",
72
+ "Order",
73
+ "OrderPage",
74
+ "PlayerCheck",
75
+ "PremiumPlan",
76
+ "PremiumQuote",
77
+ "RateLimit",
78
+ "StarsQuote",
79
+ "StatusChange",
80
+ "SteamLogin",
81
+ "SteamRates",
82
+ "ValidationGame",
83
+ "Webhook",
84
+ "WebhookEvent",
85
+ "card_code",
86
+ "FazerCardsError",
87
+ "ConfigurationError",
88
+ "InvalidRequestError",
89
+ "SignatureError",
90
+ "TransportError",
91
+ "NotSentError",
92
+ "OutcomeUnknownError",
93
+ "APIError",
94
+ "BadRequestError",
95
+ "AuthenticationError",
96
+ "NotFoundError",
97
+ "ConflictError",
98
+ "RateLimitError",
99
+ "ServerError",
100
+ "KeyCollisionError",
101
+ "WaitTimeoutError",
102
+ ]
@@ -0,0 +1,308 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import random
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Callable, Generator, Mapping, Optional, TypeVar
8
+
9
+ import httpx
10
+
11
+ from .config import Settings, load_settings
12
+ from .errors import (
13
+ APIError,
14
+ KeyCollisionError,
15
+ NotSentError,
16
+ OutcomeUnknownError,
17
+ RateLimitError,
18
+ TransportError,
19
+ for_status,
20
+ )
21
+ from .messages import text
22
+ from .models import RateLimit
23
+
24
+ T = TypeVar("T")
25
+
26
+ NOT_SENT = (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout, httpx.UnsupportedProtocol)
27
+ RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
28
+
29
+
30
+ @dataclass
31
+ class Op:
32
+ method: str
33
+ endpoint: str
34
+ parse: Callable[[Any], Any] = lambda body: body
35
+ query: Optional[dict[str, Any]] = None
36
+ body: Optional[dict[str, Any]] = None
37
+ path_params: Mapping[str, Any] = field(default_factory=dict)
38
+ safe: bool = False
39
+ key: Optional[str] = None
40
+ echo: Mapping[str, Any] = field(default_factory=dict)
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Pause:
45
+ seconds: float
46
+
47
+
48
+ Step = Any
49
+ Flow = Generator[Step, Any, T]
50
+
51
+
52
+ class Outcome:
53
+ def __init__(self, value: Any = None, error: Optional[Exception] = None, wait: Optional[float] = None) -> None:
54
+ self.value = value
55
+ self.error = error
56
+ self.wait = wait
57
+
58
+
59
+ def _number(value: Optional[str]) -> Optional[float]:
60
+ try:
61
+ return float(value) if value not in (None, "") else None
62
+ except (TypeError, ValueError):
63
+ return None
64
+
65
+
66
+ def _echo_mismatch(body: Any, expected: Mapping[str, Any]) -> Optional[str]:
67
+ order = body.get("order", body) if isinstance(body, Mapping) else None
68
+ if not isinstance(order, Mapping):
69
+ return None
70
+ given = order.get("fields")
71
+ fields: Mapping[str, Any] = given if isinstance(given, Mapping) else {}
72
+ actual = {
73
+ "category_id": order.get("category_id"),
74
+ "offer_id": order.get("offer_id") or order.get("card_id"),
75
+ "player_id": fields.get("player_id"),
76
+ }
77
+ for name, value in expected.items():
78
+ seen = actual.get(name)
79
+ if value not in (None, "") and seen is not None and str(seen) != str(value):
80
+ return str(order.get("id") or order.get("order_id") or "?")
81
+ return None
82
+
83
+
84
+ class Engine:
85
+ def __init__(
86
+ self,
87
+ api_key: Optional[str] = None,
88
+ *,
89
+ settings: Optional[Settings] = None,
90
+ config_file: Optional[str] = None,
91
+ **overrides: Any,
92
+ ) -> None:
93
+ if settings is None:
94
+ settings = load_settings(config_file, api_key=api_key, **overrides)
95
+ elif api_key or overrides:
96
+ current = settings.model_dump()
97
+ current["api_key"] = settings.api_key.get_secret_value()
98
+ current["webhook_secret"] = settings.webhook_secret.get_secret_value()
99
+ changes = {k: v for k, v in dict(overrides, api_key=api_key).items() if v is not None}
100
+ settings = load_settings(None, **{**current, **changes})
101
+ self.settings = settings
102
+ settings.check_transport()
103
+ self._api_key = settings.key()
104
+ self.rate_limit: Optional[RateLimit] = None
105
+
106
+ def __repr__(self) -> str:
107
+ return f"{type(self).__name__}(base_url={self.settings.base_url!r})"
108
+
109
+ def _timeout(self) -> httpx.Timeout:
110
+ return httpx.Timeout(self.settings.timeout, connect=self.settings.connect_timeout)
111
+
112
+ def _headers(self, op: Op) -> dict[str, str]:
113
+ headers = {
114
+ "X-API-Key": self._api_key,
115
+ "Accept": "application/json",
116
+ "User-Agent": self.settings.resolved_user_agent(),
117
+ }
118
+ if op.key:
119
+ headers["Idempotency-Key"] = op.key
120
+ return headers
121
+
122
+ def _request_args(self, op: Op) -> dict[str, Any]:
123
+ args: dict[str, Any] = {"headers": self._headers(op)}
124
+ if op.method != "GET":
125
+ args["json"] = op.body or {}
126
+ return args
127
+
128
+ def _backoff(self, attempt: int) -> float:
129
+ base = self.settings.backoff_base
130
+ return min(self.settings.backoff_cap, base * (2**attempt)) + random.uniform(0, base / 2)
131
+
132
+ def _resend(self, op: Op, attempt: int) -> bool:
133
+ return bool(op.key) and self.settings.resend_keyed_orders and attempt == 0
134
+
135
+ def _remember_limits(self, response: httpx.Response) -> None:
136
+ headers = response.headers
137
+ if "x-ratelimit-limit" not in headers:
138
+ return
139
+ limit, remaining = _number(headers.get("x-ratelimit-limit")), _number(headers.get("x-ratelimit-remaining"))
140
+ self.rate_limit = RateLimit(
141
+ int(limit) if limit is not None else None,
142
+ int(remaining) if remaining is not None else None,
143
+ _number(headers.get("x-ratelimit-reset")),
144
+ )
145
+
146
+ def _on_exception(self, op: Op, exc: Exception, attempt: int) -> Outcome:
147
+ path = self.settings.path(op.endpoint, **op.path_params)
148
+ can_retry = attempt < self.settings.max_retries
149
+ if isinstance(exc, NOT_SENT):
150
+ if can_retry:
151
+ return Outcome(wait=self._backoff(attempt))
152
+ return Outcome(error=NotSentError(text("error.connect", error=exc), path=path, idempotency_key=op.key))
153
+ if op.safe:
154
+ if can_retry:
155
+ return Outcome(wait=self._backoff(attempt))
156
+ return Outcome(error=TransportError(text("error.timeout", error=exc), path=path, idempotency_key=op.key))
157
+ if self._resend(op, attempt):
158
+ return Outcome(wait=self._backoff(attempt))
159
+ return Outcome(error=OutcomeUnknownError(text("error.unknown_outcome", reason=type(exc).__name__), path=path,
160
+ idempotency_key=op.key))
161
+
162
+ def _on_response(self, op: Op, response: httpx.Response, attempt: int) -> Outcome:
163
+ self._remember_limits(response)
164
+ path = self.settings.path(op.endpoint, **op.path_params)
165
+ status = response.status_code
166
+ can_retry = attempt < self.settings.max_retries
167
+ if status == 429 and can_retry:
168
+ hint = _number(response.headers.get("retry-after")) or _number(response.headers.get("x-ratelimit-reset"))
169
+ return Outcome(wait=min(self.settings.backoff_cap, hint) if hint is not None else self._backoff(attempt))
170
+ if status in RETRY_STATUSES and status != 429:
171
+ if op.safe and can_retry:
172
+ return Outcome(wait=self._backoff(attempt))
173
+ if not op.safe:
174
+ if self._resend(op, attempt):
175
+ return Outcome(wait=self._backoff(attempt))
176
+ return Outcome(error=OutcomeUnknownError(text("error.unknown_outcome", reason=f"HTTP {status}"),
177
+ status_code=status, path=path, idempotency_key=op.key))
178
+ try:
179
+ body = response.json()
180
+ except ValueError:
181
+ snippet = response.text[:200]
182
+ if status < 400 and not op.safe:
183
+ return Outcome(error=OutcomeUnknownError(text("error.body", body=snippet), status_code=status,
184
+ path=path, idempotency_key=op.key))
185
+ return Outcome(error=for_status(status)(text("error.body", body=snippet), status_code=status,
186
+ body=snippet, path=path))
187
+ refused = isinstance(body, Mapping) and body.get("ok") is False
188
+ if status >= 400 or refused:
189
+ message = (body.get("message") or body.get("error")) if isinstance(body, Mapping) else None
190
+ kind = for_status(status) if status >= 400 else APIError
191
+ error_args: dict[str, Any] = {"status_code": status, "body": body, "path": path}
192
+ if kind is RateLimitError:
193
+ error_args["retry_after"] = _number(response.headers.get("retry-after"))
194
+ return Outcome(error=kind(str(message or text("error.refused")), **error_args))
195
+ if op.key and op.echo:
196
+ other = _echo_mismatch(body, op.echo)
197
+ if other is not None:
198
+ return Outcome(error=KeyCollisionError(text("error.collision", idempotency_key=op.key, order_id=other),
199
+ idempotency_key=op.key, order=body))
200
+ return Outcome(value=op.parse(body))
201
+
202
+
203
+ class SyncEngine(Engine):
204
+ def __init__(self, *args: Any, http_client: Optional[httpx.Client] = None, **kwargs: Any) -> None:
205
+ super().__init__(*args, **kwargs)
206
+ self._owns_http = http_client is None
207
+ self._http = http_client or httpx.Client(timeout=self._timeout())
208
+
209
+ def close(self) -> None:
210
+ if self._owns_http:
211
+ self._http.close()
212
+
213
+ def __enter__(self: T) -> T:
214
+ return self
215
+
216
+ def __exit__(self, *exc: Any) -> None:
217
+ self.close()
218
+
219
+ def _sleep(self, seconds: float) -> None:
220
+ if seconds > 0:
221
+ time.sleep(seconds)
222
+
223
+ def _run(self, op: Op) -> Any:
224
+ url = self.settings.url(op.endpoint, op.query, **op.path_params)
225
+ args = self._request_args(op)
226
+ attempt = 0
227
+ while True:
228
+ try:
229
+ response = self._http.request(op.method, url, **args)
230
+ except httpx.HTTPError as exc:
231
+ outcome = self._on_exception(op, exc, attempt)
232
+ else:
233
+ outcome = self._on_response(op, response, attempt)
234
+ if outcome.wait is not None:
235
+ self._sleep(outcome.wait)
236
+ attempt += 1
237
+ continue
238
+ if outcome.error is not None:
239
+ raise outcome.error
240
+ return outcome.value
241
+
242
+ def _flow(self, flow: Flow[T]) -> T:
243
+ reply: Any = None
244
+ while True:
245
+ try:
246
+ step = flow.send(reply)
247
+ except StopIteration as done:
248
+ result: T = done.value
249
+ return result
250
+ if isinstance(step, Pause):
251
+ self._sleep(step.seconds)
252
+ reply = None
253
+ else:
254
+ reply = self._run(step)
255
+
256
+
257
+ class AsyncEngine(Engine):
258
+ def __init__(self, *args: Any, http_client: Optional[httpx.AsyncClient] = None, **kwargs: Any) -> None:
259
+ super().__init__(*args, **kwargs)
260
+ self._owns_http = http_client is None
261
+ self._http = http_client or httpx.AsyncClient(timeout=self._timeout())
262
+
263
+ async def close(self) -> None:
264
+ if self._owns_http:
265
+ await self._http.aclose()
266
+
267
+ async def __aenter__(self: T) -> T:
268
+ return self
269
+
270
+ async def __aexit__(self, *exc: Any) -> None:
271
+ await self.close()
272
+
273
+ async def _sleep(self, seconds: float) -> None:
274
+ if seconds > 0:
275
+ await asyncio.sleep(seconds)
276
+
277
+ async def _run(self, op: Op) -> Any:
278
+ url = self.settings.url(op.endpoint, op.query, **op.path_params)
279
+ args = self._request_args(op)
280
+ attempt = 0
281
+ while True:
282
+ try:
283
+ response = await self._http.request(op.method, url, **args)
284
+ except httpx.HTTPError as exc:
285
+ outcome = self._on_exception(op, exc, attempt)
286
+ else:
287
+ outcome = self._on_response(op, response, attempt)
288
+ if outcome.wait is not None:
289
+ await self._sleep(outcome.wait)
290
+ attempt += 1
291
+ continue
292
+ if outcome.error is not None:
293
+ raise outcome.error
294
+ return outcome.value
295
+
296
+ async def _flow(self, flow: Flow[T]) -> T:
297
+ reply: Any = None
298
+ while True:
299
+ try:
300
+ step = flow.send(reply)
301
+ except StopIteration as done:
302
+ result: T = done.value
303
+ return result
304
+ if isinstance(step, Pause):
305
+ await self._sleep(step.seconds)
306
+ reply = None
307
+ else:
308
+ reply = await self._run(step)
@@ -0,0 +1 @@
1
+ __version__ = "0.1.1"
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Mapping, Optional
4
+
5
+ from ._core import AsyncEngine, Op, SyncEngine
6
+ from .models import WebhookEvent
7
+ from .resources import AccountResource, GiftCards, Orders, Steam, Telegram, Topups
8
+ from .webhooks import Payload, header, verify_webhook
9
+
10
+
11
+ class _Namespaces:
12
+ settings: Any
13
+
14
+ def _mount(self) -> None:
15
+ self.account = AccountResource(self)
16
+ self.topups = Topups(self)
17
+ self.giftcards = GiftCards(self)
18
+ self.orders = Orders(self)
19
+ self.telegram = Telegram(self)
20
+ self.steam = Steam(self)
21
+
22
+ def verify_webhook(self, payload: Payload, signature: str = "", *,
23
+ headers: Optional[Mapping[str, Any]] = None) -> WebhookEvent:
24
+ given = signature or (header(headers) if headers is not None else "")
25
+ return verify_webhook(self.settings.webhook_secret.get_secret_value(), payload, given)
26
+
27
+ @staticmethod
28
+ def _raw(method: str, path: str, body: Optional[Mapping[str, Any]], safe: Optional[bool],
29
+ idempotency_key: Optional[str]) -> Op:
30
+ verb = method.upper()
31
+ return Op(verb, path if path.startswith("/") else f"/{path}", body=dict(body) if body is not None else None,
32
+ safe=verb == "GET" if safe is None else safe, key=idempotency_key)
33
+
34
+
35
+ class FazerCards(SyncEngine, _Namespaces):
36
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
37
+ super().__init__(*args, **kwargs)
38
+ self._mount()
39
+
40
+ def balance(self) -> Any:
41
+ return self.account.balance()
42
+
43
+ def request(self, method: str, path: str, body: Optional[Mapping[str, Any]] = None, *,
44
+ safe: Optional[bool] = None, idempotency_key: Optional[str] = None) -> Any:
45
+ return self._run(self._raw(method, path, body, safe, idempotency_key))
46
+
47
+
48
+ class AsyncFazerCards(AsyncEngine, _Namespaces):
49
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
50
+ super().__init__(*args, **kwargs)
51
+ self._mount()
52
+
53
+ async def balance(self) -> Any:
54
+ return await self.account.balance()
55
+
56
+ async def request(self, method: str, path: str, body: Optional[Mapping[str, Any]] = None, *,
57
+ safe: Optional[bool] = None, idempotency_key: Optional[str] = None) -> Any:
58
+ return await self._run(self._raw(method, path, body, safe, idempotency_key))