tcgapi 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tcgapi/__init__.py ADDED
@@ -0,0 +1,68 @@
1
+ """Official Python SDK for tcgapi.dev.
2
+
3
+ >>> from tcgapi import TCGApi
4
+ >>> tcg = TCGApi(api_key="tcg_live_...")
5
+ >>> tcg.games.get("pokemon").data.name
6
+ 'Pokemon'
7
+
8
+ Async variant:
9
+
10
+ >>> from tcgapi import AsyncTCGApi
11
+ >>> async with AsyncTCGApi() as tcg:
12
+ ... resp = await tcg.search.cards("charizard")
13
+ """
14
+
15
+ from .async_client import AsyncTCGApi
16
+ from .client import TCGApi
17
+ from .errors import (
18
+ AuthError,
19
+ NotFoundError,
20
+ RateLimitError,
21
+ TcgApiError,
22
+ TierError,
23
+ )
24
+ from .models import (
25
+ ApiKeyCreated,
26
+ ApiKeySummary,
27
+ BulkCard,
28
+ BulkPriceRow,
29
+ Card,
30
+ CardWithPrice,
31
+ Game,
32
+ Meta,
33
+ Price,
34
+ PriceHistoryPoint,
35
+ PriceMover,
36
+ RateLimit,
37
+ Response,
38
+ Set,
39
+ UsageResponse,
40
+ )
41
+
42
+ __version__ = "0.2.1"
43
+ __all__ = [
44
+ "TCGApi",
45
+ "AsyncTCGApi",
46
+ # errors
47
+ "TcgApiError",
48
+ "AuthError",
49
+ "TierError",
50
+ "NotFoundError",
51
+ "RateLimitError",
52
+ # models
53
+ "Game",
54
+ "Set",
55
+ "Card",
56
+ "CardWithPrice",
57
+ "Price",
58
+ "PriceMover",
59
+ "PriceHistoryPoint",
60
+ "BulkCard",
61
+ "BulkPriceRow",
62
+ "ApiKeySummary",
63
+ "ApiKeyCreated",
64
+ "UsageResponse",
65
+ "Meta",
66
+ "RateLimit",
67
+ "Response",
68
+ ]
tcgapi/_transport.py ADDED
@@ -0,0 +1,66 @@
1
+ """Shared HTTP transport for sync + async clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from .errors import make_error
11
+
12
+ DEFAULT_BASE_URL = "https://api.tcgapi.dev/v1"
13
+ DEFAULT_TIMEOUT = 30.0
14
+
15
+
16
+ class _BaseTransport:
17
+ """Common transport plumbing — header construction, error handling."""
18
+
19
+ def __init__(
20
+ self,
21
+ api_key: str | None = None,
22
+ base_url: str | None = None,
23
+ timeout: float | None = None,
24
+ user_agent: str | None = None,
25
+ ) -> None:
26
+ self.api_key = api_key if api_key is not None else os.environ.get("TCGAPI_KEY")
27
+ self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
28
+ self.timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
29
+ self.user_agent = user_agent
30
+
31
+ def _headers(self) -> dict[str, str]:
32
+ headers = {"Accept": "application/json"}
33
+ if self.api_key:
34
+ headers["X-API-Key"] = self.api_key
35
+ if self.user_agent:
36
+ headers["User-Agent"] = self.user_agent
37
+ return headers
38
+
39
+ @staticmethod
40
+ def _clean_params(params: dict[str, Any] | None) -> dict[str, Any] | None:
41
+ if not params:
42
+ return None
43
+ return {k: v for k, v in params.items() if v is not None}
44
+
45
+ @staticmethod
46
+ def _handle_response(resp: httpx.Response) -> dict[str, Any]:
47
+ try:
48
+ body: dict[str, Any] | None = resp.json() if resp.content else None
49
+ except ValueError:
50
+ body = None
51
+
52
+ if resp.is_error:
53
+ err = (body or {}).get("error") if body else None
54
+ # Standard tcgapi: error = {message, code}. x402 402: error = "string message".
55
+ if isinstance(err, dict):
56
+ message = err.get("message") or f"Request failed with status {resp.status_code}"
57
+ code = err.get("code")
58
+ elif isinstance(err, str):
59
+ message = err
60
+ code = None
61
+ else:
62
+ message = f"Request failed with status {resp.status_code}"
63
+ code = None
64
+ raise make_error(resp.status_code, message, code, body, resp.headers.get("Retry-After"))
65
+
66
+ return body or {}
tcgapi/async_client.py ADDED
@@ -0,0 +1,75 @@
1
+ """Async TCGApi client. Mirror of the sync client surface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from ._transport import _BaseTransport
10
+ from .resources.bulk import AsyncBulkResource
11
+ from .resources.cards import AsyncCardsResource
12
+ from .resources.export import AsyncExportResource
13
+ from .resources.games import AsyncGamesResource
14
+ from .resources.keys import AsyncKeysResource
15
+ from .resources.prices import AsyncPricesResource
16
+ from .resources.search import AsyncSearchResource
17
+ from .resources.sets import AsyncSetsResource
18
+ from .resources.usage import AsyncUsageResource
19
+
20
+
21
+ class AsyncTCGApi(_BaseTransport):
22
+ """Asynchronous client. Backed by httpx.AsyncClient."""
23
+
24
+ def __init__(
25
+ self,
26
+ api_key: str | None = None,
27
+ *,
28
+ base_url: str | None = None,
29
+ timeout: float | None = None,
30
+ user_agent: str | None = None,
31
+ client: httpx.AsyncClient | None = None,
32
+ ) -> None:
33
+ super().__init__(api_key=api_key, base_url=base_url, timeout=timeout, user_agent=user_agent)
34
+ self._client = client or httpx.AsyncClient(timeout=self.timeout)
35
+ self._owns_client = client is None
36
+
37
+ self.games = AsyncGamesResource(self)
38
+ self.sets = AsyncSetsResource(self)
39
+ self.cards = AsyncCardsResource(self)
40
+ self.search = AsyncSearchResource(self)
41
+ self.prices = AsyncPricesResource(self)
42
+ self.bulk = AsyncBulkResource(self)
43
+ self.export = AsyncExportResource(self)
44
+ self.keys = AsyncKeysResource(self)
45
+ self.usage = AsyncUsageResource(self)
46
+
47
+ async def _request(self, method: str, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
48
+ resp = await self._client.request(
49
+ method,
50
+ self.base_url + path,
51
+ params=self._clean_params(params),
52
+ headers=self._headers(),
53
+ )
54
+ return self._handle_response(resp)
55
+
56
+ async def _request_raw(self, method: str, path: str, params: dict[str, Any] | None = None) -> str:
57
+ resp = await self._client.request(
58
+ method,
59
+ self.base_url + path,
60
+ params=self._clean_params(params),
61
+ headers=self._headers(),
62
+ )
63
+ if resp.is_error:
64
+ self._handle_response(resp) # raises
65
+ return resp.text
66
+
67
+ async def close(self) -> None:
68
+ if self._owns_client:
69
+ await self._client.aclose()
70
+
71
+ async def __aenter__(self) -> "AsyncTCGApi":
72
+ return self
73
+
74
+ async def __aexit__(self, *exc: object) -> None:
75
+ await self.close()
tcgapi/client.py ADDED
@@ -0,0 +1,79 @@
1
+ """Sync TCGApi client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from ._transport import _BaseTransport
10
+ from .resources.bulk import BulkResource
11
+ from .resources.cards import CardsResource
12
+ from .resources.export import ExportResource
13
+ from .resources.games import GamesResource
14
+ from .resources.keys import KeysResource
15
+ from .resources.prices import PricesResource
16
+ from .resources.search import SearchResource
17
+ from .resources.sets import SetsResource
18
+ from .resources.usage import UsageResource
19
+
20
+
21
+ class TCGApi(_BaseTransport):
22
+ """Synchronous client. Backed by httpx.Client.
23
+
24
+ Get a key at https://tcgapi.dev/dashboard. If `api_key` is omitted,
25
+ the client reads from the `TCGAPI_KEY` environment variable.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ api_key: str | None = None,
31
+ *,
32
+ base_url: str | None = None,
33
+ timeout: float | None = None,
34
+ user_agent: str | None = None,
35
+ client: httpx.Client | None = None,
36
+ ) -> None:
37
+ super().__init__(api_key=api_key, base_url=base_url, timeout=timeout, user_agent=user_agent)
38
+ self._client = client or httpx.Client(timeout=self.timeout)
39
+ self._owns_client = client is None
40
+
41
+ self.games = GamesResource(self)
42
+ self.sets = SetsResource(self)
43
+ self.cards = CardsResource(self)
44
+ self.search = SearchResource(self)
45
+ self.prices = PricesResource(self)
46
+ self.bulk = BulkResource(self)
47
+ self.export = ExportResource(self)
48
+ self.keys = KeysResource(self)
49
+ self.usage = UsageResource(self)
50
+
51
+ def _request(self, method: str, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
52
+ resp = self._client.request(
53
+ method,
54
+ self.base_url + path,
55
+ params=self._clean_params(params),
56
+ headers=self._headers(),
57
+ )
58
+ return self._handle_response(resp)
59
+
60
+ def _request_raw(self, method: str, path: str, params: dict[str, Any] | None = None) -> str:
61
+ resp = self._client.request(
62
+ method,
63
+ self.base_url + path,
64
+ params=self._clean_params(params),
65
+ headers=self._headers(),
66
+ )
67
+ if resp.is_error:
68
+ self._handle_response(resp) # raises
69
+ return resp.text
70
+
71
+ def close(self) -> None:
72
+ if self._owns_client:
73
+ self._client.close()
74
+
75
+ def __enter__(self) -> "TCGApi":
76
+ return self
77
+
78
+ def __exit__(self, *exc: object) -> None:
79
+ self.close()
tcgapi/errors.py ADDED
@@ -0,0 +1,78 @@
1
+ """Typed exceptions for tcgapi responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class TcgApiError(Exception):
9
+ """Base exception for all tcgapi errors."""
10
+
11
+ def __init__(
12
+ self,
13
+ message: str,
14
+ status: int,
15
+ code: str | None = None,
16
+ body: dict[str, Any] | None = None,
17
+ ) -> None:
18
+ super().__init__(message)
19
+ self.status = status
20
+ self.code = code or f"HTTP_{status}"
21
+ self.body = body
22
+
23
+
24
+ class AuthError(TcgApiError):
25
+ """401 — invalid or missing API key."""
26
+
27
+ def __init__(self, message: str = "Invalid or missing API key", body: dict[str, Any] | None = None) -> None:
28
+ super().__init__(message, 401, "UNAUTHORIZED", body)
29
+
30
+
31
+ class TierError(TcgApiError):
32
+ """403 — endpoint requires a higher tier than the current key has."""
33
+
34
+ def __init__(self, message: str = "Tier upgrade required", body: dict[str, Any] | None = None) -> None:
35
+ super().__init__(message, 403, "TIER_REQUIRED", body)
36
+
37
+
38
+ class NotFoundError(TcgApiError):
39
+ """404 — resource not found."""
40
+
41
+ def __init__(self, message: str = "Resource not found", body: dict[str, Any] | None = None) -> None:
42
+ super().__init__(message, 404, "NOT_FOUND", body)
43
+
44
+
45
+ class RateLimitError(TcgApiError):
46
+ """429 — daily request limit reached."""
47
+
48
+ def __init__(
49
+ self,
50
+ message: str = "Daily request limit reached",
51
+ retry_after: int | None = None,
52
+ body: dict[str, Any] | None = None,
53
+ ) -> None:
54
+ super().__init__(message, 429, "RATE_LIMITED", body)
55
+ self.retry_after = retry_after
56
+
57
+
58
+ def make_error(
59
+ status: int,
60
+ message: str,
61
+ code: str | None,
62
+ body: dict[str, Any] | None,
63
+ retry_after: str | None,
64
+ ) -> TcgApiError:
65
+ if status == 401:
66
+ return AuthError(message, body)
67
+ if status == 403:
68
+ return TierError(message, body)
69
+ if status == 404:
70
+ return NotFoundError(message, body)
71
+ if status == 429:
72
+ ra: int | None
73
+ try:
74
+ ra = int(retry_after) if retry_after else None
75
+ except (TypeError, ValueError):
76
+ ra = None
77
+ return RateLimitError(message, ra, body)
78
+ return TcgApiError(message, status, code, body)
tcgapi/models.py ADDED
@@ -0,0 +1,211 @@
1
+ """Pydantic models for tcgapi responses. Mirrors openapi/spec.yaml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Generic, TypeVar
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ T = TypeVar("T")
10
+
11
+
12
+ class _Base(BaseModel):
13
+ model_config = ConfigDict(extra="ignore", populate_by_name=True)
14
+
15
+
16
+ class Game(_Base):
17
+ id: int
18
+ name: str
19
+ slug: str
20
+ tcgplayer_id: int
21
+ priority: int | None = None
22
+ set_count: int | None = None
23
+ card_count: int | None = None
24
+ image_url: str | None = None
25
+ logo_url: str | None = None
26
+ last_synced_at: str | None = None
27
+
28
+
29
+ class Set(_Base):
30
+ id: int
31
+ name: str
32
+ slug: str | None = None
33
+ tcgplayer_id: int
34
+ abbreviation: str | None = None
35
+ release_date: str | None = None
36
+ card_count: int | None = None
37
+ image_url: str | None = None
38
+ set_icon_url: str | None = None
39
+ game_name: str | None = None
40
+ game_slug: str | None = None
41
+
42
+
43
+ class Card(_Base):
44
+ id: int
45
+ name: str
46
+ clean_name: str | None = None
47
+ number: str | None = None
48
+ rarity: str | None = None
49
+ image_url: str | None = None
50
+ tcgplayer_id: int
51
+ tcgplayer_url: str | None = None
52
+ product_type: str | None = None
53
+ foil_only: int | None = None
54
+ game_name: str | None = None
55
+ game_slug: str | None = None
56
+ set_name: str | None = None
57
+ set_slug: str | None = None
58
+ custom_attributes: dict[str, Any] | None = None
59
+
60
+
61
+ class Price(_Base):
62
+ card_id: int
63
+ printing: str | None = None
64
+ market_price: float | None = None
65
+ low_price: float | None = None
66
+ median_price: float | None = None
67
+ lowest_with_shipping: float | None = None
68
+ buylist_price: float | None = None
69
+ price_change_24h: float | None = None
70
+ price_change_7d: float | None = None
71
+ price_change_30d: float | None = None
72
+ last_updated_at: str | None = None
73
+ # Sales velocity — Pro/Business tiers only; the API omits these below Pro.
74
+ sales_volume: int | None = None
75
+ avg_sales_price: float | None = None
76
+ sales_as_of: str | None = None
77
+
78
+
79
+ class CardWithPrice(_Base):
80
+ id: int
81
+ name: str
82
+ clean_name: str | None = None
83
+ number: str | None = None
84
+ rarity: str | None = None
85
+ tcgplayer_id: int
86
+ product_type: str | None = None
87
+ foil_only: int | None = None
88
+ total_listings: int | None = None
89
+ printing: str | None = None
90
+ market_price: float | None = None
91
+ low_price: float | None = None
92
+ median_price: float | None = None
93
+ lowest_with_shipping: float | None = None
94
+ price_updated_at: str | None = None
95
+ image_url: str | None = None
96
+ game_name: str | None = None
97
+ game_slug: str | None = None
98
+ set_name: str | None = None
99
+
100
+
101
+ class PriceMover(_Base):
102
+ card_id: int
103
+ name: str
104
+ tcgplayer_id: int
105
+ product_type: str | None = None
106
+ foil_only: int | None = None
107
+ set_name: str | None = None
108
+ game_name: str | None = None
109
+ game_slug: str | None = None
110
+ printing: str | None = None
111
+ market_price: float
112
+ price_change: float
113
+ last_updated_at: str | None = None
114
+ image_url: str | None = None
115
+
116
+
117
+ class BulkPriceRow(_Base):
118
+ card_id: int
119
+ name: str | None = None
120
+ tcgplayer_id: int | None = None
121
+ product_type: str | None = None
122
+ foil_only: int | None = None
123
+ printing: str | None = None
124
+ market_price: float | None = None
125
+ low_price: float | None = None
126
+ median_price: float | None = None
127
+ lowest_with_shipping: float | None = None
128
+ buylist_price: float | None = None
129
+ price_change_24h: float | None = None
130
+ price_change_7d: float | None = None
131
+ price_change_30d: float | None = None
132
+ last_updated_at: str | None = None
133
+ image_url: str | None = None
134
+
135
+
136
+ class BulkCard(Card):
137
+ prices: list[Price] = Field(default_factory=list)
138
+
139
+
140
+ class PriceHistoryPoint(_Base):
141
+ date: str
142
+ printing: str | None = None
143
+ market_price: float | None = None
144
+ low_price: float | None = None
145
+ avg_sales_price: float | None = None
146
+ sales_volume: int | None = None
147
+
148
+
149
+ class Meta(_Base):
150
+ total: int | None = None
151
+ page: int | None = None
152
+ per_page: int | None = None
153
+ has_more: bool | None = None
154
+
155
+
156
+ class RateLimit(_Base):
157
+ daily_limit: int
158
+ daily_remaining: int
159
+ daily_reset: str
160
+
161
+
162
+ class ApiKeySummary(_Base):
163
+ id: str
164
+ key_prefix: str
165
+ name: str
166
+ tier: str
167
+ is_active: int
168
+ total_requests: int
169
+ created_at: str
170
+
171
+
172
+ class ApiKeyCreated(_Base):
173
+ id: str
174
+ key: str
175
+ key_prefix: str
176
+ name: str
177
+ tier: str
178
+ message: str | None = None
179
+
180
+
181
+ class UsageAccount(_Base):
182
+ today_requests: int
183
+ daily_limit: int
184
+ daily_remaining: int
185
+
186
+
187
+ class UsageKey(_Base):
188
+ id: str
189
+ key_prefix: str
190
+ name: str
191
+ tier: str
192
+ total_requests: int
193
+
194
+
195
+ class UsageSubscription(_Base):
196
+ plan: str
197
+ status: str
198
+
199
+
200
+ class UsageResponse(_Base):
201
+ account: UsageAccount
202
+ keys: list[UsageKey] = Field(default_factory=list)
203
+ subscription: UsageSubscription | None = None
204
+
205
+
206
+ class Response(_Base, Generic[T]):
207
+ """Wrapper returned by every method. Reach `.data`, `.meta`, `.rate_limit`."""
208
+
209
+ data: T
210
+ meta: Meta | None = None
211
+ rate_limit: RateLimit | None = None
File without changes
@@ -0,0 +1,27 @@
1
+ """Helpers shared by every resource module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, TypeVar
6
+
7
+ from pydantic import BaseModel
8
+
9
+ from ..models import Meta, RateLimit, Response
10
+
11
+ T = TypeVar("T")
12
+
13
+
14
+ def parse_response(model: type[T], body: dict[str, Any]) -> Response[T]:
15
+ """Coerce a raw response body into a typed Response[T] wrapper."""
16
+ raw_data = body.get("data")
17
+ if isinstance(model, type) and issubclass(model, BaseModel):
18
+ data = model.model_validate(raw_data)
19
+ else:
20
+ # Generic types (list[X], dict[str, list[X]], etc.) handled with TypeAdapter via the caller.
21
+ from pydantic import TypeAdapter
22
+
23
+ data = TypeAdapter(model).validate_python(raw_data)
24
+
25
+ meta = Meta.model_validate(body["meta"]) if body.get("meta") else None
26
+ rate_limit = RateLimit.model_validate(body["rate_limit"]) if body.get("rate_limit") else None
27
+ return Response[T](data=data, meta=meta, rate_limit=rate_limit) # type: ignore[valid-type]