pypricecharting 0.0.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.
@@ -0,0 +1,66 @@
1
+ from pypricecharting.asyncpricecharting import AsyncPriceCharting
2
+ from pypricecharting.error import APIError, ParameterError, PriceChartingError
3
+ from pypricecharting.models import Buyer, Offer, PriceChartingModel, ProductSummary
4
+ from pypricecharting.pricecharting import PriceCharting
5
+ from pypricecharting.requests import (
6
+ OfferPublishRequestParams,
7
+ OffersRequestParams,
8
+ OffersSort,
9
+ OffersStatus,
10
+ )
11
+ from pypricecharting.responses import (
12
+ ErrorResponse,
13
+ OfferDetailsResponse,
14
+ OfferEndResponse,
15
+ OfferFeedbackResponse,
16
+ OfferPublishResponse,
17
+ OfferRefundResponse,
18
+ OfferShipResponse,
19
+ OffersResponse,
20
+ ProductResponse,
21
+ ProductsResponse,
22
+ )
23
+ from pypricecharting.types import (
24
+ Condition,
25
+ Console,
26
+ Genre,
27
+ OfferStatus,
28
+ RateLimit,
29
+ Rating,
30
+ State,
31
+ Status,
32
+ )
33
+
34
+ __all__ = [
35
+ "APIError",
36
+ "AsyncPriceCharting",
37
+ "Buyer",
38
+ "Condition",
39
+ "Console",
40
+ "ErrorResponse",
41
+ "Genre",
42
+ "Offer",
43
+ "OfferDetailsResponse",
44
+ "OfferEndResponse",
45
+ "OfferFeedbackResponse",
46
+ "OfferPublishRequestParams",
47
+ "OfferPublishResponse",
48
+ "OfferRefundResponse",
49
+ "OfferShipResponse",
50
+ "OfferStatus",
51
+ "OffersRequestParams",
52
+ "OffersResponse",
53
+ "OffersSort",
54
+ "OffersStatus",
55
+ "ParameterError",
56
+ "PriceCharting",
57
+ "PriceChartingError",
58
+ "PriceChartingModel",
59
+ "ProductResponse",
60
+ "ProductSummary",
61
+ "ProductsResponse",
62
+ "RateLimit",
63
+ "Rating",
64
+ "State",
65
+ "Status",
66
+ ]
@@ -0,0 +1,57 @@
1
+ import logging
2
+ import re
3
+ from collections.abc import Generator
4
+ from urllib.parse import urlencode
5
+
6
+ from httpx2 import Auth, Request, Response
7
+ from pydantic import SecretStr
8
+
9
+ _TOKEN_PARAM = re.compile(r"([?&]t=)[^&\s\"']+")
10
+
11
+
12
+ class _TokenAuth(Auth):
13
+ """Add the PriceCharting token as `t` in the query string (GET) or form body (POST)."""
14
+
15
+ requires_request_body = True
16
+
17
+ def __init__(self, token: SecretStr):
18
+ self._token = token
19
+
20
+ def auth_flow(self, request: Request) -> Generator[Request, Response]:
21
+ """Attach the token to the outgoing request."""
22
+ token = self._token.get_secret_value()
23
+
24
+ if request.method == "GET":
25
+ request.url = request.url.copy_set_param("t", token)
26
+ yield request
27
+ return
28
+
29
+ param = urlencode({"t": token}).encode()
30
+ body = request.content + b"&" + param if request.content else param
31
+ headers = request.headers.copy()
32
+ headers.pop("Content-Length", None) # forces httpx2 to recalculate length
33
+ headers["Content-Type"] = "application/x-www-form-urlencoded"
34
+
35
+ yield Request(
36
+ request.method,
37
+ request.url,
38
+ headers=headers,
39
+ content=body,
40
+ extensions=request.extensions,
41
+ )
42
+
43
+
44
+ class _RedactTokenFilter(logging.Filter):
45
+ """Replace the `t` query parameter in httpx2 log records with `REDACTED`."""
46
+
47
+ def filter(self, record: logging.LogRecord) -> bool:
48
+ """Redact the token in place and always let the record through."""
49
+ message = record.getMessage()
50
+ redacted = _TOKEN_PARAM.sub(r"\1REDACTED", message)
51
+ if redacted != message:
52
+ record.msg = redacted
53
+ record.args = None
54
+ return True
55
+
56
+
57
+ _REDACT_FILTER = _RedactTokenFilter()
@@ -0,0 +1,313 @@
1
+ import logging
2
+ import os
3
+ from types import TracebackType
4
+ from typing import Self
5
+
6
+ from httpx2 import AsyncBaseTransport, AsyncClient
7
+ from pydantic import SecretStr
8
+
9
+ from pypricecharting._auth import _REDACT_FILTER, _TokenAuth
10
+ from pypricecharting.error import ParameterError
11
+ from pypricecharting.requests import OfferPublishRequestParams, OffersRequestParams
12
+ from pypricecharting.responses import (
13
+ OfferDetailsResponse,
14
+ OfferEndResponse,
15
+ OfferFeedbackResponse,
16
+ OfferPublishResponse,
17
+ OfferRefundResponse,
18
+ OfferShipResponse,
19
+ OffersResponse,
20
+ ProductResponse,
21
+ ProductsResponse,
22
+ _parse,
23
+ )
24
+ from pypricecharting.route import BASE_URL, Route
25
+ from pypricecharting.types import Rating
26
+
27
+
28
+ class AsyncPriceCharting:
29
+ """Asynchronous PriceCharting API client.
30
+
31
+ Reuse one client for all requests and close it when done (`async with` or `aclose()`).
32
+ """
33
+
34
+ TOKEN_KEY = "PRICECHARTING_TOKEN"
35
+ """Environment variable the token is read from when none is passed."""
36
+
37
+ def __init__(
38
+ self,
39
+ token: str | None = None,
40
+ base_url: str = BASE_URL,
41
+ transport: AsyncBaseTransport | None = None,
42
+ ):
43
+ """Create a client authenticated with a PriceCharting API token.
44
+
45
+ The token is redacted from `httpx2` request logs.
46
+
47
+ Args:
48
+ token: The 40-character PriceCharting API token. If omitted, it is read from the
49
+ `PRICECHARTING_TOKEN` environment variable.
50
+ base_url: API base URL. Override it to use a mock server.
51
+ transport: Custom `httpx2` async transport, such as `httpx2.MockTransport` in tests.
52
+
53
+ Raises:
54
+ ValueError: If the token is missing or not 40 characters.
55
+
56
+ """
57
+ if token is not None and len(token) != 40:
58
+ raise ValueError("PriceCharting API token is not 40 characters")
59
+ token = token or os.environ.get(self.TOKEN_KEY)
60
+
61
+ if token is None:
62
+ raise ValueError("PriceCharting API token not found")
63
+ if len(token) != 40:
64
+ raise ValueError("PriceCharting API token is not 40 characters")
65
+
66
+ self._token = SecretStr(token)
67
+ self._client = AsyncClient(
68
+ base_url=base_url, auth=_TokenAuth(self._token), transport=transport
69
+ )
70
+ logging.getLogger("httpx2").addFilter(_REDACT_FILTER)
71
+
72
+ async def aclose(self):
73
+ """Close the underlying HTTP client."""
74
+ await self._client.aclose()
75
+
76
+ async def __aenter__(self) -> Self:
77
+ """Enter the async context and return the client."""
78
+ return self
79
+
80
+ async def __aexit__(
81
+ self,
82
+ exc_type: type[BaseException] | None,
83
+ exc_value: BaseException | None,
84
+ traceback: TracebackType | None,
85
+ ) -> None:
86
+ """Close the underlying HTTP client on exit."""
87
+ await self.aclose()
88
+
89
+ async def product(
90
+ self, id: str | None = None, upc: str | None = None, q: str | None = None
91
+ ) -> ProductResponse:
92
+ """Get a single product by exactly one of `id`, `upc`, or `q`.
93
+
94
+ Args:
95
+ id: PriceCharting product ID.
96
+ upc: UPC barcode.
97
+ q: Full text search on title and/or console. Only the best match is returned.
98
+
99
+ Returns:
100
+ The matching product.
101
+
102
+ Raises:
103
+ ParameterError: If not exactly one of `id`, `upc`, or `q` is given.
104
+ APIError: If the API returns an error.
105
+
106
+ """
107
+ params = {
108
+ key: value for key, value in {"id": id, "upc": upc, "q": q}.items() if value is not None
109
+ }
110
+
111
+ if len(params) == 0:
112
+ raise ParameterError("Invalid parameters. Either id, upc, or q are required.")
113
+ if len(params) > 1:
114
+ raise ParameterError("Invalid parameters. Only one of id, upc, or q is allowed.")
115
+
116
+ response = await self._client.get(Route.Prices.PRODUCT, params=params)
117
+ return _parse(response, ProductResponse)
118
+
119
+ async def products(self, q: str) -> ProductsResponse:
120
+ """Search for products by title and/or console.
121
+
122
+ Args:
123
+ q: Full text search on title and/or console. Surrounding whitespace is stripped.
124
+
125
+ Returns:
126
+ Up to 20 matching products.
127
+
128
+ Raises:
129
+ ParameterError: If `q` is empty or only whitespace.
130
+ APIError: If the API returns an error.
131
+
132
+ """
133
+ q = q.strip()
134
+
135
+ if len(q) == 0:
136
+ raise ParameterError("Invalid parameter; Query string q was empty.")
137
+
138
+ response = await self._client.get(Route.Prices.PRODUCTS, params={"q": q})
139
+ return _parse(response, ProductsResponse)
140
+
141
+ async def offers(self, params: OffersRequestParams) -> OffersResponse:
142
+ """List marketplace offers matching the given filters.
143
+
144
+ The same request can be made at most once every 5 minutes.
145
+
146
+ Args:
147
+ params: Offer filters. Only `status` is required.
148
+
149
+ Returns:
150
+ The matching offers.
151
+
152
+ Raises:
153
+ APIError: If the API returns an error.
154
+
155
+ """
156
+ response = await self._client.get(
157
+ Route.Marketplace.OFFERS,
158
+ params=params.model_dump(mode="json", by_alias=True, exclude_none=True),
159
+ )
160
+ return _parse(response, OffersResponse)
161
+
162
+ async def offer_details(self, offer_id: str) -> OfferDetailsResponse:
163
+ """Get the full details of one marketplace offer.
164
+
165
+ Args:
166
+ offer_id: The offer ID. Surrounding whitespace is stripped.
167
+
168
+ Returns:
169
+ The offer, including the buyer's shipping details once it has sold.
170
+
171
+ Raises:
172
+ ParameterError: If `offer_id` is empty or only whitespace.
173
+ APIError: If the API returns an error.
174
+
175
+ """
176
+ offer_id = offer_id.strip()
177
+
178
+ if len(offer_id) == 0:
179
+ raise ParameterError("Invalid parameter; offer_id was empty.")
180
+
181
+ response = await self._client.get(
182
+ Route.Marketplace.OFFER_DETAILS, params={"offer-id": offer_id}
183
+ )
184
+ return _parse(response, OfferDetailsResponse)
185
+
186
+ async def offer_publish(self, params: OfferPublishRequestParams) -> OfferPublishResponse:
187
+ """Publish a new marketplace offer, or edit an existing one.
188
+
189
+ Args:
190
+ params: The offer to publish. Set `offer_id` to edit an existing offer.
191
+
192
+ Returns:
193
+ The published offer.
194
+
195
+ Raises:
196
+ APIError: If the API returns an error.
197
+
198
+ """
199
+ response = await self._client.post(
200
+ Route.Marketplace.OFFER_PUBLISH,
201
+ data=params.model_dump(mode="json", by_alias=True, exclude_none=True),
202
+ )
203
+ return _parse(response, OfferPublishResponse)
204
+
205
+ async def offer_feedback(
206
+ self, offer_id: str, rating: Rating, comment: str | None = None
207
+ ) -> OfferFeedbackResponse:
208
+ """Leave feedback on a marketplace offer.
209
+
210
+ Args:
211
+ offer_id: The offer ID. Surrounding whitespace is stripped.
212
+ rating: How the transaction went.
213
+ comment: Text explaining the rating.
214
+
215
+ Returns:
216
+ The feedback left.
217
+
218
+ Raises:
219
+ ParameterError: If `offer_id` is empty or only whitespace.
220
+ APIError: If the API returns an error.
221
+
222
+ """
223
+ offer_id = offer_id.strip()
224
+
225
+ if len(offer_id) == 0:
226
+ raise ParameterError("Invalid parameter; offer_id was empty.")
227
+
228
+ data: dict[str, str | int] = {"offer-id": offer_id, "rating": rating.value}
229
+ if comment is not None:
230
+ data["comment"] = comment
231
+
232
+ response = await self._client.post(Route.Marketplace.OFFER_FEEDBACK, data=data)
233
+ return _parse(response, OfferFeedbackResponse)
234
+
235
+ async def offer_ship(
236
+ self, offer_id: str, tracking_number: str | None = None
237
+ ) -> OfferShipResponse:
238
+ """Mark a sold marketplace offer as shipped.
239
+
240
+ Args:
241
+ offer_id: The offer ID. Surrounding whitespace is stripped.
242
+ tracking_number: USPS, FedEx, UPS, or other tracking number. A valid one can earn
243
+ positive feedback if the buyer leaves none.
244
+
245
+ Returns:
246
+ The shipped offer.
247
+
248
+ Raises:
249
+ ParameterError: If `offer_id` is empty or only whitespace.
250
+ APIError: If the API returns an error.
251
+
252
+ """
253
+ offer_id = offer_id.strip()
254
+
255
+ if len(offer_id) == 0:
256
+ raise ParameterError("Invalid parameter; offer_id was empty.")
257
+
258
+ data = {"offer-id": offer_id}
259
+ if tracking_number is not None:
260
+ data["tracking-number"] = tracking_number
261
+
262
+ response = await self._client.post(Route.Marketplace.OFFER_SHIP, data=data)
263
+ return _parse(response, OfferShipResponse)
264
+
265
+ async def offer_end(self, offer_id: str) -> OfferEndResponse:
266
+ """End a marketplace offer so it can no longer be purchased.
267
+
268
+ For a collection item, this removes it from the collection.
269
+
270
+ Args:
271
+ offer_id: The offer ID. Surrounding whitespace is stripped.
272
+
273
+ Returns:
274
+ The ended offer.
275
+
276
+ Raises:
277
+ ParameterError: If `offer_id` is empty or only whitespace.
278
+ APIError: If the API returns an error.
279
+
280
+ """
281
+ offer_id = offer_id.strip()
282
+
283
+ if len(offer_id) == 0:
284
+ raise ParameterError("Invalid parameter; offer_id was empty.")
285
+
286
+ response = await self._client.post(Route.Marketplace.OFFER_END, data={"offer-id": offer_id})
287
+ return _parse(response, OfferEndResponse)
288
+
289
+ async def offer_refund(self, offer_id: str) -> OfferRefundResponse:
290
+ """Refund a purchased marketplace offer.
291
+
292
+ Payment is returned immediately from the seller's PayPal account to the buyer.
293
+
294
+ Args:
295
+ offer_id: The offer ID. Surrounding whitespace is stripped.
296
+
297
+ Returns:
298
+ The refunded offer.
299
+
300
+ Raises:
301
+ ParameterError: If `offer_id` is empty or only whitespace.
302
+ APIError: If the API returns an error.
303
+
304
+ """
305
+ offer_id = offer_id.strip()
306
+
307
+ if len(offer_id) == 0:
308
+ raise ParameterError("Invalid parameter; offer_id was empty.")
309
+
310
+ response = await self._client.post(
311
+ Route.Marketplace.OFFER_REFUND, data={"offer-id": offer_id}
312
+ )
313
+ return _parse(response, OfferRefundResponse)
@@ -0,0 +1,25 @@
1
+ class PriceChartingError(Exception):
2
+ """Base class for all PriceCharting errors"""
3
+
4
+
5
+ class ParameterError(PriceChartingError, ValueError):
6
+ """Raised when an API method is called with invalid parameters
7
+
8
+ Raised before any request is sent.
9
+ """
10
+
11
+
12
+ class APIError(PriceChartingError):
13
+ """Raised when the API returns an error response
14
+
15
+ Attributes:
16
+ message: The API's `error-message`, or the HTTP reason phrase if it has none.
17
+ status_code: The HTTP status code of the response.
18
+
19
+ """
20
+
21
+ def __init__(self, message: str, status_code: int):
22
+ """Store the API error message and HTTP status code."""
23
+ super().__init__(f"{status_code}: {message}")
24
+ self.message = message
25
+ self.status_code = status_code
@@ -0,0 +1,82 @@
1
+ from datetime import date
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+
5
+ from pypricecharting.types import OfferStatus
6
+
7
+
8
+ def to_kebab(name: str) -> str:
9
+ """Convert a snake_case field name to the API's kebab-case key."""
10
+ return name.replace("_", "-")
11
+
12
+
13
+ class PriceChartingModel(BaseModel):
14
+ """Base model that maps snake_case fields to the API's kebab-case keys."""
15
+
16
+ model_config = ConfigDict(
17
+ alias_generator=to_kebab,
18
+ populate_by_name=True,
19
+ use_attribute_docstrings=True,
20
+ )
21
+
22
+
23
+ class ProductSummary(PriceChartingModel):
24
+ """A product as listed in /api/products search results.
25
+
26
+ https://www.pricecharting.com/api-documentation#api-products
27
+ """
28
+
29
+ id: str | None = None
30
+ """PriceCharting product ID."""
31
+ product_name: str | None = None
32
+ console_name: str | None = None
33
+
34
+
35
+ class Offer(PriceChartingModel):
36
+ """An offer as listed in /api/offers results.
37
+
38
+ https://www.pricecharting.com/api-documentation#offers
39
+ """
40
+
41
+ offer_id: str | None = None
42
+ """Unique offer ID, also shown in the offer URL."""
43
+ offer_status: OfferStatus | None = None
44
+ offer_url: str | None = None
45
+ """Relative URL of the offer page. Prepend `route.BASE_URL` for a full link."""
46
+ id: int | None = None
47
+ """PriceCharting product ID, not the offer ID."""
48
+ product_name: str | None = None
49
+ console_name: str | None = None
50
+ condition_string: str | None = None
51
+ """Condition shown to buyers, such as "Normal Wear"."""
52
+ include_string: str | None = None
53
+ """What comes with the item, such as game only or game and box."""
54
+ price: int | None = None
55
+ """Current price in pennies, or the sale price if sold. Includes any shipping premium."""
56
+ is_available: bool | None = None
57
+ is_sold: bool | None = None
58
+ is_ended: bool | None = None
59
+ is_shipped: bool | None = None
60
+ sale_time: date | None = None
61
+ """Date the offer sold. `0001-01-01` if it hasn't happened."""
62
+ shipped_time: date | None = None
63
+ """Date the seller told PriceCharting the offer shipped. `0001-01-01` if it hasn't happened."""
64
+ ended_time: date | None = None
65
+ """Date the offer ended, by sale or by the seller. `0001-01-01` if it hasn't happened."""
66
+ cert_id: str | None = None
67
+ """Grading company certificate number. Graded offers only."""
68
+ grade_co_id: int | None = None
69
+ """ID of the grading company. Graded offers only."""
70
+
71
+
72
+ class Buyer(PriceChartingModel):
73
+ """The buyer of a sold offer, as returned by /api/offer-details."""
74
+
75
+ buyer_email: str | None = None
76
+ shipping_name: str | None = None
77
+ shipping_line1: str | None = None
78
+ shipping_line2: str | None = None
79
+ shipping_city: str | None = None
80
+ shipping_state: str | None = None
81
+ shipping_zip: str | None = None
82
+ shipping_country: str | None = None