midasbuy-sdk 0.1.0__tar.gz
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.
- midasbuy_sdk-0.1.0/.github/workflows/ci.yml +38 -0
- midasbuy_sdk-0.1.0/.github/workflows/publish.yml +73 -0
- midasbuy_sdk-0.1.0/.gitignore +9 -0
- midasbuy_sdk-0.1.0/LICENSE +21 -0
- midasbuy_sdk-0.1.0/PKG-INFO +124 -0
- midasbuy_sdk-0.1.0/README.md +105 -0
- midasbuy_sdk-0.1.0/pyproject.toml +39 -0
- midasbuy_sdk-0.1.0/src/midasbuy_sdk/__init__.py +57 -0
- midasbuy_sdk-0.1.0/src/midasbuy_sdk/client.py +364 -0
- midasbuy_sdk-0.1.0/src/midasbuy_sdk/errors.py +110 -0
- midasbuy_sdk-0.1.0/src/midasbuy_sdk/models.py +134 -0
- midasbuy_sdk-0.1.0/tests/test_client.py +178 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
name: ci
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [master, main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
checks:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
strategy:
|
|
15
|
+
fail-fast: false
|
|
16
|
+
matrix:
|
|
17
|
+
python-version: ["3.11", "3.12"]
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
|
|
21
|
+
- uses: actions/setup-python@v5
|
|
22
|
+
with:
|
|
23
|
+
python-version: ${{ matrix.python-version }}
|
|
24
|
+
cache: pip
|
|
25
|
+
|
|
26
|
+
- name: Install
|
|
27
|
+
run: |
|
|
28
|
+
python -m pip install --upgrade pip
|
|
29
|
+
pip install -e ".[dev]"
|
|
30
|
+
|
|
31
|
+
- name: Lint
|
|
32
|
+
run: ruff check .
|
|
33
|
+
|
|
34
|
+
- name: Types
|
|
35
|
+
run: mypy
|
|
36
|
+
|
|
37
|
+
- name: Tests
|
|
38
|
+
run: pytest -q
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Publish to PyPI on a version tag, via a Trusted Publisher (OIDC).
|
|
2
|
+
#
|
|
3
|
+
# No API token lives in this repository. PyPI is configured once, by hand, to
|
|
4
|
+
# trust THIS repo + THIS workflow file + the `pypi` environment; the job then
|
|
5
|
+
# exchanges a short-lived OIDC token for upload rights. A leaked repo secret
|
|
6
|
+
# cannot publish, because there is no secret to leak.
|
|
7
|
+
#
|
|
8
|
+
# One-time setup on PyPI (Project → Publishing → Add a new pending publisher):
|
|
9
|
+
# owner: zlexdev · repo: midasbuy-sdk · workflow: publish.yml · environment: pypi
|
|
10
|
+
#
|
|
11
|
+
# Cutting a release:
|
|
12
|
+
# 1. bump `version` in pyproject.toml AND `__version__` in __init__.py
|
|
13
|
+
# 2. git tag v0.1.0 && git push origin v0.1.0
|
|
14
|
+
name: publish
|
|
15
|
+
|
|
16
|
+
on:
|
|
17
|
+
push:
|
|
18
|
+
tags: ["v*"]
|
|
19
|
+
|
|
20
|
+
permissions:
|
|
21
|
+
contents: read
|
|
22
|
+
|
|
23
|
+
jobs:
|
|
24
|
+
build:
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@v4
|
|
28
|
+
|
|
29
|
+
- uses: actions/setup-python@v5
|
|
30
|
+
with:
|
|
31
|
+
python-version: "3.12"
|
|
32
|
+
|
|
33
|
+
- name: Install
|
|
34
|
+
run: |
|
|
35
|
+
python -m pip install --upgrade pip
|
|
36
|
+
pip install -e ".[dev]" build
|
|
37
|
+
|
|
38
|
+
# The gate: a broken release is worse than a late one.
|
|
39
|
+
- name: Checks
|
|
40
|
+
run: |
|
|
41
|
+
ruff check .
|
|
42
|
+
mypy
|
|
43
|
+
pytest -q
|
|
44
|
+
|
|
45
|
+
# The tag IS the version — a mismatch means the wheel on PyPI would not
|
|
46
|
+
# match the source at that tag, and that is unfixable after upload.
|
|
47
|
+
- name: Tag matches the packaged version
|
|
48
|
+
run: |
|
|
49
|
+
tag="${GITHUB_REF_NAME#v}"
|
|
50
|
+
pkg="$(python -c 'import tomllib,pathlib;print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')"
|
|
51
|
+
test "$tag" = "$pkg" || { echo "tag $tag != version $pkg"; exit 1; }
|
|
52
|
+
|
|
53
|
+
- name: Build
|
|
54
|
+
run: python -m build
|
|
55
|
+
|
|
56
|
+
- uses: actions/upload-artifact@v4
|
|
57
|
+
with:
|
|
58
|
+
name: dist
|
|
59
|
+
path: dist/
|
|
60
|
+
|
|
61
|
+
publish:
|
|
62
|
+
needs: build
|
|
63
|
+
runs-on: ubuntu-latest
|
|
64
|
+
environment: pypi
|
|
65
|
+
permissions:
|
|
66
|
+
id-token: write # the OIDC token exchanged for upload rights
|
|
67
|
+
steps:
|
|
68
|
+
- uses: actions/download-artifact@v4
|
|
69
|
+
with:
|
|
70
|
+
name: dist
|
|
71
|
+
path: dist/
|
|
72
|
+
|
|
73
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 zlexdev
|
|
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,124 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: midasbuy-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Client for the Midasbuy code-activation API — free tier included
|
|
5
|
+
Project-URL: Homepage, https://github.com/zlexdev/midasbuy-sdk
|
|
6
|
+
Project-URL: Issues, https://github.com/zlexdev/midasbuy-sdk/issues
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: activation,api,midasbuy,pubg,sdk,uc
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Requires-Dist: httpx>=0.27
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
16
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
17
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# midasbuy-sdk
|
|
21
|
+
|
|
22
|
+
Python-клиент для API активации кодов Midasbuy.
|
|
23
|
+
|
|
24
|
+
Есть **бесплатный тариф**: ключ выдаётся без регистрации и без оплаты. Это
|
|
25
|
+
**бета** — лимиты временные и будут пересмотрены по её итогам.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install midasbuy-sdk
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Как получить ключ
|
|
32
|
+
|
|
33
|
+
Напишите Telegram-боту `@midasbuy_api_bot` команду `/free`.
|
|
34
|
+
|
|
35
|
+
К заявке приложите ссылки на свои профили на площадках, где вы торгуете, и
|
|
36
|
+
скриншоты-подтверждения. Это единственный барьер: он стоит не ради формальности,
|
|
37
|
+
а чтобы бесплатные ключи не разошлись пачками по одноразовым аккаунтам.
|
|
38
|
+
|
|
39
|
+
Один бесплатный ключ в одни руки. Повторная заявка вернёт тот же ключ.
|
|
40
|
+
|
|
41
|
+
## Первый вызов
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from midasbuy_sdk import MidasbuyClient
|
|
45
|
+
|
|
46
|
+
with MidasbuyClient("ваш-ключ") as client:
|
|
47
|
+
# 1. подключите свой Midas-аккаунт — активировать нужно на него
|
|
48
|
+
account = client.connect_account(cookies="...", game="pubgm")
|
|
49
|
+
|
|
50
|
+
# 2. активируйте код
|
|
51
|
+
accepted = client.activate("CODE-1234", account_id=account.account_id)
|
|
52
|
+
|
|
53
|
+
# 3. дождитесь результата
|
|
54
|
+
result = client.wait_for(accepted.activation_id)
|
|
55
|
+
print(result.status, result.granted_item)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Асинхронно — те же имена методов:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from midasbuy_sdk import AsyncMidasbuyClient
|
|
62
|
+
|
|
63
|
+
async with AsyncMidasbuyClient("ваш-ключ") as client:
|
|
64
|
+
accepted = await client.activate("CODE-1234", account_id="acc_...")
|
|
65
|
+
result = await client.wait_for(accepted.activation_id)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Что делает клиент за вас
|
|
69
|
+
|
|
70
|
+
**Ключ идемпотентности.** Каждый POST уходит с `Idempotency-Key`, и повтор
|
|
71
|
+
использует **тот же** ключ. Поэтому таймаут или 429 не превращают одну
|
|
72
|
+
активацию в две. Свой ключ можно передать явно — тогда и ваш собственный ретрай
|
|
73
|
+
схлопнется в одну операцию:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
client.activate("CODE-1234", account_id="acc_...", idempotency_key="order-42")
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**Отступ при 429.** Превышение темпа — это не ошибка, а обратное давление:
|
|
80
|
+
клиент ждёт `Retry-After` и продолжает. Исключение `RateLimited` вы увидите
|
|
81
|
+
только когда ретраи кончились.
|
|
82
|
+
|
|
83
|
+
## Ошибки
|
|
84
|
+
|
|
85
|
+
Каждая — отдельный тип, у всех есть `code` и `request_id` (его удобно
|
|
86
|
+
цитировать в поддержке).
|
|
87
|
+
|
|
88
|
+
- `RateLimited` — слишком быстро. Поле `retry_after`, ничего не потрачено.
|
|
89
|
+
- `DailyCapReached` — исчерпан суточный потолок активаций, в `reset_at` время сброса.
|
|
90
|
+
- `AuthFailed` — ключ не принят. Сервис намеренно не уточняет, почему.
|
|
91
|
+
- `OutOfStock` — кода такого номинала нет в вашем стоке.
|
|
92
|
+
- `NotFound` — объекта нет либо он чужой; API эти случаи не различает.
|
|
93
|
+
- `WaitTimeout` — `wait_for` не дождался. Активация всё ещё выполняется:
|
|
94
|
+
опросите `get_activation` позже. **Повторно активировать код нельзя** — он
|
|
95
|
+
спишется дважды.
|
|
96
|
+
|
|
97
|
+
## Методы
|
|
98
|
+
|
|
99
|
+
| Метод | Что делает |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `connect_account(cookies, game=)` | Подключить свой Midas-аккаунт |
|
|
102
|
+
| `list_accounts()` | Подключённые аккаунты |
|
|
103
|
+
| `activate(code, account_id=)` | Активировать один код |
|
|
104
|
+
| `activate_batch(denomination_value=, quantity=, account_id=, game=)` | Активировать пачку из своего стока |
|
|
105
|
+
| `get_activation(id)` | Статус одной активации |
|
|
106
|
+
| `activation_statuses(ids)` | Статусы многих активаций одним вызовом |
|
|
107
|
+
| `wait_for(id, poll=, timeout=)` | Ждать терминального статуса |
|
|
108
|
+
| `list_games()` | Поддерживаемые игры |
|
|
109
|
+
| `list_packages(game)` | Номиналы одной игры |
|
|
110
|
+
| `key_status()` | Состояние ключа: остаток на сегодня, срок |
|
|
111
|
+
|
|
112
|
+
## Про бету
|
|
113
|
+
|
|
114
|
+
Пока идёт бета, ограничитель — **темп** запросов, а не суточная квота:
|
|
115
|
+
упёршись, вы получаете `429`, ждёте и продолжаете работать. Суточный потолок
|
|
116
|
+
активаций тоже есть, но он аварийный.
|
|
117
|
+
|
|
118
|
+
По итогам беты числа будут пересмотрены, и часть возможностей станет платной —
|
|
119
|
+
о том, какие именно, сказано заранее: доставка результатов вебхуками, ссылки
|
|
120
|
+
для выдачи и второй подключённый Midas-аккаунт.
|
|
121
|
+
|
|
122
|
+
## Лицензия
|
|
123
|
+
|
|
124
|
+
MIT.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# midasbuy-sdk
|
|
2
|
+
|
|
3
|
+
Python-клиент для API активации кодов Midasbuy.
|
|
4
|
+
|
|
5
|
+
Есть **бесплатный тариф**: ключ выдаётся без регистрации и без оплаты. Это
|
|
6
|
+
**бета** — лимиты временные и будут пересмотрены по её итогам.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install midasbuy-sdk
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Как получить ключ
|
|
13
|
+
|
|
14
|
+
Напишите Telegram-боту `@midasbuy_api_bot` команду `/free`.
|
|
15
|
+
|
|
16
|
+
К заявке приложите ссылки на свои профили на площадках, где вы торгуете, и
|
|
17
|
+
скриншоты-подтверждения. Это единственный барьер: он стоит не ради формальности,
|
|
18
|
+
а чтобы бесплатные ключи не разошлись пачками по одноразовым аккаунтам.
|
|
19
|
+
|
|
20
|
+
Один бесплатный ключ в одни руки. Повторная заявка вернёт тот же ключ.
|
|
21
|
+
|
|
22
|
+
## Первый вызов
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from midasbuy_sdk import MidasbuyClient
|
|
26
|
+
|
|
27
|
+
with MidasbuyClient("ваш-ключ") as client:
|
|
28
|
+
# 1. подключите свой Midas-аккаунт — активировать нужно на него
|
|
29
|
+
account = client.connect_account(cookies="...", game="pubgm")
|
|
30
|
+
|
|
31
|
+
# 2. активируйте код
|
|
32
|
+
accepted = client.activate("CODE-1234", account_id=account.account_id)
|
|
33
|
+
|
|
34
|
+
# 3. дождитесь результата
|
|
35
|
+
result = client.wait_for(accepted.activation_id)
|
|
36
|
+
print(result.status, result.granted_item)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Асинхронно — те же имена методов:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from midasbuy_sdk import AsyncMidasbuyClient
|
|
43
|
+
|
|
44
|
+
async with AsyncMidasbuyClient("ваш-ключ") as client:
|
|
45
|
+
accepted = await client.activate("CODE-1234", account_id="acc_...")
|
|
46
|
+
result = await client.wait_for(accepted.activation_id)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Что делает клиент за вас
|
|
50
|
+
|
|
51
|
+
**Ключ идемпотентности.** Каждый POST уходит с `Idempotency-Key`, и повтор
|
|
52
|
+
использует **тот же** ключ. Поэтому таймаут или 429 не превращают одну
|
|
53
|
+
активацию в две. Свой ключ можно передать явно — тогда и ваш собственный ретрай
|
|
54
|
+
схлопнется в одну операцию:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
client.activate("CODE-1234", account_id="acc_...", idempotency_key="order-42")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Отступ при 429.** Превышение темпа — это не ошибка, а обратное давление:
|
|
61
|
+
клиент ждёт `Retry-After` и продолжает. Исключение `RateLimited` вы увидите
|
|
62
|
+
только когда ретраи кончились.
|
|
63
|
+
|
|
64
|
+
## Ошибки
|
|
65
|
+
|
|
66
|
+
Каждая — отдельный тип, у всех есть `code` и `request_id` (его удобно
|
|
67
|
+
цитировать в поддержке).
|
|
68
|
+
|
|
69
|
+
- `RateLimited` — слишком быстро. Поле `retry_after`, ничего не потрачено.
|
|
70
|
+
- `DailyCapReached` — исчерпан суточный потолок активаций, в `reset_at` время сброса.
|
|
71
|
+
- `AuthFailed` — ключ не принят. Сервис намеренно не уточняет, почему.
|
|
72
|
+
- `OutOfStock` — кода такого номинала нет в вашем стоке.
|
|
73
|
+
- `NotFound` — объекта нет либо он чужой; API эти случаи не различает.
|
|
74
|
+
- `WaitTimeout` — `wait_for` не дождался. Активация всё ещё выполняется:
|
|
75
|
+
опросите `get_activation` позже. **Повторно активировать код нельзя** — он
|
|
76
|
+
спишется дважды.
|
|
77
|
+
|
|
78
|
+
## Методы
|
|
79
|
+
|
|
80
|
+
| Метод | Что делает |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `connect_account(cookies, game=)` | Подключить свой Midas-аккаунт |
|
|
83
|
+
| `list_accounts()` | Подключённые аккаунты |
|
|
84
|
+
| `activate(code, account_id=)` | Активировать один код |
|
|
85
|
+
| `activate_batch(denomination_value=, quantity=, account_id=, game=)` | Активировать пачку из своего стока |
|
|
86
|
+
| `get_activation(id)` | Статус одной активации |
|
|
87
|
+
| `activation_statuses(ids)` | Статусы многих активаций одним вызовом |
|
|
88
|
+
| `wait_for(id, poll=, timeout=)` | Ждать терминального статуса |
|
|
89
|
+
| `list_games()` | Поддерживаемые игры |
|
|
90
|
+
| `list_packages(game)` | Номиналы одной игры |
|
|
91
|
+
| `key_status()` | Состояние ключа: остаток на сегодня, срок |
|
|
92
|
+
|
|
93
|
+
## Про бету
|
|
94
|
+
|
|
95
|
+
Пока идёт бета, ограничитель — **темп** запросов, а не суточная квота:
|
|
96
|
+
упёршись, вы получаете `429`, ждёте и продолжаете работать. Суточный потолок
|
|
97
|
+
активаций тоже есть, но он аварийный.
|
|
98
|
+
|
|
99
|
+
По итогам беты числа будут пересмотрены, и часть возможностей станет платной —
|
|
100
|
+
о том, какие именно, сказано заранее: доставка результатов вебхуками, ссылки
|
|
101
|
+
для выдачи и второй подключённый Midas-аккаунт.
|
|
102
|
+
|
|
103
|
+
## Лицензия
|
|
104
|
+
|
|
105
|
+
MIT.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "midasbuy-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Client for the Midasbuy code-activation API — free tier included"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["midasbuy", "pubg", "uc", "activation", "api", "sdk"]
|
|
13
|
+
dependencies = ["httpx>=0.27"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://github.com/zlexdev/midasbuy-sdk"
|
|
17
|
+
Issues = "https://github.com/zlexdev/midasbuy-sdk/issues"
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.24", "respx>=0.21", "ruff>=0.6", "mypy>=1.11"]
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/midasbuy_sdk"]
|
|
24
|
+
|
|
25
|
+
[tool.ruff]
|
|
26
|
+
line-length = 100
|
|
27
|
+
target-version = "py311"
|
|
28
|
+
|
|
29
|
+
[tool.ruff.lint]
|
|
30
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
31
|
+
|
|
32
|
+
[tool.mypy]
|
|
33
|
+
python_version = "3.11"
|
|
34
|
+
strict = true
|
|
35
|
+
files = ["src"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
asyncio_mode = "auto"
|
|
39
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""midasbuy-sdk — a small client for the Midasbuy activation API.
|
|
2
|
+
|
|
3
|
+
from midasbuy_sdk import MidasbuyClient
|
|
4
|
+
|
|
5
|
+
with MidasbuyClient("your-key") as client:
|
|
6
|
+
accepted = client.activate("CODE-1234", account_id="acc_…")
|
|
7
|
+
result = client.wait_for(accepted.activation_id)
|
|
8
|
+
print(result.status, result.granted_item)
|
|
9
|
+
|
|
10
|
+
The free tier is a **beta**: its limits are temporary and will be re-set from
|
|
11
|
+
what the beta measures. Get a key from the Telegram bot — see the README.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from midasbuy_sdk.client import DEFAULT_BASE_URL, AsyncMidasbuyClient, MidasbuyClient
|
|
15
|
+
from midasbuy_sdk.errors import (
|
|
16
|
+
AuthFailed,
|
|
17
|
+
DailyCapReached,
|
|
18
|
+
MidasbuyError,
|
|
19
|
+
NotFound,
|
|
20
|
+
OutOfStock,
|
|
21
|
+
RateLimited,
|
|
22
|
+
ServerError,
|
|
23
|
+
ValidationFailed,
|
|
24
|
+
WaitTimeout,
|
|
25
|
+
)
|
|
26
|
+
from midasbuy_sdk.models import (
|
|
27
|
+
Accepted,
|
|
28
|
+
AccountConnect,
|
|
29
|
+
Activation,
|
|
30
|
+
BatchAccepted,
|
|
31
|
+
KeyStatus,
|
|
32
|
+
Package,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.0"
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"DEFAULT_BASE_URL",
|
|
39
|
+
"Accepted",
|
|
40
|
+
"AccountConnect",
|
|
41
|
+
"Activation",
|
|
42
|
+
"AsyncMidasbuyClient",
|
|
43
|
+
"AuthFailed",
|
|
44
|
+
"BatchAccepted",
|
|
45
|
+
"DailyCapReached",
|
|
46
|
+
"KeyStatus",
|
|
47
|
+
"MidasbuyClient",
|
|
48
|
+
"MidasbuyError",
|
|
49
|
+
"NotFound",
|
|
50
|
+
"OutOfStock",
|
|
51
|
+
"Package",
|
|
52
|
+
"RateLimited",
|
|
53
|
+
"ServerError",
|
|
54
|
+
"ValidationFailed",
|
|
55
|
+
"WaitTimeout",
|
|
56
|
+
"__version__",
|
|
57
|
+
]
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"""The client — sync and async, same method names, same behaviour.
|
|
2
|
+
|
|
3
|
+
Two things it does for you that are easy to get wrong by hand:
|
|
4
|
+
|
|
5
|
+
* **Idempotency.** Every POST carries an ``Idempotency-Key``. A retry reuses the
|
|
6
|
+
SAME key, so a timeout or a 429 can never turn one activation into two. Pass
|
|
7
|
+
your own key when you want a business-level retry to collapse too.
|
|
8
|
+
* **Backoff.** 429 and 5xx are retried with the server's own ``Retry-After``
|
|
9
|
+
when it sends one. A spent rate budget is normal back-pressure, not an error
|
|
10
|
+
to handle — by the time you see :class:`RateLimited`, the retries are gone.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import time
|
|
17
|
+
import uuid
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from midasbuy_sdk.errors import DailyCapReached, MidasbuyError, RateLimited, WaitTimeout, error_for
|
|
24
|
+
from midasbuy_sdk.models import (
|
|
25
|
+
Accepted,
|
|
26
|
+
AccountConnect,
|
|
27
|
+
Activation,
|
|
28
|
+
BatchAccepted,
|
|
29
|
+
KeyStatus,
|
|
30
|
+
Package,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
DEFAULT_BASE_URL = "https://free.midas.chqcode.dev/v1"
|
|
34
|
+
"""The free contour. Point at your paid host by passing ``base_url=``."""
|
|
35
|
+
|
|
36
|
+
_RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
37
|
+
_MAX_BACKOFF_S = 30.0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _Common:
|
|
41
|
+
"""Everything that does not care whether the transport blocks."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
api_key: str,
|
|
46
|
+
*,
|
|
47
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
48
|
+
timeout: float = 30.0,
|
|
49
|
+
max_retries: int = 3,
|
|
50
|
+
) -> None:
|
|
51
|
+
if not api_key:
|
|
52
|
+
raise ValueError("api_key is required — get a free one from the bot")
|
|
53
|
+
self._base_url = base_url.rstrip("/")
|
|
54
|
+
self._timeout = timeout
|
|
55
|
+
self._max_retries = max_retries
|
|
56
|
+
self._headers = {
|
|
57
|
+
"Authorization": f"Bearer {api_key}",
|
|
58
|
+
"Accept": "application/json",
|
|
59
|
+
"User-Agent": "midasbuy-sdk",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
def _post_headers(self, idempotency_key: str | None) -> dict[str, str]:
|
|
63
|
+
# Generated per CALL, not per retry: the whole point is that every
|
|
64
|
+
# attempt of one logical operation carries the same key.
|
|
65
|
+
return {**self._headers, "Idempotency-Key": idempotency_key or str(uuid.uuid4())}
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def _unwrap(response: httpx.Response) -> dict[str, Any]:
|
|
69
|
+
request_id = response.headers.get("x-request-id")
|
|
70
|
+
try:
|
|
71
|
+
payload = response.json()
|
|
72
|
+
except ValueError:
|
|
73
|
+
payload = {}
|
|
74
|
+
if response.is_success:
|
|
75
|
+
data = payload.get("data", payload)
|
|
76
|
+
return data if isinstance(data, dict) else {"items": data}
|
|
77
|
+
|
|
78
|
+
error = payload.get("error", payload) if isinstance(payload, dict) else {}
|
|
79
|
+
code = error.get("code") if isinstance(error, dict) else None
|
|
80
|
+
message = (
|
|
81
|
+
error.get("message") if isinstance(error, dict) else None
|
|
82
|
+
) or response.reason_phrase
|
|
83
|
+
exc = error_for(response.status_code, code, message, request_id)
|
|
84
|
+
if isinstance(exc, RateLimited):
|
|
85
|
+
exc.retry_after = _retry_after(response)
|
|
86
|
+
if isinstance(exc, DailyCapReached) and isinstance(error, dict):
|
|
87
|
+
exc.reset_at = error.get("reset_at") or (error.get("context") or {}).get("reset_at")
|
|
88
|
+
raise exc
|
|
89
|
+
|
|
90
|
+
def _sleep_for(self, attempt: int, response: httpx.Response | None) -> float:
|
|
91
|
+
if response is not None:
|
|
92
|
+
explicit = _retry_after(response, default=0.0)
|
|
93
|
+
if explicit > 0:
|
|
94
|
+
return min(explicit, _MAX_BACKOFF_S)
|
|
95
|
+
return min(2.0**attempt, _MAX_BACKOFF_S)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _retry_after(response: httpx.Response, *, default: float = 1.0) -> float:
|
|
99
|
+
raw = response.headers.get("retry-after")
|
|
100
|
+
if raw is None:
|
|
101
|
+
return default
|
|
102
|
+
try:
|
|
103
|
+
return float(raw)
|
|
104
|
+
except ValueError:
|
|
105
|
+
return default
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class MidasbuyClient(_Common):
|
|
109
|
+
"""Blocking client. Use it as a context manager to close the pool."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, api_key: str, **kwargs: Any) -> None:
|
|
112
|
+
super().__init__(api_key, **kwargs)
|
|
113
|
+
self._http = httpx.Client(timeout=self._timeout)
|
|
114
|
+
|
|
115
|
+
def __enter__(self) -> MidasbuyClient:
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
def __exit__(self, *_: object) -> None:
|
|
119
|
+
self.close()
|
|
120
|
+
|
|
121
|
+
def close(self) -> None:
|
|
122
|
+
self._http.close()
|
|
123
|
+
|
|
124
|
+
def _request(
|
|
125
|
+
self,
|
|
126
|
+
method: str,
|
|
127
|
+
path: str,
|
|
128
|
+
*,
|
|
129
|
+
json: dict[str, Any] | None = None,
|
|
130
|
+
params: dict[str, Any] | None = None,
|
|
131
|
+
idempotency_key: str | None = None,
|
|
132
|
+
) -> dict[str, Any]:
|
|
133
|
+
headers = self._post_headers(idempotency_key) if method == "POST" else self._headers
|
|
134
|
+
last: MidasbuyError | None = None
|
|
135
|
+
for attempt in range(self._max_retries + 1):
|
|
136
|
+
response = self._http.request(
|
|
137
|
+
method, f"{self._base_url}{path}", json=json, params=params, headers=headers
|
|
138
|
+
)
|
|
139
|
+
if response.status_code in _RETRY_STATUSES and attempt < self._max_retries:
|
|
140
|
+
time.sleep(self._sleep_for(attempt, response))
|
|
141
|
+
continue
|
|
142
|
+
try:
|
|
143
|
+
return self._unwrap(response)
|
|
144
|
+
except MidasbuyError as exc:
|
|
145
|
+
last = exc
|
|
146
|
+
raise
|
|
147
|
+
raise last or MidasbuyError("request failed")
|
|
148
|
+
|
|
149
|
+
def connect_account(
|
|
150
|
+
self, cookies: str, *, game: str, idempotency_key: str | None = None
|
|
151
|
+
) -> AccountConnect:
|
|
152
|
+
"""Link YOUR Midas account. Async: poll ``list_accounts`` for the status."""
|
|
153
|
+
return AccountConnect.from_json(
|
|
154
|
+
self._request(
|
|
155
|
+
"POST",
|
|
156
|
+
"/accounts/connect",
|
|
157
|
+
json={"cookies": cookies, "game": game},
|
|
158
|
+
idempotency_key=idempotency_key,
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def list_accounts(self) -> list[dict[str, Any]]:
|
|
163
|
+
return list(self._request("GET", "/accounts/list").get("items", []))
|
|
164
|
+
|
|
165
|
+
def activate(
|
|
166
|
+
self, code: str, *, account_id: str, idempotency_key: str | None = None
|
|
167
|
+
) -> Accepted:
|
|
168
|
+
"""Activate one code. Returns as soon as the job is queued (202)."""
|
|
169
|
+
return Accepted.from_json(
|
|
170
|
+
self._request(
|
|
171
|
+
"POST",
|
|
172
|
+
"/redeem/activate",
|
|
173
|
+
json={"code": code, "account_id": account_id},
|
|
174
|
+
idempotency_key=idempotency_key,
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def activate_batch(
|
|
179
|
+
self,
|
|
180
|
+
*,
|
|
181
|
+
denomination_value: int,
|
|
182
|
+
quantity: int,
|
|
183
|
+
account_id: str,
|
|
184
|
+
game: str,
|
|
185
|
+
idempotency_key: str | None = None,
|
|
186
|
+
) -> BatchAccepted:
|
|
187
|
+
"""Activate up to ``quantity`` codes of one denomination from your stock."""
|
|
188
|
+
return BatchAccepted.from_json(
|
|
189
|
+
self._request(
|
|
190
|
+
"POST",
|
|
191
|
+
"/redeem/activate-batch-by-denomination",
|
|
192
|
+
json={
|
|
193
|
+
"denomination_value": denomination_value,
|
|
194
|
+
"quantity": quantity,
|
|
195
|
+
"account_id": account_id,
|
|
196
|
+
"game": game,
|
|
197
|
+
},
|
|
198
|
+
idempotency_key=idempotency_key,
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def get_activation(self, activation_id: str) -> Activation:
|
|
203
|
+
return Activation.from_json(
|
|
204
|
+
self._request("GET", "/redeem/get", params={"id": activation_id})
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def activation_statuses(self, activation_ids: Sequence[str]) -> list[Activation]:
|
|
208
|
+
"""Statuses for many ids in one call — cheaper than polling each."""
|
|
209
|
+
data = self._request(
|
|
210
|
+
"POST", "/redeem/status-batch", json={"activation_ids": list(activation_ids)}
|
|
211
|
+
)
|
|
212
|
+
return [Activation.from_json(row) for row in data.get("activations", [])]
|
|
213
|
+
|
|
214
|
+
def wait_for(
|
|
215
|
+
self, activation_id: str, *, poll: float = 2.0, timeout: float = 300.0
|
|
216
|
+
) -> Activation:
|
|
217
|
+
"""Poll until the activation is terminal. Raises :class:`WaitTimeout` if not."""
|
|
218
|
+
deadline = time.monotonic() + timeout
|
|
219
|
+
while True:
|
|
220
|
+
activation = self.get_activation(activation_id)
|
|
221
|
+
if activation.is_terminal:
|
|
222
|
+
return activation
|
|
223
|
+
if time.monotonic() >= deadline:
|
|
224
|
+
raise WaitTimeout(
|
|
225
|
+
f"activation {activation_id} still {activation.status} after {timeout}s"
|
|
226
|
+
)
|
|
227
|
+
time.sleep(poll)
|
|
228
|
+
|
|
229
|
+
def list_games(self) -> list[str]:
|
|
230
|
+
"""Games the catalog supports — the ``game`` argument everywhere else."""
|
|
231
|
+
data = self._request("GET", "/catalog/games")
|
|
232
|
+
return [str(row.get("code") or row.get("id") or row) for row in data.get("items", [])]
|
|
233
|
+
|
|
234
|
+
def list_packages(self, game: str) -> list[Package]:
|
|
235
|
+
"""Purchasable denominations for one game."""
|
|
236
|
+
data = self._request("GET", "/catalog/items", params={"game": game})
|
|
237
|
+
return [Package.from_json(row) for row in data.get("items", [])]
|
|
238
|
+
|
|
239
|
+
def key_status(self) -> KeyStatus:
|
|
240
|
+
"""Your key: status, expiry, and how much of today's cap is left."""
|
|
241
|
+
return KeyStatus.from_json(self._request("GET", "/subscription"))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class AsyncMidasbuyClient(_Common):
|
|
245
|
+
"""The same eight methods, awaited."""
|
|
246
|
+
|
|
247
|
+
def __init__(self, api_key: str, **kwargs: Any) -> None:
|
|
248
|
+
super().__init__(api_key, **kwargs)
|
|
249
|
+
self._http = httpx.AsyncClient(timeout=self._timeout)
|
|
250
|
+
|
|
251
|
+
async def __aenter__(self) -> AsyncMidasbuyClient:
|
|
252
|
+
return self
|
|
253
|
+
|
|
254
|
+
async def __aexit__(self, *_: object) -> None:
|
|
255
|
+
await self.aclose()
|
|
256
|
+
|
|
257
|
+
async def aclose(self) -> None:
|
|
258
|
+
await self._http.aclose()
|
|
259
|
+
|
|
260
|
+
async def _request(
|
|
261
|
+
self,
|
|
262
|
+
method: str,
|
|
263
|
+
path: str,
|
|
264
|
+
*,
|
|
265
|
+
json: dict[str, Any] | None = None,
|
|
266
|
+
params: dict[str, Any] | None = None,
|
|
267
|
+
idempotency_key: str | None = None,
|
|
268
|
+
) -> dict[str, Any]:
|
|
269
|
+
headers = self._post_headers(idempotency_key) if method == "POST" else self._headers
|
|
270
|
+
for attempt in range(self._max_retries + 1):
|
|
271
|
+
response = await self._http.request(
|
|
272
|
+
method, f"{self._base_url}{path}", json=json, params=params, headers=headers
|
|
273
|
+
)
|
|
274
|
+
if response.status_code in _RETRY_STATUSES and attempt < self._max_retries:
|
|
275
|
+
await asyncio.sleep(self._sleep_for(attempt, response))
|
|
276
|
+
continue
|
|
277
|
+
return self._unwrap(response)
|
|
278
|
+
raise MidasbuyError("request failed")
|
|
279
|
+
|
|
280
|
+
async def connect_account(
|
|
281
|
+
self, cookies: str, *, game: str, idempotency_key: str | None = None
|
|
282
|
+
) -> AccountConnect:
|
|
283
|
+
return AccountConnect.from_json(
|
|
284
|
+
await self._request(
|
|
285
|
+
"POST",
|
|
286
|
+
"/accounts/connect",
|
|
287
|
+
json={"cookies": cookies, "game": game},
|
|
288
|
+
idempotency_key=idempotency_key,
|
|
289
|
+
)
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
async def list_accounts(self) -> list[dict[str, Any]]:
|
|
293
|
+
return list((await self._request("GET", "/accounts/list")).get("items", []))
|
|
294
|
+
|
|
295
|
+
async def activate(
|
|
296
|
+
self, code: str, *, account_id: str, idempotency_key: str | None = None
|
|
297
|
+
) -> Accepted:
|
|
298
|
+
return Accepted.from_json(
|
|
299
|
+
await self._request(
|
|
300
|
+
"POST",
|
|
301
|
+
"/redeem/activate",
|
|
302
|
+
json={"code": code, "account_id": account_id},
|
|
303
|
+
idempotency_key=idempotency_key,
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
async def activate_batch(
|
|
308
|
+
self,
|
|
309
|
+
*,
|
|
310
|
+
denomination_value: int,
|
|
311
|
+
quantity: int,
|
|
312
|
+
account_id: str,
|
|
313
|
+
game: str,
|
|
314
|
+
idempotency_key: str | None = None,
|
|
315
|
+
) -> BatchAccepted:
|
|
316
|
+
return BatchAccepted.from_json(
|
|
317
|
+
await self._request(
|
|
318
|
+
"POST",
|
|
319
|
+
"/redeem/activate-batch-by-denomination",
|
|
320
|
+
json={
|
|
321
|
+
"denomination_value": denomination_value,
|
|
322
|
+
"quantity": quantity,
|
|
323
|
+
"account_id": account_id,
|
|
324
|
+
"game": game,
|
|
325
|
+
},
|
|
326
|
+
idempotency_key=idempotency_key,
|
|
327
|
+
)
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
async def get_activation(self, activation_id: str) -> Activation:
|
|
331
|
+
return Activation.from_json(
|
|
332
|
+
await self._request("GET", "/redeem/get", params={"id": activation_id})
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
async def activation_statuses(self, activation_ids: Sequence[str]) -> list[Activation]:
|
|
336
|
+
data = await self._request(
|
|
337
|
+
"POST", "/redeem/status-batch", json={"activation_ids": list(activation_ids)}
|
|
338
|
+
)
|
|
339
|
+
return [Activation.from_json(row) for row in data.get("activations", [])]
|
|
340
|
+
|
|
341
|
+
async def wait_for(
|
|
342
|
+
self, activation_id: str, *, poll: float = 2.0, timeout: float = 300.0
|
|
343
|
+
) -> Activation:
|
|
344
|
+
deadline = time.monotonic() + timeout
|
|
345
|
+
while True:
|
|
346
|
+
activation = await self.get_activation(activation_id)
|
|
347
|
+
if activation.is_terminal:
|
|
348
|
+
return activation
|
|
349
|
+
if time.monotonic() >= deadline:
|
|
350
|
+
raise WaitTimeout(
|
|
351
|
+
f"activation {activation_id} still {activation.status} after {timeout}s"
|
|
352
|
+
)
|
|
353
|
+
await asyncio.sleep(poll)
|
|
354
|
+
|
|
355
|
+
async def list_games(self) -> list[str]:
|
|
356
|
+
data = await self._request("GET", "/catalog/games")
|
|
357
|
+
return [str(row.get("code") or row.get("id") or row) for row in data.get("items", [])]
|
|
358
|
+
|
|
359
|
+
async def list_packages(self, game: str) -> list[Package]:
|
|
360
|
+
data = await self._request("GET", "/catalog/items", params={"game": game})
|
|
361
|
+
return [Package.from_json(row) for row in data.get("items", [])]
|
|
362
|
+
|
|
363
|
+
async def key_status(self) -> KeyStatus:
|
|
364
|
+
return KeyStatus.from_json(await self._request("GET", "/subscription"))
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Typed errors — one per way a call can fail, carrying the facts you need.
|
|
2
|
+
|
|
3
|
+
Every error keeps ``code`` (the stable machine string from the API) and
|
|
4
|
+
``request_id``, so a support message can quote something the operator can find.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MidasbuyError(Exception):
|
|
11
|
+
"""Base for everything this client raises."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
message: str,
|
|
16
|
+
*,
|
|
17
|
+
code: str | None = None,
|
|
18
|
+
status: int | None = None,
|
|
19
|
+
request_id: str | None = None,
|
|
20
|
+
) -> None:
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.message = message
|
|
23
|
+
self.code = code
|
|
24
|
+
self.status = status
|
|
25
|
+
self.request_id = request_id
|
|
26
|
+
|
|
27
|
+
def __str__(self) -> str:
|
|
28
|
+
parts = [self.message]
|
|
29
|
+
if self.code:
|
|
30
|
+
parts.append(f"code={self.code}")
|
|
31
|
+
if self.request_id:
|
|
32
|
+
parts.append(f"request_id={self.request_id}")
|
|
33
|
+
return " · ".join(parts)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AuthFailed(MidasbuyError):
|
|
37
|
+
"""The key is missing, wrong, expired or revoked.
|
|
38
|
+
|
|
39
|
+
The API answers a single neutral 401 for all of those on purpose, so this
|
|
40
|
+
error cannot tell you which — get a fresh key if you believe it is live.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RateLimited(MidasbuyError):
|
|
45
|
+
"""You are calling faster than the key's per-minute budget.
|
|
46
|
+
|
|
47
|
+
Nothing was consumed. Wait ``retry_after`` seconds and continue — this is
|
|
48
|
+
the beta's normal back-pressure, not a wall.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, message: str, *, retry_after: float = 1.0, **kwargs: object) -> None:
|
|
52
|
+
super().__init__(message, **kwargs) # type: ignore[arg-type]
|
|
53
|
+
self.retry_after = retry_after
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DailyCapReached(MidasbuyError):
|
|
57
|
+
"""The key's daily activation stop-loss is spent; it resets at ``reset_at``."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, message: str, *, reset_at: str | None = None, **kwargs: object) -> None:
|
|
60
|
+
super().__init__(message, **kwargs) # type: ignore[arg-type]
|
|
61
|
+
self.reset_at = reset_at
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class OutOfStock(MidasbuyError):
|
|
65
|
+
"""No code of that denomination is in your stock."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class NotFound(MidasbuyError):
|
|
69
|
+
"""No such object — or it belongs to someone else. The API does not
|
|
70
|
+
distinguish the two, so neither does this error."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ValidationFailed(MidasbuyError):
|
|
74
|
+
"""The request body was rejected before anything happened."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ServerError(MidasbuyError):
|
|
78
|
+
"""The service failed. Retried automatically; this means it kept failing."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class WaitTimeout(MidasbuyError):
|
|
82
|
+
"""``wait_for`` gave up before the activation reached a terminal status.
|
|
83
|
+
|
|
84
|
+
The activation is still running — poll ``get_activation`` later. It is NOT
|
|
85
|
+
safe to re-activate the code: that would spend it twice.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
_BY_CODE: dict[str, type[MidasbuyError]] = {
|
|
90
|
+
"code_out_of_stock": OutOfStock,
|
|
91
|
+
"activation_window_limit": DailyCapReached,
|
|
92
|
+
"rate_limited": RateLimited,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def error_for(status: int, code: str | None, message: str, request_id: str | None) -> MidasbuyError:
|
|
97
|
+
"""Map an API failure onto the narrowest error class available."""
|
|
98
|
+
if code and code in _BY_CODE:
|
|
99
|
+
return _BY_CODE[code](message, code=code, status=status, request_id=request_id)
|
|
100
|
+
if status == 401:
|
|
101
|
+
return AuthFailed(message, code=code, status=status, request_id=request_id)
|
|
102
|
+
if status == 404:
|
|
103
|
+
return NotFound(message, code=code, status=status, request_id=request_id)
|
|
104
|
+
if status == 429:
|
|
105
|
+
return RateLimited(message, code=code, status=status, request_id=request_id)
|
|
106
|
+
if status in (400, 409, 422):
|
|
107
|
+
return ValidationFailed(message, code=code, status=status, request_id=request_id)
|
|
108
|
+
if status >= 500:
|
|
109
|
+
return ServerError(message, code=code, status=status, request_id=request_id)
|
|
110
|
+
return MidasbuyError(message, code=code, status=status, request_id=request_id)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""What the API gives back, as plain dataclasses.
|
|
2
|
+
|
|
3
|
+
Deliberately not pydantic: this client should install next to any version of
|
|
4
|
+
anything. Unknown fields are kept in ``raw`` so a server-side addition never
|
|
5
|
+
breaks your code — and so you can read a field this SDK does not model yet.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class Accepted:
|
|
16
|
+
"""A 202: the work is queued, not done. Poll with ``get_activation``."""
|
|
17
|
+
|
|
18
|
+
activation_id: str
|
|
19
|
+
status: str
|
|
20
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_json(cls, data: dict[str, Any]) -> Accepted:
|
|
24
|
+
return cls(
|
|
25
|
+
activation_id=str(data.get("activation_id") or data.get("id") or ""),
|
|
26
|
+
status=str(data.get("status", "pending")),
|
|
27
|
+
raw=data,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class BatchAccepted:
|
|
33
|
+
"""A batch 202 — ``accepted`` may be lower than you asked for."""
|
|
34
|
+
|
|
35
|
+
activation_ids: list[str]
|
|
36
|
+
accepted: int
|
|
37
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_json(cls, data: dict[str, Any]) -> BatchAccepted:
|
|
41
|
+
ids = [str(i) for i in data.get("activation_ids", [])]
|
|
42
|
+
return cls(activation_ids=ids, accepted=int(data.get("accepted", len(ids))), raw=data)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class Activation:
|
|
47
|
+
id: str
|
|
48
|
+
status: str
|
|
49
|
+
granted_item: str | None = None
|
|
50
|
+
failure_reason: str | None = None
|
|
51
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
52
|
+
|
|
53
|
+
TERMINAL: frozenset[str] = frozenset({"success", "failed", "indeterminate"})
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def is_terminal(self) -> bool:
|
|
57
|
+
return self.status.lower() in self.TERMINAL
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def succeeded(self) -> bool:
|
|
61
|
+
return self.status.lower() == "success"
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_json(cls, data: dict[str, Any]) -> Activation:
|
|
65
|
+
return cls(
|
|
66
|
+
id=str(data.get("id", "")),
|
|
67
|
+
status=str(data.get("status", "")),
|
|
68
|
+
granted_item=data.get("granted_item"),
|
|
69
|
+
failure_reason=data.get("failure_reason"),
|
|
70
|
+
raw=data,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class AccountConnect:
|
|
76
|
+
account_id: str
|
|
77
|
+
status: str
|
|
78
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_json(cls, data: dict[str, Any]) -> AccountConnect:
|
|
82
|
+
return cls(
|
|
83
|
+
account_id=str(data.get("account_id", "")),
|
|
84
|
+
status=str(data.get("status", "connecting")),
|
|
85
|
+
raw=data,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True, slots=True)
|
|
90
|
+
class KeyStatus:
|
|
91
|
+
"""Your key as the service sees it — what is left and when it resets."""
|
|
92
|
+
|
|
93
|
+
status: str
|
|
94
|
+
expires_at: str | None = None
|
|
95
|
+
limit_per_day: int | None = None
|
|
96
|
+
used_today: int | None = None
|
|
97
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def remaining_today(self) -> int | None:
|
|
101
|
+
if self.limit_per_day is None or self.used_today is None:
|
|
102
|
+
return None
|
|
103
|
+
return max(0, self.limit_per_day - self.used_today)
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def from_json(cls, data: dict[str, Any]) -> KeyStatus:
|
|
107
|
+
return cls(
|
|
108
|
+
status=str(data.get("status", "")),
|
|
109
|
+
expires_at=data.get("expires_at"),
|
|
110
|
+
limit_per_day=data.get("limit_per_day"),
|
|
111
|
+
used_today=data.get("used_today"),
|
|
112
|
+
raw=data,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True, slots=True)
|
|
117
|
+
class Package:
|
|
118
|
+
"""One purchasable denomination from the catalog."""
|
|
119
|
+
|
|
120
|
+
id: str
|
|
121
|
+
game: str
|
|
122
|
+
denomination_value: int | None = None
|
|
123
|
+
title: str | None = None
|
|
124
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def from_json(cls, data: dict[str, Any]) -> Package:
|
|
128
|
+
return cls(
|
|
129
|
+
id=str(data.get("id", "")),
|
|
130
|
+
game=str(data.get("game", "")),
|
|
131
|
+
denomination_value=data.get("denomination_value"),
|
|
132
|
+
title=data.get("title") or data.get("name"),
|
|
133
|
+
raw=data,
|
|
134
|
+
)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""What must not break: idempotency across retries, error mapping, wait_for."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
import respx
|
|
8
|
+
|
|
9
|
+
from midasbuy_sdk import (
|
|
10
|
+
AsyncMidasbuyClient,
|
|
11
|
+
AuthFailed,
|
|
12
|
+
DailyCapReached,
|
|
13
|
+
MidasbuyClient,
|
|
14
|
+
OutOfStock,
|
|
15
|
+
RateLimited,
|
|
16
|
+
WaitTimeout,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
BASE = "https://free.test/v1"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _client(**kwargs: object) -> MidasbuyClient:
|
|
23
|
+
return MidasbuyClient("key-123", base_url=BASE, **kwargs) # type: ignore[arg-type]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@respx.mock
|
|
27
|
+
def test_activate_returns_the_accepted_id() -> None:
|
|
28
|
+
respx.post(f"{BASE}/redeem/activate").mock(
|
|
29
|
+
return_value=httpx.Response(
|
|
30
|
+
202, json={"data": {"activation_id": "act_1", "status": "pending"}}
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
with _client() as client:
|
|
34
|
+
accepted = client.activate("CODE", account_id="acc_1")
|
|
35
|
+
assert accepted.activation_id == "act_1"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@respx.mock
|
|
39
|
+
def test_post_always_carries_an_idempotency_key() -> None:
|
|
40
|
+
route = respx.post(f"{BASE}/redeem/activate").mock(
|
|
41
|
+
return_value=httpx.Response(202, json={"data": {"activation_id": "act_1"}})
|
|
42
|
+
)
|
|
43
|
+
with _client() as client:
|
|
44
|
+
client.activate("CODE", account_id="acc_1")
|
|
45
|
+
assert route.calls.last.request.headers["Idempotency-Key"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@respx.mock
|
|
49
|
+
def test_a_retried_429_reuses_the_same_key() -> None:
|
|
50
|
+
"""The one thing that must never break: a retry is not a second activation."""
|
|
51
|
+
route = respx.post(f"{BASE}/redeem/activate").mock(
|
|
52
|
+
side_effect=[
|
|
53
|
+
httpx.Response(
|
|
54
|
+
429, headers={"Retry-After": "0"}, json={"error": {"code": "rate_limited"}}
|
|
55
|
+
),
|
|
56
|
+
httpx.Response(202, json={"data": {"activation_id": "act_1"}}),
|
|
57
|
+
]
|
|
58
|
+
)
|
|
59
|
+
with _client() as client:
|
|
60
|
+
client.activate("CODE", account_id="acc_1")
|
|
61
|
+
|
|
62
|
+
keys = {call.request.headers["Idempotency-Key"] for call in route.calls}
|
|
63
|
+
assert len(route.calls) == 2
|
|
64
|
+
assert len(keys) == 1
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@respx.mock
|
|
68
|
+
def test_an_explicit_key_wins() -> None:
|
|
69
|
+
route = respx.post(f"{BASE}/redeem/activate").mock(
|
|
70
|
+
return_value=httpx.Response(202, json={"data": {"activation_id": "act_1"}})
|
|
71
|
+
)
|
|
72
|
+
with _client() as client:
|
|
73
|
+
client.activate("CODE", account_id="acc_1", idempotency_key="mine-1")
|
|
74
|
+
assert route.calls.last.request.headers["Idempotency-Key"] == "mine-1"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@respx.mock
|
|
78
|
+
def test_rate_limited_after_the_retries_are_spent() -> None:
|
|
79
|
+
respx.post(f"{BASE}/redeem/activate").mock(
|
|
80
|
+
return_value=httpx.Response(
|
|
81
|
+
429,
|
|
82
|
+
headers={"Retry-After": "7"},
|
|
83
|
+
json={"error": {"code": "rate_limited", "message": "slow down"}},
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
with _client(max_retries=0) as client, pytest.raises(RateLimited) as excinfo:
|
|
87
|
+
client.activate("CODE", account_id="acc_1")
|
|
88
|
+
assert excinfo.value.retry_after == 7.0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@respx.mock
|
|
92
|
+
def test_daily_cap_carries_its_reset() -> None:
|
|
93
|
+
respx.post(f"{BASE}/redeem/activate").mock(
|
|
94
|
+
return_value=httpx.Response(
|
|
95
|
+
429,
|
|
96
|
+
json={
|
|
97
|
+
"error": {
|
|
98
|
+
"code": "activation_window_limit",
|
|
99
|
+
"message": "cap reached",
|
|
100
|
+
"reset_at": "2026-07-26T00:00:00Z",
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
with _client(max_retries=0) as client, pytest.raises(DailyCapReached) as excinfo:
|
|
106
|
+
client.activate("CODE", account_id="acc_1")
|
|
107
|
+
assert excinfo.value.reset_at == "2026-07-26T00:00:00Z"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@respx.mock
|
|
111
|
+
def test_auth_and_stock_errors_are_distinct_types() -> None:
|
|
112
|
+
respx.get(f"{BASE}/subscription").mock(
|
|
113
|
+
return_value=httpx.Response(401, json={"error": {"code": "unauthorized", "message": "no"}})
|
|
114
|
+
)
|
|
115
|
+
respx.post(f"{BASE}/redeem/activate-batch-by-denomination").mock(
|
|
116
|
+
return_value=httpx.Response(
|
|
117
|
+
409, json={"error": {"code": "code_out_of_stock", "message": "empty"}}
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
with _client() as client:
|
|
121
|
+
with pytest.raises(AuthFailed):
|
|
122
|
+
client.key_status()
|
|
123
|
+
with pytest.raises(OutOfStock):
|
|
124
|
+
client.activate_batch(
|
|
125
|
+
denomination_value=60, quantity=2, account_id="acc_1", game="pubgm"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@respx.mock
|
|
130
|
+
def test_wait_for_polls_until_terminal() -> None:
|
|
131
|
+
respx.get(f"{BASE}/redeem/get").mock(
|
|
132
|
+
side_effect=[
|
|
133
|
+
httpx.Response(200, json={"data": {"id": "act_1", "status": "running"}}),
|
|
134
|
+
httpx.Response(
|
|
135
|
+
200, json={"data": {"id": "act_1", "status": "success", "granted_item": "60 UC"}}
|
|
136
|
+
),
|
|
137
|
+
]
|
|
138
|
+
)
|
|
139
|
+
with _client() as client:
|
|
140
|
+
activation = client.wait_for("act_1", poll=0, timeout=5)
|
|
141
|
+
assert activation.succeeded
|
|
142
|
+
assert activation.granted_item == "60 UC"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@respx.mock
|
|
146
|
+
def test_wait_for_times_out_without_lying() -> None:
|
|
147
|
+
respx.get(f"{BASE}/redeem/get").mock(
|
|
148
|
+
return_value=httpx.Response(200, json={"data": {"id": "act_1", "status": "running"}})
|
|
149
|
+
)
|
|
150
|
+
with _client() as client, pytest.raises(WaitTimeout):
|
|
151
|
+
client.wait_for("act_1", poll=0, timeout=0)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@respx.mock
|
|
155
|
+
def test_key_status_reports_what_is_left() -> None:
|
|
156
|
+
respx.get(f"{BASE}/subscription").mock(
|
|
157
|
+
return_value=httpx.Response(
|
|
158
|
+
200, json={"data": {"status": "active", "limit_per_day": 1000, "used_today": 40}}
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
with _client() as client:
|
|
162
|
+
status = client.key_status()
|
|
163
|
+
assert status.remaining_today == 960
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@respx.mock
|
|
167
|
+
async def test_async_client_mirrors_the_sync_one() -> None:
|
|
168
|
+
respx.post(f"{BASE}/redeem/activate").mock(
|
|
169
|
+
return_value=httpx.Response(202, json={"data": {"activation_id": "act_9"}})
|
|
170
|
+
)
|
|
171
|
+
async with AsyncMidasbuyClient("key-123", base_url=BASE) as client:
|
|
172
|
+
accepted = await client.activate("CODE", account_id="acc_1")
|
|
173
|
+
assert accepted.activation_id == "act_9"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_an_empty_key_is_refused_before_any_call() -> None:
|
|
177
|
+
with pytest.raises(ValueError):
|
|
178
|
+
MidasbuyClient("")
|