Skip to content

Commit 71e0b56

Browse files
committed
Add sealed product listings (#5)
SealedResource had no listings surface at all, so neither sealed endpoint was reachable from the SDK: the TCGplayer one is new, and the eBay one has been live since sealed_sales started being scraped but was never wired up. Adds client.sealed.listings.{ebay,tcgplayer} with the same iterate_*/all_* helpers as client.cards.listings, on both the sync and async clients, so both product types paginate identically. The sealed ebay() signature deliberately omits graded/grader/grade. Sealed products are not graded and the API rejects those filters on this route, so accepting them would have offered keyword arguments that always fail.
1 parent 0345dbd commit 71e0b56

5 files changed

Lines changed: 149 additions & 0 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,22 @@ for offer in client.cards.listings.iterate_tcgplayer(789, condition="Near Mint")
8383
print(offer.seller_name, offer.price, offer.shipping_price)
8484
```
8585

86+
Sealed products carry the same two listing sources, under `client.sealed.listings`:
87+
88+
```python
89+
for offer in client.sealed.listings.iterate_tcgplayer(5678):
90+
print(offer.seller_name, offer.price, offer.quantity)
91+
92+
for sale in client.sealed.listings.iterate_ebay(5678, sort="price_desc"):
93+
print(sale.title, sale.price, sale.sold_at)
94+
```
95+
96+
Sealed TCGplayer offers are normally condition `"Unopened"` with an empty
97+
`printing`, so those two filters rarely narrow anything. Sealed eBay sales are
98+
never graded, so `graded`, `grader`, and `grade` aren't accepted there and
99+
`grader`/`grade` come back `None`. The async client mirrors all of these on
100+
`AsyncPkmnPrices`.
101+
86102
## Currency
87103

88104
Every price has a `currency` field. Pass `currency="usd"` or `currency="eur"` to filter, or leave it off to get everything your plan allows. EUR (Cardmarket) prices need a Pro plan; a free key asking for `eur` raises `ForbiddenError`.

src/pkmnprices/_endpoints.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@ def sealed_history(sealed_id: int, **query: Any) -> Request:
6666
return Request("GET", f"/v1/sealed/{sealed_id}/prices/history", _clean(query))
6767

6868

69+
def sealed_listings_ebay(sealed_id: int, **query: Any) -> Request:
70+
return Request("GET", f"/v1/sealed/{sealed_id}/listings/ebay", _clean(query))
71+
72+
73+
def sealed_listings_tcgplayer(sealed_id: int, **query: Any) -> Request:
74+
return Request("GET", f"/v1/sealed/{sealed_id}/listings/tcgplayer", _clean(query))
75+
76+
6977
def build_page(raw: dict[str, Any], model: type[Model]) -> Page[Any]:
7078
items = [model.from_dict(item) for item in raw["data"]]
7179
return Page(items, PageInfo.from_dict(raw["pagination"]))

src/pkmnprices/aio.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,9 +144,51 @@ def iterate_price_history(self, card_id: int, **params: Any) -> AsyncIterator[Pr
144144
return paginate_async(lambda page: self.price_history(card_id, **{**params, "page": page}), start)
145145

146146

147+
class AsyncSealedListingsResource:
148+
def __init__(self, transport: AsyncTransport) -> None:
149+
self._t = transport
150+
151+
# Sealed eBay sales are never graded, so graded/grader/grade don't apply.
152+
async def ebay(
153+
self, sealed_id: int, *, min_price: float | None = None, max_price: float | None = None,
154+
sort: str | None = None, limit: int | None = None, cursor: str | None = None,
155+
) -> CursorPage[EbayListing]:
156+
raw = await self._t.request(ep.sealed_listings_ebay(
157+
sealed_id, min_price=min_price, max_price=max_price,
158+
sort=sort, limit=limit, cursor=cursor,
159+
))
160+
return ep.build_cursor_page(raw, EbayListing)
161+
162+
def iterate_ebay(self, sealed_id: int, **params: Any) -> AsyncIterator[EbayListing]:
163+
return paginate_cursor_async(lambda cursor: self.ebay(sealed_id, **{**params, "cursor": cursor}))
164+
165+
async def all_ebay(self, sealed_id: int, **params: Any) -> List[EbayListing]:
166+
return [item async for item in self.iterate_ebay(sealed_id, **params)]
167+
168+
# Sealed offers are normally condition "Unopened" with an empty printing,
169+
# so those two filters rarely narrow anything here.
170+
async def tcgplayer(
171+
self, sealed_id: int, *, condition: str | None = None, language: str | None = None,
172+
printing: str | None = None, min_price: float | None = None, max_price: float | None = None,
173+
sort: str | None = None, limit: int | None = None, cursor: str | None = None,
174+
) -> CursorPage[TcgplayerListing]:
175+
raw = await self._t.request(ep.sealed_listings_tcgplayer(
176+
sealed_id, condition=condition, language=language, printing=printing,
177+
min_price=min_price, max_price=max_price, sort=sort, limit=limit, cursor=cursor,
178+
))
179+
return ep.build_cursor_page(raw, TcgplayerListing)
180+
181+
def iterate_tcgplayer(self, sealed_id: int, **params: Any) -> AsyncIterator[TcgplayerListing]:
182+
return paginate_cursor_async(lambda cursor: self.tcgplayer(sealed_id, **{**params, "cursor": cursor}))
183+
184+
async def all_tcgplayer(self, sealed_id: int, **params: Any) -> List[TcgplayerListing]:
185+
return [item async for item in self.iterate_tcgplayer(sealed_id, **params)]
186+
187+
147188
class AsyncSealedResource:
148189
def __init__(self, transport: AsyncTransport) -> None:
149190
self._t = transport
191+
self.listings = AsyncSealedListingsResource(transport)
150192

151193
async def list(
152194
self, *, set_id: int | None = None, name: str | None = None, language: str | None = None,

src/pkmnprices/resources.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,9 +144,51 @@ def iterate_price_history(self, card_id: int, **params: Any) -> Iterator[PriceHi
144144
return paginate(lambda page: self.price_history(card_id, **{**params, "page": page}), start)
145145

146146

147+
class SealedListingsResource:
148+
def __init__(self, transport: SyncTransport) -> None:
149+
self._t = transport
150+
151+
# Sealed eBay sales are never graded, so graded/grader/grade don't apply.
152+
def ebay(
153+
self, sealed_id: int, *, min_price: float | None = None, max_price: float | None = None,
154+
sort: str | None = None, limit: int | None = None, cursor: str | None = None,
155+
) -> CursorPage[EbayListing]:
156+
raw = self._t.request(ep.sealed_listings_ebay(
157+
sealed_id, min_price=min_price, max_price=max_price,
158+
sort=sort, limit=limit, cursor=cursor,
159+
))
160+
return ep.build_cursor_page(raw, EbayListing)
161+
162+
def iterate_ebay(self, sealed_id: int, **params: Any) -> Iterator[EbayListing]:
163+
return paginate_cursor(lambda cursor: self.ebay(sealed_id, **{**params, "cursor": cursor}))
164+
165+
def all_ebay(self, sealed_id: int, **params: Any) -> List[EbayListing]:
166+
return list(self.iterate_ebay(sealed_id, **params))
167+
168+
# Sealed offers are normally condition "Unopened" with an empty printing,
169+
# so those two filters rarely narrow anything here.
170+
def tcgplayer(
171+
self, sealed_id: int, *, condition: str | None = None, language: str | None = None,
172+
printing: str | None = None, min_price: float | None = None, max_price: float | None = None,
173+
sort: str | None = None, limit: int | None = None, cursor: str | None = None,
174+
) -> CursorPage[TcgplayerListing]:
175+
raw = self._t.request(ep.sealed_listings_tcgplayer(
176+
sealed_id, condition=condition, language=language, printing=printing,
177+
min_price=min_price, max_price=max_price, sort=sort, limit=limit, cursor=cursor,
178+
))
179+
return ep.build_cursor_page(raw, TcgplayerListing)
180+
181+
def iterate_tcgplayer(self, sealed_id: int, **params: Any) -> Iterator[TcgplayerListing]:
182+
return paginate_cursor(lambda cursor: self.tcgplayer(sealed_id, **{**params, "cursor": cursor}))
183+
184+
def all_tcgplayer(self, sealed_id: int, **params: Any) -> List[TcgplayerListing]:
185+
return list(self.iterate_tcgplayer(sealed_id, **params))
186+
187+
147188
class SealedResource:
148189
def __init__(self, transport: SyncTransport) -> None:
149190
self._t = transport
191+
self.listings = SealedListingsResource(transport)
150192

151193
def list(
152194
self, *, set_id: int | None = None, name: str | None = None, language: str | None = None,

tests/test_client.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,47 @@ def handler(request: httpx.Request) -> httpx.Response:
176176
assert "printing=1st+Edition+Holofoil" in captured["url"]
177177

178178

179+
def test_sealed_tcgplayer_listings() -> None:
180+
captured = {}
181+
182+
def handler(request: httpx.Request) -> httpx.Response:
183+
captured["url"] = str(request.url)
184+
return _json({"data": [{"id": 9, "listing_id": 991122, "printing": "",
185+
"condition": "Unopened", "language": "English", "price": 128.99,
186+
"shipping_price": 0.0, "seller_name": "SealedVault", "seller_id": "77321",
187+
"seller_rating": 99.2, "seller_sales": "10000+", "quantity": 4,
188+
"listing_type": "standard", "direct_seller": False, "gold_seller": True,
189+
"verified_seller": True, "custom_title": None,
190+
"updated_at": "2026-08-13T09:10:00+00:00"}],
191+
"pagination": {"has_more": False, "next_cursor": None, "count": 1}})
192+
193+
client = PkmnPrices("pk_test", _transport=httpx.MockTransport(handler))
194+
listings = client.sealed.listings.all_tcgplayer(5678, sort="price_asc")
195+
assert len(listings) == 1
196+
assert listings[0].seller_name == "SealedVault"
197+
assert listings[0].condition == "Unopened"
198+
assert "/v1/sealed/5678/listings/tcgplayer" in captured["url"]
199+
assert "sort=price_asc" in captured["url"]
200+
201+
202+
def test_sealed_ebay_listings() -> None:
203+
captured = {}
204+
205+
def handler(request: httpx.Request) -> httpx.Response:
206+
captured["url"] = str(request.url)
207+
return _json({"data": [{"id": 4, "title": "Obsidian Flames Booster Box", "price": 119.0,
208+
"grader": None, "grade": None, "sold_at": "2026-08-01",
209+
"listing_url": "https://example.test/x"}],
210+
"pagination": {"has_more": False, "next_cursor": None, "count": 1}})
211+
212+
client = PkmnPrices("pk_test", _transport=httpx.MockTransport(handler))
213+
page = client.sealed.listings.ebay(5678, min_price=100.0)
214+
assert page.data[0].grader is None
215+
assert page.data[0].price == 119.0
216+
assert "/v1/sealed/5678/listings/ebay" in captured["url"]
217+
assert "min_price=100" in captured["url"]
218+
219+
179220
def test_cardmarket_listings() -> None:
180221
captured = {}
181222

0 commit comments

Comments
 (0)