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
|
@@ -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
|
+
[](https://www.python.org)
|
|
46
|
+
[](https://api.fzr.cards/api/v2)
|
|
47
|
+
[](https://mypy-lang.org)
|
|
48
|
+
[](https://www.python-httpx.org)
|
|
49
|
+
[](https://docs.astral.sh/ruff)
|
|
50
|
+
[](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,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
|
+
]
|
fazercards_sdk/_core.py
ADDED
|
@@ -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"
|
fazercards_sdk/client.py
ADDED
|
@@ -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))
|