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.
- productinformationapi-0.1.0/LICENSE +21 -0
- productinformationapi-0.1.0/PKG-INFO +132 -0
- productinformationapi-0.1.0/README.md +116 -0
- productinformationapi-0.1.0/pyproject.toml +36 -0
- productinformationapi-0.1.0/setup.cfg +4 -0
- productinformationapi-0.1.0/src/productinformationapi/__init__.py +5 -0
- productinformationapi-0.1.0/src/productinformationapi/client.py +191 -0
- productinformationapi-0.1.0/src/productinformationapi/py.typed +0 -0
- productinformationapi-0.1.0/src/productinformationapi.egg-info/PKG-INFO +132 -0
- productinformationapi-0.1.0/src/productinformationapi.egg-info/SOURCES.txt +11 -0
- productinformationapi-0.1.0/src/productinformationapi.egg-info/dependency_links.txt +1 -0
- productinformationapi-0.1.0/src/productinformationapi.egg-info/top_level.txt +1 -0
- productinformationapi-0.1.0/tests/test_client.py +177 -0
|
@@ -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,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
|
|
File without changes
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
productinformationapi
|
|
@@ -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()
|