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.
@@ -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,9 @@
1
+ __pycache__/
2
+ *.pyc
3
+ dist/
4
+ build/
5
+ *.egg-info/
6
+ .venv/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
@@ -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("")