productinformationapi 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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 HustleGotReal
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,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: productinformationapi
3
+ Version: 0.1.0
4
+ Summary: Product information and stock checks with Product Information API
5
+ Author-email: HustleGotReal <contact@hustlegotreal.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://productinformationapi.com
8
+ Keywords: product-information,stock,scraping,api,sdk
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Typing :: Typed
12
+ Requires-Python: >=3.11
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Dynamic: license-file
16
+
17
+ # Product Information API for Python
18
+
19
+ Product information and stock checks in Python 3.11+, with type hints and no runtime dependencies.
20
+
21
+ ## Install
22
+
23
+ ```sh
24
+ python -m pip install productinformationapi
25
+ ```
26
+
27
+ Or install the distributed wheel with `python -m pip install ./productinformationapi-0.1.0-py3-none-any.whl`.
28
+
29
+ ## Quick start
30
+
31
+ Use a tenant API key from your Product Information API account. MCP OAuth access tokens are scoped
32
+ to MCP and cannot authenticate these REST requests. Keep the key in your server environment:
33
+
34
+ ```sh
35
+ export PRODUCTINFORMATIONAPI_API_KEY='your-api-key'
36
+ ```
37
+
38
+ ```python
39
+ from productinformationapi import ProductInformationAPI
40
+
41
+ client = ProductInformationAPI() # Reads PRODUCTINFORMATIONAPI_API_KEY.
42
+ url = "https://www.amazon.com/dp/B0D3BCR3V7"
43
+
44
+ product = client.get_product_information(url)
45
+ print((product.get("productInformation") or {}).get("title"), product["creditsCharged"])
46
+
47
+ stock = client.check_stock(url)
48
+ print(stock["inStock"], stock["offers"], stock["creditsRemaining"])
49
+ ```
50
+
51
+ Methods return the complete API response as a dictionary. Response keys retain the API's camelCase
52
+ names. The client is synchronous; in an asyncio application, use a worker thread:
53
+
54
+ ```python
55
+ import asyncio
56
+
57
+ stock = await asyncio.to_thread(client.check_stock, url)
58
+ ```
59
+
60
+ Cancelling the asyncio waiter does not cancel the worker thread's HTTP request; its socket timeout
61
+ still applies.
62
+
63
+ ## Bulk
64
+
65
+ ```python
66
+ batch = client.check_stock_bulk([
67
+ "https://www.amazon.com/dp/B0D3BCR3V7",
68
+ {"productUrl": "https://www.amazon.co.uk/dp/B0D3BCR3V7", "sourceSite": "amazon_gb"},
69
+ ])
70
+
71
+ for row in batch["results"]:
72
+ if row["httpStatus"] == 200:
73
+ print(row["index"], row["result"])
74
+ else:
75
+ print(row["index"], row["httpStatus"], row["result"]["error"])
76
+
77
+ # Product information uses the same inputs:
78
+ products = client.get_product_information_bulk([
79
+ "https://www.amazon.com/dp/B0D3BCR3V7",
80
+ ])
81
+ ```
82
+
83
+ Bulk calls return inline. A successful HTTP response can contain failed items; inspect each
84
+ `httpStatus`. The current default server limit is 100 items per batch. Items are never silently
85
+ split, retried, or reordered. Large batches may require a longer HTTP timeout.
86
+
87
+ ## Options and errors
88
+
89
+ ```python
90
+ from uuid import uuid4
91
+ from productinformationapi import APIError
92
+
93
+ request_key = str(uuid4()) # Retain this key if you retry this exact request.
94
+ try:
95
+ stock = client.check_stock(
96
+ url,
97
+ idempotency_key=request_key,
98
+ timeout=120,
99
+ request_timeout_ms=30_000,
100
+ )
101
+ print(stock["inStock"])
102
+ except APIError as error:
103
+ print(error.status, error.code, error.request_id, error.retry_after_s)
104
+ ```
105
+
106
+ - Constructor: `ProductInformationAPI(api_key=None, *, base_url="https://api.productinformationapi.com", timeout=120)`.
107
+ - All methods accept `idempotency_key` and `timeout` in seconds. `timeout` is the standard-library
108
+ socket timeout for blocking operations, not a guaranteed total wall-clock deadline.
109
+ - Singular methods accept `source_site`. `get_product_information` also accepts
110
+ `include_gpsr=True` for Amazon DE GPSR details. Bulk requests do not accept GPSR options.
111
+ - `request_timeout_ms` applies only to singular stock requests, from 1000 to 100000 milliseconds.
112
+ It shortens the server execution budget. Leave additional time in `timeout` for server cleanup.
113
+ - Timeouts do not prove the server did no work or charged no credits. There are no automatic
114
+ retries. Reuse an idempotency key only for the same operation and payload; keep bulk items in
115
+ the same order on a retry. Keys must contain 1–255 visible ASCII characters.
116
+ - `APIError` exposes `status`, `code`, `request_id`, `retryable`, `retry_after_s`, and the parsed
117
+ response as `body`. HTML edge failures still produce `APIError`. Bulk item errors remain in
118
+ `batch["results"]`. Transport failures retain standard-library exceptions such as `URLError`
119
+ and `TimeoutError`.
120
+
121
+ ## Webhooks
122
+
123
+ Scrape-result webhooks and asynchronous jobs are not currently implemented by the API. These
124
+ methods return the result directly; no webhook URL option is available.
125
+
126
+ ## Support and license
127
+
128
+ Visit [Product Information API](https://productinformationapi.com) or contact
129
+ [contact@hustlegotreal.com](mailto:contact@hustlegotreal.com).
130
+
131
+ This SDK is MIT licensed. API access requires a separate account and is subject to the service's
132
+ terms and credit usage.
@@ -0,0 +1,116 @@
1
+ # Product Information API for Python
2
+
3
+ Product information and stock checks in Python 3.11+, with type hints and no runtime dependencies.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ python -m pip install productinformationapi
9
+ ```
10
+
11
+ Or install the distributed wheel with `python -m pip install ./productinformationapi-0.1.0-py3-none-any.whl`.
12
+
13
+ ## Quick start
14
+
15
+ Use a tenant API key from your Product Information API account. MCP OAuth access tokens are scoped
16
+ to MCP and cannot authenticate these REST requests. Keep the key in your server environment:
17
+
18
+ ```sh
19
+ export PRODUCTINFORMATIONAPI_API_KEY='your-api-key'
20
+ ```
21
+
22
+ ```python
23
+ from productinformationapi import ProductInformationAPI
24
+
25
+ client = ProductInformationAPI() # Reads PRODUCTINFORMATIONAPI_API_KEY.
26
+ url = "https://www.amazon.com/dp/B0D3BCR3V7"
27
+
28
+ product = client.get_product_information(url)
29
+ print((product.get("productInformation") or {}).get("title"), product["creditsCharged"])
30
+
31
+ stock = client.check_stock(url)
32
+ print(stock["inStock"], stock["offers"], stock["creditsRemaining"])
33
+ ```
34
+
35
+ Methods return the complete API response as a dictionary. Response keys retain the API's camelCase
36
+ names. The client is synchronous; in an asyncio application, use a worker thread:
37
+
38
+ ```python
39
+ import asyncio
40
+
41
+ stock = await asyncio.to_thread(client.check_stock, url)
42
+ ```
43
+
44
+ Cancelling the asyncio waiter does not cancel the worker thread's HTTP request; its socket timeout
45
+ still applies.
46
+
47
+ ## Bulk
48
+
49
+ ```python
50
+ batch = client.check_stock_bulk([
51
+ "https://www.amazon.com/dp/B0D3BCR3V7",
52
+ {"productUrl": "https://www.amazon.co.uk/dp/B0D3BCR3V7", "sourceSite": "amazon_gb"},
53
+ ])
54
+
55
+ for row in batch["results"]:
56
+ if row["httpStatus"] == 200:
57
+ print(row["index"], row["result"])
58
+ else:
59
+ print(row["index"], row["httpStatus"], row["result"]["error"])
60
+
61
+ # Product information uses the same inputs:
62
+ products = client.get_product_information_bulk([
63
+ "https://www.amazon.com/dp/B0D3BCR3V7",
64
+ ])
65
+ ```
66
+
67
+ Bulk calls return inline. A successful HTTP response can contain failed items; inspect each
68
+ `httpStatus`. The current default server limit is 100 items per batch. Items are never silently
69
+ split, retried, or reordered. Large batches may require a longer HTTP timeout.
70
+
71
+ ## Options and errors
72
+
73
+ ```python
74
+ from uuid import uuid4
75
+ from productinformationapi import APIError
76
+
77
+ request_key = str(uuid4()) # Retain this key if you retry this exact request.
78
+ try:
79
+ stock = client.check_stock(
80
+ url,
81
+ idempotency_key=request_key,
82
+ timeout=120,
83
+ request_timeout_ms=30_000,
84
+ )
85
+ print(stock["inStock"])
86
+ except APIError as error:
87
+ print(error.status, error.code, error.request_id, error.retry_after_s)
88
+ ```
89
+
90
+ - Constructor: `ProductInformationAPI(api_key=None, *, base_url="https://api.productinformationapi.com", timeout=120)`.
91
+ - All methods accept `idempotency_key` and `timeout` in seconds. `timeout` is the standard-library
92
+ socket timeout for blocking operations, not a guaranteed total wall-clock deadline.
93
+ - Singular methods accept `source_site`. `get_product_information` also accepts
94
+ `include_gpsr=True` for Amazon DE GPSR details. Bulk requests do not accept GPSR options.
95
+ - `request_timeout_ms` applies only to singular stock requests, from 1000 to 100000 milliseconds.
96
+ It shortens the server execution budget. Leave additional time in `timeout` for server cleanup.
97
+ - Timeouts do not prove the server did no work or charged no credits. There are no automatic
98
+ retries. Reuse an idempotency key only for the same operation and payload; keep bulk items in
99
+ the same order on a retry. Keys must contain 1–255 visible ASCII characters.
100
+ - `APIError` exposes `status`, `code`, `request_id`, `retryable`, `retry_after_s`, and the parsed
101
+ response as `body`. HTML edge failures still produce `APIError`. Bulk item errors remain in
102
+ `batch["results"]`. Transport failures retain standard-library exceptions such as `URLError`
103
+ and `TimeoutError`.
104
+
105
+ ## Webhooks
106
+
107
+ Scrape-result webhooks and asynchronous jobs are not currently implemented by the API. These
108
+ methods return the result directly; no webhook URL option is available.
109
+
110
+ ## Support and license
111
+
112
+ Visit [Product Information API](https://productinformationapi.com) or contact
113
+ [contact@hustlegotreal.com](mailto:contact@hustlegotreal.com).
114
+
115
+ This SDK is MIT licensed. API access requires a separate account and is subject to the service's
116
+ terms and credit usage.
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0.3"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "productinformationapi"
7
+ version = "0.1.0"
8
+ description = "Product information and stock checks with Product Information API"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{name = "HustleGotReal", email = "contact@hustlegotreal.com"}]
13
+ keywords = ["product-information", "stock", "scraping", "api", "sdk"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3 :: Only",
16
+ "Operating System :: OS Independent",
17
+ "Typing :: Typed",
18
+ ]
19
+ requires-python = ">=3.11"
20
+ dependencies = []
21
+
22
+ [project.urls]
23
+ Homepage = "https://productinformationapi.com"
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["src"]
27
+
28
+ [tool.setuptools.package-data]
29
+ productinformationapi = ["py.typed"]
30
+
31
+ [tool.ruff]
32
+ target-version = "py311"
33
+
34
+ [tool.ruff.lint]
35
+ select = ["E", "F", "I", "UP", "B"]
36
+ ignore = ["E501"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """Small, dependency-free client for Product Information API."""
2
+
3
+ from .client import APIError, ProductInformationAPI
4
+
5
+ __all__ = ["APIError", "ProductInformationAPI"]
@@ -0,0 +1,191 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import math
5
+ import os
6
+ import re
7
+ from typing import Any
8
+ from urllib.error import HTTPError
9
+ from urllib.parse import urlsplit
10
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
11
+
12
+ DEFAULT_BASE_URL = "https://api.productinformationapi.com"
13
+
14
+
15
+ class APIError(Exception):
16
+ """HTTP failure with the server's error code, request ID, and retry advice."""
17
+
18
+ def __init__(self, status: int, body: Any, headers: Any, *, invalid_response: bool = False):
19
+ error = body.get("error") if isinstance(body, dict) else None
20
+ error = error if isinstance(error, dict) else {}
21
+ message = error.get("message")
22
+ if not isinstance(message, str):
23
+ message = f"API returned HTTP {status}"
24
+ if invalid_response:
25
+ message += " with an invalid JSON response"
26
+ super().__init__(message)
27
+ self.status = status
28
+ self.code = error.get("code", "invalid_response" if invalid_response else "http_error")
29
+ self.request_id = error.get("requestId") or headers.get("X-Request-ID")
30
+ self.retryable = error.get("retryable") is True
31
+ retry_after = error.get("retryAfterS", headers.get("Retry-After"))
32
+ self.retry_after_s = (
33
+ int(retry_after) if re.fullmatch(r"[0-9]+", str(retry_after)) else None
34
+ )
35
+ self.body = body
36
+
37
+
38
+ class _NoRedirect(HTTPRedirectHandler):
39
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
40
+ return None
41
+
42
+
43
+ def _required_string(value: Any, name: str) -> str:
44
+ if not isinstance(value, str) or not value.strip():
45
+ raise ValueError(f"{name} must be a non-empty string")
46
+ return value.strip()
47
+
48
+
49
+ def _timeout(value: float) -> float:
50
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0:
51
+ raise ValueError("timeout must be a positive number of seconds")
52
+ return value
53
+
54
+
55
+ def _product_body(product_url: str, source_site: str | None = None) -> dict[str, Any]:
56
+ body = {"productUrl": _required_string(product_url, "product_url")}
57
+ if source_site is not None:
58
+ body["sourceSite"] = _required_string(source_site, "source_site")
59
+ return body
60
+
61
+
62
+ def _bulk_body(items: list[str | dict[str, str]]) -> dict[str, Any]:
63
+ if not isinstance(items, list) or not items:
64
+ raise ValueError("items must be a non-empty list of URLs or productUrl/sourceSite dictionaries")
65
+ result = []
66
+ for item in items:
67
+ if isinstance(item, str):
68
+ result.append(_product_body(item))
69
+ elif isinstance(item, dict) and not item.keys() - {"productUrl", "sourceSite"}:
70
+ result.append(_product_body(item.get("productUrl"), item.get("sourceSite")))
71
+ else:
72
+ raise ValueError("each bulk item must be a URL or productUrl/sourceSite dictionary")
73
+ return {"items": result}
74
+
75
+
76
+ class ProductInformationAPI:
77
+ """Synchronous client. Uses tenant API keys and never automatically retries requests."""
78
+
79
+ def __init__(
80
+ self,
81
+ api_key: str | None = None,
82
+ *,
83
+ base_url: str = DEFAULT_BASE_URL,
84
+ timeout: float = 120,
85
+ ):
86
+ self._api_key = _required_string(
87
+ os.environ.get("PRODUCTINFORMATIONAPI_API_KEY") if api_key is None else api_key,
88
+ "api_key",
89
+ )
90
+ if not re.fullmatch(r"[!-~]+", self._api_key):
91
+ raise ValueError("api_key must contain visible ASCII only")
92
+ url = urlsplit(base_url)
93
+ local = url.hostname in {"localhost", "127.0.0.1", "::1"}
94
+ if (
95
+ not url.hostname
96
+ or (url.scheme != "https" and not (url.scheme == "http" and local))
97
+ or url.username is not None
98
+ or url.password is not None
99
+ or url.query
100
+ or url.fragment
101
+ ):
102
+ raise ValueError("base_url must use HTTPS (HTTP is allowed on localhost) without credentials, query, or fragment")
103
+ self._base_url = base_url.rstrip("/")
104
+ self._timeout = _timeout(timeout)
105
+ self._opener = build_opener(_NoRedirect())
106
+
107
+ def get_product_information(
108
+ self,
109
+ product_url: str,
110
+ *,
111
+ source_site: str | None = None,
112
+ include_gpsr: bool | None = None,
113
+ idempotency_key: str | None = None,
114
+ timeout: float | None = None,
115
+ ) -> dict[str, Any]:
116
+ body = _product_body(product_url, source_site)
117
+ if include_gpsr is not None:
118
+ if not isinstance(include_gpsr, bool):
119
+ raise ValueError("include_gpsr must be a boolean")
120
+ body["includeGpsr"] = include_gpsr
121
+ return self._post("/v1/product-information", body, idempotency_key, timeout)
122
+
123
+ def check_stock(
124
+ self,
125
+ product_url: str,
126
+ *,
127
+ source_site: str | None = None,
128
+ idempotency_key: str | None = None,
129
+ timeout: float | None = None,
130
+ request_timeout_ms: int | None = None,
131
+ ) -> dict[str, Any]:
132
+ headers = {}
133
+ if request_timeout_ms is not None:
134
+ if isinstance(request_timeout_ms, bool) or not isinstance(request_timeout_ms, int) or not 1000 <= request_timeout_ms <= 100_000:
135
+ raise ValueError("request_timeout_ms must be an integer between 1000 and 100000")
136
+ if request_timeout_ms >= _timeout(self._timeout if timeout is None else timeout) * 1000:
137
+ raise ValueError("timeout must exceed request_timeout_ms / 1000; allow extra time for server cleanup")
138
+ headers["X-Request-Timeout-Ms"] = str(request_timeout_ms)
139
+ return self._post("/v1/product-stock", _product_body(product_url, source_site), idempotency_key, timeout, headers)
140
+
141
+ def get_product_information_bulk(
142
+ self,
143
+ items: list[str | dict[str, str]],
144
+ *,
145
+ idempotency_key: str | None = None,
146
+ timeout: float | None = None,
147
+ ) -> dict[str, Any]:
148
+ return self._post("/v1/product-information/bulk", _bulk_body(items), idempotency_key, timeout)
149
+
150
+ def check_stock_bulk(
151
+ self,
152
+ items: list[str | dict[str, str]],
153
+ *,
154
+ idempotency_key: str | None = None,
155
+ timeout: float | None = None,
156
+ ) -> dict[str, Any]:
157
+ return self._post("/v1/product-stock/bulk", _bulk_body(items), idempotency_key, timeout)
158
+
159
+ def _post(self, path, body, idempotency_key, timeout, extra_headers=None) -> dict[str, Any]:
160
+ headers = {
161
+ "Authorization": f"Bearer {self._api_key}",
162
+ "Accept": "application/json",
163
+ "Content-Type": "application/json",
164
+ "User-Agent": "ProductInformationAPI-Python/0.1.0",
165
+ **(extra_headers or {}),
166
+ }
167
+ if idempotency_key is not None:
168
+ if not isinstance(idempotency_key, str) or not re.fullmatch(r"[!-~]{1,255}", idempotency_key):
169
+ raise ValueError("idempotency_key must be 1-255 visible ASCII characters")
170
+ headers["Idempotency-Key"] = idempotency_key
171
+ request = Request(
172
+ f"{self._base_url}{path}",
173
+ data=json.dumps(body).encode("utf-8"),
174
+ headers=headers,
175
+ method="POST",
176
+ )
177
+ try:
178
+ response = self._opener.open(request, timeout=_timeout(self._timeout if timeout is None else timeout))
179
+ except HTTPError as exc:
180
+ response = exc
181
+ with response:
182
+ raw = response.read()
183
+ try:
184
+ result = json.loads(raw)
185
+ except (ValueError, UnicodeDecodeError):
186
+ result = None
187
+ if not 200 <= response.status < 300:
188
+ raise APIError(response.status, result, response.headers)
189
+ if not isinstance(result, dict):
190
+ raise APIError(response.status, result, response.headers, invalid_response=True)
191
+ return result
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: productinformationapi
3
+ Version: 0.1.0
4
+ Summary: Product information and stock checks with Product Information API
5
+ Author-email: HustleGotReal <contact@hustlegotreal.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://productinformationapi.com
8
+ Keywords: product-information,stock,scraping,api,sdk
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Typing :: Typed
12
+ Requires-Python: >=3.11
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Dynamic: license-file
16
+
17
+ # Product Information API for Python
18
+
19
+ Product information and stock checks in Python 3.11+, with type hints and no runtime dependencies.
20
+
21
+ ## Install
22
+
23
+ ```sh
24
+ python -m pip install productinformationapi
25
+ ```
26
+
27
+ Or install the distributed wheel with `python -m pip install ./productinformationapi-0.1.0-py3-none-any.whl`.
28
+
29
+ ## Quick start
30
+
31
+ Use a tenant API key from your Product Information API account. MCP OAuth access tokens are scoped
32
+ to MCP and cannot authenticate these REST requests. Keep the key in your server environment:
33
+
34
+ ```sh
35
+ export PRODUCTINFORMATIONAPI_API_KEY='your-api-key'
36
+ ```
37
+
38
+ ```python
39
+ from productinformationapi import ProductInformationAPI
40
+
41
+ client = ProductInformationAPI() # Reads PRODUCTINFORMATIONAPI_API_KEY.
42
+ url = "https://www.amazon.com/dp/B0D3BCR3V7"
43
+
44
+ product = client.get_product_information(url)
45
+ print((product.get("productInformation") or {}).get("title"), product["creditsCharged"])
46
+
47
+ stock = client.check_stock(url)
48
+ print(stock["inStock"], stock["offers"], stock["creditsRemaining"])
49
+ ```
50
+
51
+ Methods return the complete API response as a dictionary. Response keys retain the API's camelCase
52
+ names. The client is synchronous; in an asyncio application, use a worker thread:
53
+
54
+ ```python
55
+ import asyncio
56
+
57
+ stock = await asyncio.to_thread(client.check_stock, url)
58
+ ```
59
+
60
+ Cancelling the asyncio waiter does not cancel the worker thread's HTTP request; its socket timeout
61
+ still applies.
62
+
63
+ ## Bulk
64
+
65
+ ```python
66
+ batch = client.check_stock_bulk([
67
+ "https://www.amazon.com/dp/B0D3BCR3V7",
68
+ {"productUrl": "https://www.amazon.co.uk/dp/B0D3BCR3V7", "sourceSite": "amazon_gb"},
69
+ ])
70
+
71
+ for row in batch["results"]:
72
+ if row["httpStatus"] == 200:
73
+ print(row["index"], row["result"])
74
+ else:
75
+ print(row["index"], row["httpStatus"], row["result"]["error"])
76
+
77
+ # Product information uses the same inputs:
78
+ products = client.get_product_information_bulk([
79
+ "https://www.amazon.com/dp/B0D3BCR3V7",
80
+ ])
81
+ ```
82
+
83
+ Bulk calls return inline. A successful HTTP response can contain failed items; inspect each
84
+ `httpStatus`. The current default server limit is 100 items per batch. Items are never silently
85
+ split, retried, or reordered. Large batches may require a longer HTTP timeout.
86
+
87
+ ## Options and errors
88
+
89
+ ```python
90
+ from uuid import uuid4
91
+ from productinformationapi import APIError
92
+
93
+ request_key = str(uuid4()) # Retain this key if you retry this exact request.
94
+ try:
95
+ stock = client.check_stock(
96
+ url,
97
+ idempotency_key=request_key,
98
+ timeout=120,
99
+ request_timeout_ms=30_000,
100
+ )
101
+ print(stock["inStock"])
102
+ except APIError as error:
103
+ print(error.status, error.code, error.request_id, error.retry_after_s)
104
+ ```
105
+
106
+ - Constructor: `ProductInformationAPI(api_key=None, *, base_url="https://api.productinformationapi.com", timeout=120)`.
107
+ - All methods accept `idempotency_key` and `timeout` in seconds. `timeout` is the standard-library
108
+ socket timeout for blocking operations, not a guaranteed total wall-clock deadline.
109
+ - Singular methods accept `source_site`. `get_product_information` also accepts
110
+ `include_gpsr=True` for Amazon DE GPSR details. Bulk requests do not accept GPSR options.
111
+ - `request_timeout_ms` applies only to singular stock requests, from 1000 to 100000 milliseconds.
112
+ It shortens the server execution budget. Leave additional time in `timeout` for server cleanup.
113
+ - Timeouts do not prove the server did no work or charged no credits. There are no automatic
114
+ retries. Reuse an idempotency key only for the same operation and payload; keep bulk items in
115
+ the same order on a retry. Keys must contain 1–255 visible ASCII characters.
116
+ - `APIError` exposes `status`, `code`, `request_id`, `retryable`, `retry_after_s`, and the parsed
117
+ response as `body`. HTML edge failures still produce `APIError`. Bulk item errors remain in
118
+ `batch["results"]`. Transport failures retain standard-library exceptions such as `URLError`
119
+ and `TimeoutError`.
120
+
121
+ ## Webhooks
122
+
123
+ Scrape-result webhooks and asynchronous jobs are not currently implemented by the API. These
124
+ methods return the result directly; no webhook URL option is available.
125
+
126
+ ## Support and license
127
+
128
+ Visit [Product Information API](https://productinformationapi.com) or contact
129
+ [contact@hustlegotreal.com](mailto:contact@hustlegotreal.com).
130
+
131
+ This SDK is MIT licensed. API access requires a separate account and is subject to the service's
132
+ terms and credit usage.
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/productinformationapi/__init__.py
5
+ src/productinformationapi/client.py
6
+ src/productinformationapi/py.typed
7
+ src/productinformationapi.egg-info/PKG-INFO
8
+ src/productinformationapi.egg-info/SOURCES.txt
9
+ src/productinformationapi.egg-info/dependency_links.txt
10
+ src/productinformationapi.egg-info/top_level.txt
11
+ tests/test_client.py
@@ -0,0 +1,177 @@
1
+ import json
2
+ import os
3
+ import threading
4
+ import unittest
5
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
6
+ from unittest.mock import patch
7
+
8
+ from productinformationapi import APIError, ProductInformationAPI
9
+
10
+ URL = "https://www.amazon.com/dp/B0D3BCR3V7"
11
+ STOCK = {"outcome": "OK", "inStock": True, "offers": [], "creditsCharged": 1, "creditsRemaining": 9}
12
+
13
+
14
+ class ClientTests(unittest.TestCase):
15
+ def setUp(self):
16
+ self.requests = []
17
+ self.status = 200
18
+ self.body = json.dumps(STOCK).encode()
19
+ self.headers = {}
20
+ self.stall_body = False
21
+ self.stop = threading.Event()
22
+ owner = self
23
+
24
+ class Handler(BaseHTTPRequestHandler):
25
+ def do_POST(self):
26
+ owner.requests.append({
27
+ "path": self.path,
28
+ "headers": self.headers,
29
+ "body": json.loads(self.rfile.read(int(self.headers["Content-Length"]))),
30
+ })
31
+ self.send_response(owner.status)
32
+ for name, value in owner.headers.items():
33
+ self.send_header(name, value)
34
+ self.end_headers()
35
+ if owner.stall_body:
36
+ self.wfile.write(b'{"outcome":')
37
+ self.wfile.flush()
38
+ owner.stop.wait(5)
39
+ else:
40
+ self.wfile.write(owner.body)
41
+
42
+ def log_message(self, *_args):
43
+ pass
44
+
45
+ self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
46
+ self.thread = threading.Thread(target=self.server.serve_forever, kwargs={"poll_interval": 0.01})
47
+ self.thread.start()
48
+ self.base_url = f"http://127.0.0.1:{self.server.server_port}"
49
+ self.client = ProductInformationAPI("test-key", base_url=self.base_url + "/")
50
+
51
+ def tearDown(self):
52
+ self.stop.set()
53
+ self.server.shutdown()
54
+ self.server.server_close()
55
+ self.thread.join()
56
+
57
+ def test_all_four_operations_match_public_contract(self):
58
+ result = self.client.get_product_information(URL, source_site="amazon_us", include_gpsr=False, idempotency_key="info-1")
59
+ self.assertEqual(result, STOCK)
60
+ self.client.check_stock(URL, request_timeout_ms=30_000, idempotency_key="stock-1")
61
+ self.client.get_product_information_bulk([URL, {"productUrl": URL, "sourceSite": "amazon_us"}], idempotency_key="batch-1")
62
+ self.client.check_stock_bulk([URL])
63
+ self.assertEqual([r["path"] for r in self.requests], [
64
+ "/v1/product-information", "/v1/product-stock", "/v1/product-information/bulk", "/v1/product-stock/bulk",
65
+ ])
66
+ self.assertEqual([r["body"] for r in self.requests], [
67
+ {"productUrl": URL, "sourceSite": "amazon_us", "includeGpsr": False},
68
+ {"productUrl": URL},
69
+ {"items": [{"productUrl": URL}, {"productUrl": URL, "sourceSite": "amazon_us"}]},
70
+ {"items": [{"productUrl": URL}]},
71
+ ])
72
+ for request in self.requests:
73
+ self.assertEqual(request["headers"]["Authorization"], "Bearer test-key")
74
+ self.assertEqual(request["headers"]["Content-Type"], "application/json")
75
+ self.assertTrue(request["headers"]["User-Agent"].startswith("ProductInformationAPI-Python/"))
76
+ self.assertEqual(self.requests[0]["headers"]["Idempotency-Key"], "info-1")
77
+ self.assertEqual(self.requests[1]["headers"]["X-Request-Timeout-Ms"], "30000")
78
+ self.assertEqual(self.requests[2]["headers"]["Idempotency-Key"], "batch-1")
79
+ self.assertIsNone(self.requests[3]["headers"]["Idempotency-Key"])
80
+
81
+ def test_bulk_preserves_partial_errors_and_order(self):
82
+ batch = {
83
+ "requestId": "req_bulk", "total": 2, "succeeded": 1, "failed": 1, "retryable": 1,
84
+ "results": [
85
+ {"index": 0, "httpStatus": 200, "result": STOCK},
86
+ {"index": 1, "httpStatus": 503, "result": {"error": {"code": "scrape_unavailable", "retryable": True}}},
87
+ ],
88
+ }
89
+ self.body = json.dumps(batch).encode()
90
+ self.assertEqual(self.client.check_stock_bulk([URL, URL]), batch)
91
+
92
+ def test_api_errors_preserve_metadata_without_retrying(self):
93
+ for index, status in enumerate((401, 402, 409, 429, 503), 1):
94
+ with self.subTest(status=status):
95
+ body = {"error": {"code": "example_error", "message": "Request unavailable", "requestId": "req_123", "retryable": status >= 429, "retryAfterS": 7}}
96
+ self.status = status
97
+ self.body = json.dumps(body).encode()
98
+ self.headers = {"Retry-After": "9"}
99
+ with self.assertRaises(APIError) as caught:
100
+ self.client.check_stock(URL)
101
+ error = caught.exception
102
+ self.assertEqual(error.status, status)
103
+ self.assertEqual(error.code, "example_error")
104
+ self.assertEqual(error.request_id, "req_123")
105
+ self.assertEqual(error.retry_after_s, 7)
106
+ self.assertEqual(error.retryable, status >= 429)
107
+ self.assertEqual(error.body, body)
108
+ self.assertEqual(len(self.requests), index)
109
+
110
+ def test_html_edge_error_preserves_headers(self):
111
+ self.status = 403
112
+ self.body = b"<html>Forbidden</html>"
113
+ self.headers = {"X-Request-ID": "edge_1", "Retry-After": "2"}
114
+ with self.assertRaises(APIError) as caught:
115
+ self.client.check_stock(URL)
116
+ self.assertEqual(caught.exception.status, 403)
117
+ self.assertEqual(caught.exception.request_id, "edge_1")
118
+ self.assertEqual(caught.exception.retry_after_s, 2)
119
+ self.assertIsNone(caught.exception.body)
120
+
121
+ def test_malformed_success(self):
122
+ for body in (b"not json", b"[]", b"null"):
123
+ self.body = body
124
+ with self.assertRaises(APIError) as caught:
125
+ self.client.check_stock(URL)
126
+ self.assertEqual(caught.exception.code, "invalid_response")
127
+ self.assertEqual(caught.exception.status, 200)
128
+
129
+ def test_does_not_follow_redirects(self):
130
+ for status in (301, 302, 303, 307, 308):
131
+ self.status = status
132
+ self.headers = {"Location": self.base_url + "/redirect-target"}
133
+ with self.assertRaises(APIError) as caught:
134
+ self.client.check_stock(URL)
135
+ self.assertEqual(caught.exception.status, status)
136
+ self.assertEqual(len(self.requests), 5)
137
+ self.assertTrue(all(r["path"] == "/v1/product-stock" for r in self.requests))
138
+
139
+ def test_timeout_covers_stalled_response_body(self):
140
+ self.stall_body = True
141
+ with self.assertRaises(TimeoutError):
142
+ self.client.check_stock(URL, timeout=0.1)
143
+ self.assertEqual(len(self.requests), 1)
144
+
145
+ def test_invalid_inputs_are_rejected_before_sending(self):
146
+ for operation in (
147
+ lambda: self.client.check_stock(""),
148
+ lambda: self.client.check_stock_bulk([]),
149
+ lambda: self.client.check_stock_bulk([{"productUrl": URL, "includeGpsr": True}]),
150
+ lambda: self.client.get_product_information(URL, include_gpsr="true"),
151
+ lambda: self.client.check_stock(URL, request_timeout_ms=100),
152
+ lambda: self.client.check_stock(URL, request_timeout_ms=5000, timeout=5),
153
+ lambda: self.client.check_stock(URL, timeout=0),
154
+ lambda: self.client.check_stock(URL, timeout=float("nan")),
155
+ ):
156
+ with self.assertRaises(ValueError):
157
+ operation()
158
+ for key in ("", "has spaces", "x" * 256, "new\nline"):
159
+ with self.assertRaises(ValueError):
160
+ self.client.check_stock(URL, idempotency_key=key)
161
+ self.assertEqual(self.requests, [])
162
+
163
+ def test_credential_and_transport_configuration(self):
164
+ for base_url in ("http://example.com", "ftp://example.com", "https://user:pass@example.com", "https://example.com?key=x", "https://example.com#x"):
165
+ with self.assertRaises(ValueError):
166
+ ProductInformationAPI("test-key", base_url=base_url)
167
+ for api_key in ("", "key\nInjected"):
168
+ with self.assertRaises(ValueError):
169
+ ProductInformationAPI(api_key)
170
+ self.assertNotIn("test-key", repr(self.client))
171
+ with patch.dict(os.environ, {"PRODUCTINFORMATIONAPI_API_KEY": "environment-key"}):
172
+ ProductInformationAPI(base_url=self.base_url).check_stock(URL)
173
+ self.assertEqual(self.requests[-1]["headers"]["Authorization"], "Bearer environment-key")
174
+
175
+
176
+ if __name__ == "__main__":
177
+ unittest.main()