storerouter 0.1.0__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.
- storerouter/__init__.py +90 -0
- storerouter/_client.py +348 -0
- storerouter/_retry.py +92 -0
- storerouter/models.py +25 -0
- storerouter/py.typed +0 -0
- storerouter-0.1.0.dist-info/METADATA +391 -0
- storerouter-0.1.0.dist-info/RECORD +8 -0
- storerouter-0.1.0.dist-info/WHEEL +4 -0
storerouter/__init__.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""StoreRouter Python SDK: structured product data from real merchant catalogs.
|
|
2
|
+
|
|
3
|
+
Look up any product URL as normalized JSON, search products across stores,
|
|
4
|
+
and discover brands and stores::
|
|
5
|
+
|
|
6
|
+
from storerouter import StoreRouter
|
|
7
|
+
|
|
8
|
+
with StoreRouter() as client: # reads STOREROUTER_API_KEY
|
|
9
|
+
product = client.lookup_product("https://www.allbirds.com/products/mens-tree-runners").product
|
|
10
|
+
page = client.search_products(q="linen shirt", stores=["everlane.com"], limit=5)
|
|
11
|
+
|
|
12
|
+
:class:`AsyncStoreRouter` has the same methods as coroutines.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import importlib.metadata as _metadata
|
|
16
|
+
|
|
17
|
+
from octogen_ai_sdk import (
|
|
18
|
+
Facet,
|
|
19
|
+
MissingAPIKeyError,
|
|
20
|
+
RequestContext,
|
|
21
|
+
RequestContextDefaults,
|
|
22
|
+
SearchFacet,
|
|
23
|
+
)
|
|
24
|
+
from octogen_ai_sdk import OctogenAPIError as APIError
|
|
25
|
+
from octogen_ai_sdk import OctogenAuthenticationError as AuthenticationError
|
|
26
|
+
from octogen_ai_sdk import OctogenConnectionError as APIConnectionError
|
|
27
|
+
from octogen_ai_sdk import OctogenError as StoreRouterError
|
|
28
|
+
from octogen_ai_sdk import OctogenForbiddenError as PermissionDeniedError
|
|
29
|
+
from octogen_ai_sdk import OctogenInsufficientCreditError as InsufficientCreditError
|
|
30
|
+
from octogen_ai_sdk import OctogenNotFoundError as NotFoundError
|
|
31
|
+
from octogen_ai_sdk import OctogenRateLimitError as RateLimitError
|
|
32
|
+
from octogen_ai_sdk import OctogenServerError as InternalServerError
|
|
33
|
+
from octogen_ai_sdk import OctogenTimeoutError as APITimeoutError
|
|
34
|
+
from octogen_ai_sdk import OctogenValidationError as ValidationError
|
|
35
|
+
|
|
36
|
+
from storerouter import models
|
|
37
|
+
from storerouter._client import (
|
|
38
|
+
API_KEY_ENV_VAR,
|
|
39
|
+
BASE_URL_ENV_VAR,
|
|
40
|
+
DEFAULT_BASE_URL,
|
|
41
|
+
AsyncStoreRouter,
|
|
42
|
+
LookupResult,
|
|
43
|
+
StoreRouter,
|
|
44
|
+
)
|
|
45
|
+
from storerouter._retry import RetryTransport
|
|
46
|
+
from storerouter.models import (
|
|
47
|
+
Product,
|
|
48
|
+
ProductListItem,
|
|
49
|
+
ProductLookupResponse,
|
|
50
|
+
ProductSearchPage,
|
|
51
|
+
ProductVariant,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
__version__ = _metadata.version("storerouter")
|
|
56
|
+
except _metadata.PackageNotFoundError: # pragma: no cover - source checkout
|
|
57
|
+
__version__ = "0.0.0"
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"API_KEY_ENV_VAR",
|
|
61
|
+
"BASE_URL_ENV_VAR",
|
|
62
|
+
"DEFAULT_BASE_URL",
|
|
63
|
+
"APIConnectionError",
|
|
64
|
+
"APIError",
|
|
65
|
+
"APITimeoutError",
|
|
66
|
+
"AsyncStoreRouter",
|
|
67
|
+
"AuthenticationError",
|
|
68
|
+
"Facet",
|
|
69
|
+
"InsufficientCreditError",
|
|
70
|
+
"InternalServerError",
|
|
71
|
+
"LookupResult",
|
|
72
|
+
"MissingAPIKeyError",
|
|
73
|
+
"NotFoundError",
|
|
74
|
+
"PermissionDeniedError",
|
|
75
|
+
"Product",
|
|
76
|
+
"ProductListItem",
|
|
77
|
+
"ProductLookupResponse",
|
|
78
|
+
"ProductSearchPage",
|
|
79
|
+
"ProductVariant",
|
|
80
|
+
"RateLimitError",
|
|
81
|
+
"RequestContext",
|
|
82
|
+
"RequestContextDefaults",
|
|
83
|
+
"RetryTransport",
|
|
84
|
+
"SearchFacet",
|
|
85
|
+
"StoreRouter",
|
|
86
|
+
"StoreRouterError",
|
|
87
|
+
"ValidationError",
|
|
88
|
+
"__version__",
|
|
89
|
+
"models",
|
|
90
|
+
]
|
storerouter/_client.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""The async :class:`AsyncStoreRouter` and blocking :class:`StoreRouter` clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import os
|
|
8
|
+
import threading
|
|
9
|
+
from collections.abc import Callable, Coroutine, Sequence
|
|
10
|
+
from concurrent.futures import Future
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from types import TracebackType
|
|
13
|
+
from typing import Any, Concatenate, Generic, ParamSpec, Self, TypeVar, overload
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from octogen_ai_sdk import (
|
|
17
|
+
MissingAPIKeyError,
|
|
18
|
+
OctogenAuthenticationError,
|
|
19
|
+
OctogenClient,
|
|
20
|
+
OctogenError,
|
|
21
|
+
OctogenForbiddenError,
|
|
22
|
+
OctogenInsufficientCreditError,
|
|
23
|
+
RequestContextDefaults,
|
|
24
|
+
)
|
|
25
|
+
from octogen_ai_sdk.models import (
|
|
26
|
+
MerchantProductUrlLookupResponse,
|
|
27
|
+
MerchantProductView,
|
|
28
|
+
ProductLookupCachePolicy,
|
|
29
|
+
ProductLookupMatchMode,
|
|
30
|
+
ProductLookupResolutionMode,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
from storerouter._retry import DEFAULT_MAX_RETRIES, RetryTransport
|
|
34
|
+
|
|
35
|
+
#: The variable the StoreRouter docs, CLI and integrations name. Prefer it.
|
|
36
|
+
API_KEY_ENV_VAR = "STOREROUTER_API_KEY"
|
|
37
|
+
#: Read when ``STOREROUTER_API_KEY`` is unset: the Octogen SDKs' own name.
|
|
38
|
+
FALLBACK_API_KEY_ENV_VAR = "OCTOGEN_PLATFORM_API_KEY"
|
|
39
|
+
BASE_URL_ENV_VAR = "STOREROUTER_BASE_URL"
|
|
40
|
+
DEFAULT_BASE_URL = "https://api.storerouter.ai/v1"
|
|
41
|
+
DEFAULT_TIMEOUT = 60.0
|
|
42
|
+
DEFAULT_LOOKUP_CONCURRENCY = 8
|
|
43
|
+
|
|
44
|
+
#: Sent as the request context's ``application`` unless you pass your own
|
|
45
|
+
#: ``default_context``, so StoreRouter can tell this SDK's traffic apart.
|
|
46
|
+
APPLICATION = "storerouter-python"
|
|
47
|
+
|
|
48
|
+
#: Errors every other URL in a batch would hit too. They stop the batch
|
|
49
|
+
#: instead of being recorded once per URL.
|
|
50
|
+
_BATCH_FATAL_ERRORS = (
|
|
51
|
+
OctogenAuthenticationError,
|
|
52
|
+
OctogenForbiddenError,
|
|
53
|
+
OctogenInsufficientCreditError,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class LookupResult:
|
|
59
|
+
"""One URL's outcome in :meth:`AsyncStoreRouter.lookup_products`.
|
|
60
|
+
|
|
61
|
+
Exactly one of ``response`` and ``error`` is set.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
url: str
|
|
65
|
+
response: MerchantProductUrlLookupResponse | None = None
|
|
66
|
+
error: OctogenError | None = None
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def ok(self) -> bool:
|
|
70
|
+
return self.error is None
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def product(self) -> MerchantProductView | None:
|
|
74
|
+
"""The normalized product, or ``None`` when the lookup failed."""
|
|
75
|
+
return self.response.product if self.response is not None else None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class AsyncStoreRouter(OctogenClient):
|
|
79
|
+
"""Async client for the StoreRouter API.
|
|
80
|
+
|
|
81
|
+
The API key comes from ``api_key``, else ``STOREROUTER_API_KEY``, else
|
|
82
|
+
``OCTOGEN_PLATFORM_API_KEY``. The base URL comes from ``base_url``, else
|
|
83
|
+
``STOREROUTER_BASE_URL``, else ``https://api.storerouter.ai/v1``.
|
|
84
|
+
|
|
85
|
+
429 and 5xx responses and failed connections are retried up to
|
|
86
|
+
``max_retries`` times, honoring ``Retry-After``. Pass ``http_client`` to
|
|
87
|
+
bring your own ``httpx.AsyncClient``; it is used as is, without retries
|
|
88
|
+
unless its transport is a :class:`storerouter.RetryTransport`.
|
|
89
|
+
|
|
90
|
+
Every method of ``octogen_ai_sdk.OctogenClient`` is available; this class
|
|
91
|
+
adds :meth:`lookup_products` for batches of URLs.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
api_key: str | None = None,
|
|
98
|
+
base_url: str | None = None,
|
|
99
|
+
timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
|
|
100
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
101
|
+
http_client: httpx.AsyncClient | None = None,
|
|
102
|
+
default_context: RequestContextDefaults | None = None,
|
|
103
|
+
) -> None:
|
|
104
|
+
resolved_key = (
|
|
105
|
+
api_key
|
|
106
|
+
or os.environ.get(API_KEY_ENV_VAR)
|
|
107
|
+
or os.environ.get(FALLBACK_API_KEY_ENV_VAR)
|
|
108
|
+
)
|
|
109
|
+
if not resolved_key:
|
|
110
|
+
raise MissingAPIKeyError(
|
|
111
|
+
f"StoreRouter API key required. Set {API_KEY_ENV_VAR} or pass "
|
|
112
|
+
"api_key. Get one at https://dev.storerouter.ai."
|
|
113
|
+
)
|
|
114
|
+
owns_client = http_client is None
|
|
115
|
+
if http_client is None:
|
|
116
|
+
http_client = httpx.AsyncClient(
|
|
117
|
+
timeout=timeout,
|
|
118
|
+
transport=RetryTransport(max_retries=max_retries),
|
|
119
|
+
)
|
|
120
|
+
super().__init__(
|
|
121
|
+
api_key=resolved_key,
|
|
122
|
+
base_url=(base_url or os.environ.get(BASE_URL_ENV_VAR) or DEFAULT_BASE_URL),
|
|
123
|
+
timeout=timeout,
|
|
124
|
+
http_client=http_client,
|
|
125
|
+
default_context=(
|
|
126
|
+
default_context
|
|
127
|
+
if default_context is not None
|
|
128
|
+
else RequestContextDefaults(application=APPLICATION)
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
# The base class only closes a client it created itself.
|
|
132
|
+
self._owns_client = owns_client
|
|
133
|
+
|
|
134
|
+
async def lookup_products(
|
|
135
|
+
self,
|
|
136
|
+
urls: Sequence[str],
|
|
137
|
+
*,
|
|
138
|
+
max_concurrency: int = DEFAULT_LOOKUP_CONCURRENCY,
|
|
139
|
+
match_mode: ProductLookupMatchMode | str | None = None,
|
|
140
|
+
resolution_mode: ProductLookupResolutionMode | str | None = None,
|
|
141
|
+
on_demand_cache_policy: ProductLookupCachePolicy | str | None = None,
|
|
142
|
+
timeout: float | httpx.Timeout | None = None,
|
|
143
|
+
) -> list[LookupResult]:
|
|
144
|
+
"""Look up many product URLs concurrently, one result per URL, in order.
|
|
145
|
+
|
|
146
|
+
A URL that fails (not found, invalid, still rate limited after
|
|
147
|
+
retries) gets a :class:`LookupResult` with ``error`` set, and the rest
|
|
148
|
+
of the batch carries on. Authentication, permission and credit errors
|
|
149
|
+
apply to every URL alike, so they are raised and stop the batch.
|
|
150
|
+
|
|
151
|
+
Each URL is one ``POST /v1/products/lookup`` call; only successful
|
|
152
|
+
lookups are billed.
|
|
153
|
+
"""
|
|
154
|
+
if isinstance(urls, str):
|
|
155
|
+
raise TypeError("lookup_products() takes a sequence of URLs, not a str")
|
|
156
|
+
if max_concurrency < 1:
|
|
157
|
+
raise ValueError("max_concurrency must be >= 1")
|
|
158
|
+
semaphore = asyncio.Semaphore(max_concurrency)
|
|
159
|
+
|
|
160
|
+
async def one(url: str) -> LookupResult:
|
|
161
|
+
async with semaphore:
|
|
162
|
+
try:
|
|
163
|
+
response = await self.lookup_product(
|
|
164
|
+
url,
|
|
165
|
+
match_mode=match_mode,
|
|
166
|
+
resolution_mode=resolution_mode,
|
|
167
|
+
on_demand_cache_policy=on_demand_cache_policy,
|
|
168
|
+
timeout=timeout,
|
|
169
|
+
)
|
|
170
|
+
except _BATCH_FATAL_ERRORS:
|
|
171
|
+
raise
|
|
172
|
+
except OctogenError as exc:
|
|
173
|
+
return LookupResult(url=url, error=exc)
|
|
174
|
+
return LookupResult(url=url, response=response)
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
async with asyncio.TaskGroup() as group:
|
|
178
|
+
tasks = [group.create_task(one(url)) for url in urls]
|
|
179
|
+
except* _BATCH_FATAL_ERRORS as group_error:
|
|
180
|
+
# Raise the first fatal error itself, not an ExceptionGroup.
|
|
181
|
+
raise group_error.exceptions[0] from None
|
|
182
|
+
return [task.result() for task in tasks]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
_P = ParamSpec("_P")
|
|
186
|
+
_R = TypeVar("_R")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class _Blocking(Generic[_P, _R]):
|
|
190
|
+
"""A blocking twin of an :class:`AsyncStoreRouter` method, same signature.
|
|
191
|
+
|
|
192
|
+
A descriptor rather than a plain wrapper function so type checkers bind
|
|
193
|
+
``self`` and show the async method's parameters on the sync client.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
def __init__(
|
|
197
|
+
self,
|
|
198
|
+
method: Callable[Concatenate[AsyncStoreRouter, _P], Coroutine[Any, Any, _R]],
|
|
199
|
+
) -> None:
|
|
200
|
+
self._name = ""
|
|
201
|
+
self.__doc__ = method.__doc__
|
|
202
|
+
# The async signature without ``self``, for help() and Jupyter.
|
|
203
|
+
signature = inspect.signature(method)
|
|
204
|
+
self._signature = signature.replace(
|
|
205
|
+
parameters=list(signature.parameters.values())[1:]
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def __set_name__(self, owner: type, name: str) -> None:
|
|
209
|
+
self._name = name
|
|
210
|
+
|
|
211
|
+
@overload
|
|
212
|
+
def __get__(self, instance: None, owner: type | None = None) -> Self: ...
|
|
213
|
+
|
|
214
|
+
@overload
|
|
215
|
+
def __get__(
|
|
216
|
+
self, instance: StoreRouter, owner: type | None = None
|
|
217
|
+
) -> Callable[_P, _R]: ...
|
|
218
|
+
|
|
219
|
+
def __get__(
|
|
220
|
+
self, instance: StoreRouter | None, owner: type | None = None
|
|
221
|
+
) -> Self | Callable[_P, _R]:
|
|
222
|
+
if instance is None:
|
|
223
|
+
return self
|
|
224
|
+
name = self._name
|
|
225
|
+
|
|
226
|
+
def call(*args: _P.args, **kwargs: _P.kwargs) -> _R:
|
|
227
|
+
return instance._run(getattr(instance._client, name)(*args, **kwargs))
|
|
228
|
+
|
|
229
|
+
call.__name__ = name
|
|
230
|
+
call.__qualname__ = f"StoreRouter.{name}"
|
|
231
|
+
call.__doc__ = self.__doc__
|
|
232
|
+
call.__dict__["__signature__"] = self._signature
|
|
233
|
+
return call
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class StoreRouter:
|
|
237
|
+
"""Blocking client for the StoreRouter API.
|
|
238
|
+
|
|
239
|
+
Same constructor and methods as :class:`AsyncStoreRouter`, without
|
|
240
|
+
``await``. It runs the async client on a private event-loop thread, so it
|
|
241
|
+
works anywhere, including inside Jupyter or a running event loop. Close it
|
|
242
|
+
with :meth:`close` or use it as a context manager.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
def __init__(
|
|
246
|
+
self,
|
|
247
|
+
*,
|
|
248
|
+
api_key: str | None = None,
|
|
249
|
+
base_url: str | None = None,
|
|
250
|
+
timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
|
|
251
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
252
|
+
default_context: RequestContextDefaults | None = None,
|
|
253
|
+
) -> None:
|
|
254
|
+
# Fail on a missing key here, before a thread exists to clean up.
|
|
255
|
+
self._client = AsyncStoreRouter(
|
|
256
|
+
api_key=api_key,
|
|
257
|
+
base_url=base_url,
|
|
258
|
+
timeout=timeout,
|
|
259
|
+
max_retries=max_retries,
|
|
260
|
+
default_context=default_context,
|
|
261
|
+
)
|
|
262
|
+
self._loop = asyncio.new_event_loop()
|
|
263
|
+
self._thread = threading.Thread(
|
|
264
|
+
target=self._loop.run_forever, name="storerouter-client", daemon=True
|
|
265
|
+
)
|
|
266
|
+
self._thread.start()
|
|
267
|
+
self._closed = False
|
|
268
|
+
|
|
269
|
+
def _run(self, coroutine: Coroutine[Any, Any, _R]) -> _R:
|
|
270
|
+
if self._closed:
|
|
271
|
+
coroutine.close()
|
|
272
|
+
raise RuntimeError("StoreRouter client is closed")
|
|
273
|
+
future: Future[_R] = asyncio.run_coroutine_threadsafe(coroutine, self._loop)
|
|
274
|
+
return future.result()
|
|
275
|
+
|
|
276
|
+
def close(self) -> None:
|
|
277
|
+
"""Close connections and stop the client's event-loop thread."""
|
|
278
|
+
if self._closed:
|
|
279
|
+
return
|
|
280
|
+
try:
|
|
281
|
+
self._run(self._client.aclose())
|
|
282
|
+
finally:
|
|
283
|
+
self._closed = True
|
|
284
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
285
|
+
self._thread.join()
|
|
286
|
+
self._loop.close()
|
|
287
|
+
|
|
288
|
+
def __enter__(self) -> Self:
|
|
289
|
+
return self
|
|
290
|
+
|
|
291
|
+
def __exit__(
|
|
292
|
+
self,
|
|
293
|
+
exc_type: type[BaseException] | None,
|
|
294
|
+
exc: BaseException | None,
|
|
295
|
+
traceback: TracebackType | None,
|
|
296
|
+
) -> None:
|
|
297
|
+
self.close()
|
|
298
|
+
|
|
299
|
+
# Products
|
|
300
|
+
lookup_product = _Blocking(AsyncStoreRouter.lookup_product)
|
|
301
|
+
lookup_products = _Blocking(AsyncStoreRouter.lookup_products)
|
|
302
|
+
lookup_product_markdown = _Blocking(AsyncStoreRouter.lookup_product_markdown)
|
|
303
|
+
parse_product_alias = _Blocking(AsyncStoreRouter.parse_product_alias)
|
|
304
|
+
retrieve_product = _Blocking(AsyncStoreRouter.retrieve_product)
|
|
305
|
+
retrieve_products = _Blocking(AsyncStoreRouter.retrieve_products)
|
|
306
|
+
resolve_product_from_html = _Blocking(AsyncStoreRouter.resolve_product_from_html)
|
|
307
|
+
search_products = _Blocking(AsyncStoreRouter.search_products)
|
|
308
|
+
count_product_facets = _Blocking(AsyncStoreRouter.count_product_facets)
|
|
309
|
+
more_like_this_products = _Blocking(AsyncStoreRouter.more_like_this_products)
|
|
310
|
+
|
|
311
|
+
# Stores
|
|
312
|
+
get_store = _Blocking(AsyncStoreRouter.get_store)
|
|
313
|
+
search_stores = _Blocking(AsyncStoreRouter.search_stores)
|
|
314
|
+
search_similar_stores = _Blocking(AsyncStoreRouter.search_similar_stores)
|
|
315
|
+
|
|
316
|
+
# Brands
|
|
317
|
+
search_brands = _Blocking(AsyncStoreRouter.search_brands)
|
|
318
|
+
search_brand_prefix = _Blocking(AsyncStoreRouter.search_brand_prefix)
|
|
319
|
+
search_similar_brands = _Blocking(AsyncStoreRouter.search_similar_brands)
|
|
320
|
+
get_brand_directory = _Blocking(AsyncStoreRouter.get_brand_directory)
|
|
321
|
+
get_brand_details = _Blocking(AsyncStoreRouter.get_brand_details)
|
|
322
|
+
get_brand_coverage = _Blocking(AsyncStoreRouter.get_brand_coverage)
|
|
323
|
+
browse_brand_products = _Blocking(AsyncStoreRouter.browse_brand_products)
|
|
324
|
+
get_brand_top_categories = _Blocking(AsyncStoreRouter.get_brand_top_categories)
|
|
325
|
+
|
|
326
|
+
# Taxonomy
|
|
327
|
+
list_taxonomy_categories = _Blocking(AsyncStoreRouter.list_taxonomy_categories)
|
|
328
|
+
list_taxonomy_attributes = _Blocking(AsyncStoreRouter.list_taxonomy_attributes)
|
|
329
|
+
list_taxonomy_attribute_values = _Blocking(
|
|
330
|
+
AsyncStoreRouter.list_taxonomy_attribute_values
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# Account and coverage URL lists
|
|
334
|
+
get_me = _Blocking(AsyncStoreRouter.get_me)
|
|
335
|
+
create_coverage_url_list = _Blocking(AsyncStoreRouter.create_coverage_url_list)
|
|
336
|
+
list_coverage_url_lists = _Blocking(AsyncStoreRouter.list_coverage_url_lists)
|
|
337
|
+
get_coverage_url_list = _Blocking(AsyncStoreRouter.get_coverage_url_list)
|
|
338
|
+
delete_coverage_url_list = _Blocking(AsyncStoreRouter.delete_coverage_url_list)
|
|
339
|
+
add_coverage_url_list_urls = _Blocking(AsyncStoreRouter.add_coverage_url_list_urls)
|
|
340
|
+
remove_coverage_url_list_urls = _Blocking(
|
|
341
|
+
AsyncStoreRouter.remove_coverage_url_list_urls
|
|
342
|
+
)
|
|
343
|
+
check_coverage_url_list_urls = _Blocking(
|
|
344
|
+
AsyncStoreRouter.check_coverage_url_list_urls
|
|
345
|
+
)
|
|
346
|
+
list_coverage_url_list_urls = _Blocking(
|
|
347
|
+
AsyncStoreRouter.list_coverage_url_list_urls
|
|
348
|
+
)
|
storerouter/_retry.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Retry 429 and 5xx responses at the transport, under every client method."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import random
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from email.utils import parsedate_to_datetime
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
#: Statuses worth another attempt: rate limited, or a server-side failure.
|
|
14
|
+
RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
15
|
+
|
|
16
|
+
DEFAULT_MAX_RETRIES = 2
|
|
17
|
+
_BACKOFF_BASE = 0.5
|
|
18
|
+
_BACKOFF_CAP = 8.0
|
|
19
|
+
#: A ``Retry-After`` longer than this is the server asking us to stop, not to
|
|
20
|
+
#: wait, so the response is returned (and raised) instead of slept on.
|
|
21
|
+
_RETRY_AFTER_CAP = 60.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RetryTransport(httpx.AsyncBaseTransport):
|
|
25
|
+
"""Wrap a transport and retry 429/5xx responses and failed connections.
|
|
26
|
+
|
|
27
|
+
Waits ``Retry-After`` when the server sends one, else exponential backoff
|
|
28
|
+
with full jitter. Only connection failures are retried, never read
|
|
29
|
+
timeouts: a request that timed out mid-flight may have done its (billed)
|
|
30
|
+
work, and an on-demand lookup can legitimately be slow.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
inner: httpx.AsyncBaseTransport | None = None,
|
|
36
|
+
*,
|
|
37
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
38
|
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
39
|
+
) -> None:
|
|
40
|
+
if max_retries < 0:
|
|
41
|
+
raise ValueError("max_retries must be >= 0")
|
|
42
|
+
self._inner = inner or httpx.AsyncHTTPTransport()
|
|
43
|
+
self._max_retries = max_retries
|
|
44
|
+
self._sleep = sleep
|
|
45
|
+
|
|
46
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
47
|
+
attempt = 0
|
|
48
|
+
while True:
|
|
49
|
+
try:
|
|
50
|
+
response = await self._inner.handle_async_request(request)
|
|
51
|
+
except (httpx.ConnectError, httpx.ConnectTimeout):
|
|
52
|
+
if attempt >= self._max_retries:
|
|
53
|
+
raise
|
|
54
|
+
delay = _backoff(attempt)
|
|
55
|
+
else:
|
|
56
|
+
if (
|
|
57
|
+
response.status_code not in RETRY_STATUSES
|
|
58
|
+
or attempt >= self._max_retries
|
|
59
|
+
):
|
|
60
|
+
return response
|
|
61
|
+
retry_after = _retry_after_seconds(response.headers.get("Retry-After"))
|
|
62
|
+
if retry_after is not None and retry_after > _RETRY_AFTER_CAP:
|
|
63
|
+
return response
|
|
64
|
+
delay = retry_after if retry_after is not None else _backoff(attempt)
|
|
65
|
+
await response.aclose()
|
|
66
|
+
attempt += 1
|
|
67
|
+
await self._sleep(delay)
|
|
68
|
+
|
|
69
|
+
async def aclose(self) -> None:
|
|
70
|
+
await self._inner.aclose()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _backoff(attempt: int) -> float:
|
|
74
|
+
return random.uniform(0, min(_BACKOFF_CAP, _BACKOFF_BASE * 2**attempt))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _retry_after_seconds(value: str | None) -> float | None:
|
|
78
|
+
"""``Retry-After`` as seconds: delta-seconds or an HTTP date."""
|
|
79
|
+
if not value:
|
|
80
|
+
return None
|
|
81
|
+
value = value.strip()
|
|
82
|
+
try:
|
|
83
|
+
return max(0.0, float(value))
|
|
84
|
+
except ValueError:
|
|
85
|
+
pass
|
|
86
|
+
try:
|
|
87
|
+
when = parsedate_to_datetime(value)
|
|
88
|
+
except (TypeError, ValueError):
|
|
89
|
+
return None
|
|
90
|
+
if when.tzinfo is None:
|
|
91
|
+
when = when.replace(tzinfo=UTC)
|
|
92
|
+
return max(0.0, (when - datetime.now(UTC)).total_seconds())
|
storerouter/models.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Every request and response model, re-exported from ``octogen_ai_sdk.models``.
|
|
2
|
+
|
|
3
|
+
The friendlier names below are aliases of the same classes:
|
|
4
|
+
|
|
5
|
+
- :data:`Product` -- the full normalized product a lookup returns.
|
|
6
|
+
- :data:`ProductListItem` -- one search, brand-browse or more-like-this result.
|
|
7
|
+
- :data:`ProductLookupResponse` -- a lookup's envelope: ``product`` plus how
|
|
8
|
+
it was resolved.
|
|
9
|
+
- :data:`ProductSearchPage` -- one page of search results.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from octogen_ai_sdk.models import * # noqa: F403
|
|
13
|
+
from octogen_ai_sdk.models import (
|
|
14
|
+
MerchantProductListItem,
|
|
15
|
+
MerchantProductUrlLookupResponse,
|
|
16
|
+
MerchantProductView,
|
|
17
|
+
MerchantVariantView,
|
|
18
|
+
ProgrammaticProductSearchPage,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
Product = MerchantProductView
|
|
22
|
+
ProductVariant = MerchantVariantView
|
|
23
|
+
ProductListItem = MerchantProductListItem
|
|
24
|
+
ProductLookupResponse = MerchantProductUrlLookupResponse
|
|
25
|
+
ProductSearchPage = ProgrammaticProductSearchPage
|
storerouter/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: storerouter
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for StoreRouter: look up any product URL as structured JSON, search products across stores, and discover brands and stores
|
|
5
|
+
Keywords: storerouter,ecommerce,product-data,product-search,product-lookup,web-scraping,structured-data,shopping,ai-agents,sdk
|
|
6
|
+
Author: StoreRouter (Octogen AI)
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
16
|
+
Classifier: Topic :: Office/Business
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Dist: httpx>=0.28.1
|
|
20
|
+
Requires-Dist: octogen-ai-sdk>=0.17.0,<1.0.0
|
|
21
|
+
Requires-Dist: pydantic>=2.0
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Project-URL: Homepage, https://storerouter.ai
|
|
24
|
+
Project-URL: Documentation, https://storerouter.ai/docs
|
|
25
|
+
Project-URL: Source, https://github.com/octogen-ai/octogen-dev/tree/main/sdks/storerouter-python
|
|
26
|
+
Project-URL: Issues, https://github.com/octogen-ai/octogen-dev/issues
|
|
27
|
+
Project-URL: Changelog, https://github.com/octogen-ai/octogen-dev/blob/main/sdks/storerouter-python/CHANGELOG.md
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# StoreRouter Python SDK
|
|
31
|
+
|
|
32
|
+
[](https://pypi.org/project/storerouter/)
|
|
33
|
+
[](https://pypi.org/project/storerouter/)
|
|
34
|
+
[](https://github.com/octogen-ai/octogen-dev/blob/main/LICENSE)
|
|
35
|
+
|
|
36
|
+
`storerouter` is the official Python SDK for [StoreRouter](https://storerouter.ai),
|
|
37
|
+
an API that returns **structured e-commerce product data**. Use it to:
|
|
38
|
+
|
|
39
|
+
- turn a product page URL into normalized JSON (title, brand, price, currency,
|
|
40
|
+
stock, variants, images, identifiers, taxonomy category and attributes);
|
|
41
|
+
- search products across thousands of stores, or only the stores you name;
|
|
42
|
+
- discover stores and brands.
|
|
43
|
+
|
|
44
|
+
Every result uses **one product schema**, whatever store it came from, and
|
|
45
|
+
every response is a typed Pydantic model. The package has sync and async
|
|
46
|
+
clients, and it retries 429 and 5xx responses for you.
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install storerouter
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quickstart
|
|
53
|
+
|
|
54
|
+
Get an API key at [dev.storerouter.ai](https://dev.storerouter.ai) and export it:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
export STOREROUTER_API_KEY="sk_..."
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from storerouter import StoreRouter
|
|
62
|
+
|
|
63
|
+
client = StoreRouter() # reads STOREROUTER_API_KEY
|
|
64
|
+
|
|
65
|
+
result = client.lookup_product(
|
|
66
|
+
"https://www.allbirds.com/products/mens-runner-nz-jersey-light-grey-syrah"
|
|
67
|
+
)
|
|
68
|
+
product = result.product
|
|
69
|
+
print(product.title, product.current_price, product.currency, product.in_stock)
|
|
70
|
+
# Men's Runner NZ Jersey - Light Grey/Syrah (Blizzard Sole) 110.0 USD True
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Look up a product URL
|
|
74
|
+
|
|
75
|
+
`lookup_product` takes the URL of any product page. It returns the indexed
|
|
76
|
+
product when StoreRouter already has it, and otherwise extracts the page on
|
|
77
|
+
demand. `result.source` says which: `"indexed"` or `"on_demand"`.
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
result = client.lookup_product(
|
|
81
|
+
"https://www.allbirds.com/products/mens-runner-nz-jersey-light-grey-syrah"
|
|
82
|
+
)
|
|
83
|
+
print(result.product.model_dump_json(by_alias=True, exclude_none=True, indent=2))
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
This is real output, shortened. It shows one of 19 variants and some of the
|
|
87
|
+
enrichment attributes. It leaves out the description, the long brand
|
|
88
|
+
description, the image list, the store's own attributes and the request ID on
|
|
89
|
+
image URLs.
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"uuid": "2b7a52cc-93e4-545a-999c-ed6f41f54ea6",
|
|
94
|
+
"catalogKey": "allbirds",
|
|
95
|
+
"productUrl": "https://www.allbirds.com/products/mens-runner-nz-jersey-light-grey-syrah",
|
|
96
|
+
"title": "Men's Runner NZ Jersey - Light Grey/Syrah (Blizzard Sole)",
|
|
97
|
+
"brand": { "name": "Allbirds", "slug": "allbirds", "url": "www.allbirds.com" },
|
|
98
|
+
"currentPrice": 110.0,
|
|
99
|
+
"originalPrice": 110.0,
|
|
100
|
+
"currency": "USD",
|
|
101
|
+
"inStock": true,
|
|
102
|
+
"availability": "InStock",
|
|
103
|
+
"primaryImage": {
|
|
104
|
+
"url": "https://cdn.shopify.com/s/files/1/1104/4168/files/A12548_26Q1_Runner-NZ-Jersey-Light-Grey-Syrah-Bizzard-Sole_PDP_LEFT.png?v=1774551568",
|
|
105
|
+
"cdnUrl": "https://cdn.storerouter.ai/91b22b320476b49b1d658a81b4f2aad6/6afbe5a521bcb04aa95fbb111487a33d.webp",
|
|
106
|
+
"width": 4000,
|
|
107
|
+
"height": 4000,
|
|
108
|
+
"mimeType": "image/webp"
|
|
109
|
+
},
|
|
110
|
+
"sizes": [
|
|
111
|
+
"8",
|
|
112
|
+
"8.5",
|
|
113
|
+
"9",
|
|
114
|
+
"9.5",
|
|
115
|
+
"10",
|
|
116
|
+
"10.5",
|
|
117
|
+
"11",
|
|
118
|
+
"11.5",
|
|
119
|
+
"12",
|
|
120
|
+
"12.5",
|
|
121
|
+
"13",
|
|
122
|
+
"13.5",
|
|
123
|
+
"14"
|
|
124
|
+
],
|
|
125
|
+
"colors": [{ "label": "Light Grey/Syrah (Blizzard Sole)" }],
|
|
126
|
+
"categories": [{ "name": "Shoes" }],
|
|
127
|
+
"variants": [
|
|
128
|
+
{
|
|
129
|
+
"sku": "mens-runner-nz-jersey-light-grey-syrah",
|
|
130
|
+
"productUrl": "https://www.allbirds.com/products/mens-runner-nz-jersey-light-grey-syrah",
|
|
131
|
+
"color": { "label": "Light Grey/Syrah (Blizzard Sole)" },
|
|
132
|
+
"inStock": true,
|
|
133
|
+
"availability": "InStock",
|
|
134
|
+
"currentPrice": 110.0,
|
|
135
|
+
"currency": "USD"
|
|
136
|
+
}
|
|
137
|
+
],
|
|
138
|
+
"identifiers": { "productId": "7258388856912", "productGroupId": "7258388856912" },
|
|
139
|
+
"details": { "materials": ["cotton"], "fit": [], "patterns": [] },
|
|
140
|
+
"audience": { "genders": ["male"], "ageGroups": ["adult"] },
|
|
141
|
+
"enrichment": {
|
|
142
|
+
"type": "sneakers",
|
|
143
|
+
"category_path": ["Apparel & Accessories", "Shoes", "Sneakers"],
|
|
144
|
+
"color_family": ["Gray", "Red", "White"],
|
|
145
|
+
"gender": "male",
|
|
146
|
+
"age_groups": ["adult"],
|
|
147
|
+
"attributes": {
|
|
148
|
+
"Activity Type": ["Running"],
|
|
149
|
+
"Closure type": ["Lace-Up"],
|
|
150
|
+
"Footwear material": ["Cotton"],
|
|
151
|
+
"Sneaker style": ["Athletic"],
|
|
152
|
+
"Shoe Type (Silhouette/Style)": ["Low-Top"]
|
|
153
|
+
},
|
|
154
|
+
"summary": "Men's light grey jersey textile sneakers featuring contrasting red laces and a clean white sole."
|
|
155
|
+
},
|
|
156
|
+
"languageCode": "en-US",
|
|
157
|
+
"updatedAt": "2026-11-06T10:34:25.665193Z"
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Want Markdown for an LLM prompt instead of JSON? Call
|
|
162
|
+
`client.lookup_product_markdown(url)`.
|
|
163
|
+
|
|
164
|
+
### Look up many URLs at once
|
|
165
|
+
|
|
166
|
+
`lookup_products` runs lookups concurrently (8 at a time by default) and
|
|
167
|
+
returns one `LookupResult` per URL, in the same order. A URL that fails does
|
|
168
|
+
not stop the batch.
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
results = client.lookup_products([
|
|
172
|
+
"https://www.allbirds.com/products/mens-runner-nz-jersey-light-grey-syrah",
|
|
173
|
+
"https://www.jcrew.com/p/mens/categories/clothing/shirts/linen/short-sleeve-camp-collar-shirt-in-irish-linen/BG997",
|
|
174
|
+
"https://www.example.com/not-a-product",
|
|
175
|
+
])
|
|
176
|
+
for r in results:
|
|
177
|
+
if r.ok:
|
|
178
|
+
print("ok ", r.product.title, r.product.current_price)
|
|
179
|
+
else:
|
|
180
|
+
print("fail", r.url, type(r.error).__name__, r.error)
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
```text
|
|
184
|
+
ok Men's Runner NZ Jersey - Light Grey/Syrah (Blizzard Sole) 110.0
|
|
185
|
+
ok Short-sleeve camp-collar shirt in Irish linen 87.99
|
|
186
|
+
fail https://www.example.com/not-a-product OctogenNotFoundError product_not_found
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Search products
|
|
190
|
+
|
|
191
|
+
`search_products` takes a natural-language query. By default StoreRouter
|
|
192
|
+
understands it: it rewrites the query for retrieval and turns implied
|
|
193
|
+
constraints into filters. You can narrow the search with:
|
|
194
|
+
|
|
195
|
+
- `stores`: up to 20 store domains;
|
|
196
|
+
- `facets`: filters such as `color_family`, `gender`, `brand_name`,
|
|
197
|
+
`category_path.depth_N` and taxonomy attributes;
|
|
198
|
+
- `price_min` and `price_max`.
|
|
199
|
+
|
|
200
|
+
With `relax=True`, a query that finds nothing is retried with its filters
|
|
201
|
+
dropped in stages. Only the facets you mark `relaxable=True` can be dropped.
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
from storerouter import SearchFacet
|
|
205
|
+
|
|
206
|
+
page = client.search_products(
|
|
207
|
+
q="linen shirt",
|
|
208
|
+
stores=["jcrew.com"],
|
|
209
|
+
facets=[SearchFacet(name="color_family", values=["Blue"], relaxable=True)],
|
|
210
|
+
relax=True,
|
|
211
|
+
limit=3,
|
|
212
|
+
)
|
|
213
|
+
for item in page.items:
|
|
214
|
+
print(f"{item.title} | {item.current_price} {item.currency} | {item.product_url}")
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
```text
|
|
218
|
+
Short-sleeve camp-collar shirt in Irish linen | 87.99 USD | https://www.jcrew.com/p/mens/categories/clothing/shirts/linen/short-sleeve-camp-collar-shirt-in-irish-linen/BG997
|
|
219
|
+
Gamine button-up shirt in linen | 87.99 USD | https://www.jcrew.com/p/womens/categories/clothing/matching-sets/gamine-button-up-shirt-in-linen/CV033
|
|
220
|
+
Washed linen shirt with point collar | 104.99 USD | https://www.jcrew.com/p/mens/categories/clothing/shirts/linen/washed-linen-shirt-with-point-collar/CH601
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
`page.effective_query` shows the search that actually ran: the understood
|
|
224
|
+
filters, and which facets `relax` dropped. To get the next page, pass
|
|
225
|
+
`cursor=page.next_cursor`. Each item is a `ProductListItem`, a lighter
|
|
226
|
+
version of `Product`. To get the full record, call `lookup_product` with the
|
|
227
|
+
item's URL, or `retrieve_products` with the item UUIDs.
|
|
228
|
+
|
|
229
|
+
Related calls:
|
|
230
|
+
|
|
231
|
+
- `count_product_facets`: facet value counts for a query;
|
|
232
|
+
- `more_like_this_products`: products similar to the ones you pass;
|
|
233
|
+
- `list_taxonomy_categories` and `list_taxonomy_attributes`: the category tree
|
|
234
|
+
and attribute names that facets accept.
|
|
235
|
+
|
|
236
|
+
## Stores and brands
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
store = client.get_store("allbirds.com") # is this store covered, and how well?
|
|
240
|
+
for catalog in store.catalogs:
|
|
241
|
+
print(catalog.catalog_display_name, catalog.product_count)
|
|
242
|
+
|
|
243
|
+
stores = client.search_stores("sustainable sneakers", limit=3)
|
|
244
|
+
print([s.store.primary_domain for s in stores.results])
|
|
245
|
+
# ['seavees.com', 'stellamccartney.com', '8000kicks.com']
|
|
246
|
+
|
|
247
|
+
brands = client.search_brands("minimalist sneakers", limit=3)
|
|
248
|
+
print([(b.brand_name, b.domain) for b in brands.results])
|
|
249
|
+
# [('Little Love Bug Co.', 'littlelovebugcompany.com'), ('ALIVEFORM', 'aliveform.co'), ('Camper', 'camper.com')]
|
|
250
|
+
|
|
251
|
+
brand = client.get_brand_details("allbirds")
|
|
252
|
+
print(brand.founded_year, brand.origin_country, brand.price_tier)
|
|
253
|
+
# 2016 New Zealand PREMIUM
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
More brand calls: `search_brand_prefix` (autocomplete), `search_similar_brands`,
|
|
257
|
+
`get_brand_directory`, `get_brand_coverage`, `browse_brand_products` and
|
|
258
|
+
`get_brand_top_categories`. For stores there is also `search_similar_stores`.
|
|
259
|
+
|
|
260
|
+
## Async
|
|
261
|
+
|
|
262
|
+
`AsyncStoreRouter` has the same constructor and the same methods as
|
|
263
|
+
`StoreRouter`, as coroutines:
|
|
264
|
+
|
|
265
|
+
```python
|
|
266
|
+
import asyncio
|
|
267
|
+
from storerouter import AsyncStoreRouter
|
|
268
|
+
|
|
269
|
+
async def main() -> None:
|
|
270
|
+
async with AsyncStoreRouter() as client:
|
|
271
|
+
page = await client.search_products(q="wool sweater", limit=2)
|
|
272
|
+
print([item.title for item in page.items])
|
|
273
|
+
|
|
274
|
+
asyncio.run(main())
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
`StoreRouter` runs the async client on a private event-loop thread. It
|
|
278
|
+
therefore works inside Jupyter and other code that already runs an event
|
|
279
|
+
loop. Close it with `client.close()`, or use it in a `with` block.
|
|
280
|
+
|
|
281
|
+
## The normalized product schema
|
|
282
|
+
|
|
283
|
+
Every lookup returns a `Product` (`storerouter.models.Product`). Every search
|
|
284
|
+
result is a `ProductListItem`, which is a subset of `Product`. Python
|
|
285
|
+
attributes use snake_case. The JSON wire format uses camelCase:
|
|
286
|
+
`model_dump(by_alias=True)`.
|
|
287
|
+
|
|
288
|
+
| Field | What it holds |
|
|
289
|
+
| ------------------------------------ | ---------------------------------------------------------------------------------------------- |
|
|
290
|
+
| `uuid`, `catalog_key`, `product_url` | StoreRouter's stable ID, the store's catalog, and the canonical product URL |
|
|
291
|
+
| `title`, `description` | As the store states them |
|
|
292
|
+
| `brand` | `name`, `slug`, `url`, `description` |
|
|
293
|
+
| `current_price`, `original_price` | Sale price and list price, as numbers |
|
|
294
|
+
| `currency` | ISO 4217 code, for example `USD` |
|
|
295
|
+
| `in_stock`, `availability` | Stock rolled up across variants, and the schema.org availability term |
|
|
296
|
+
| `variants` | Per-variant `sku`, `color`, `size`, `in_stock`, `current_price`, `original_price`, `image_url` |
|
|
297
|
+
| `sizes`, `colors` | Sizes and color labels (with swatches and hex codes when the store has them) |
|
|
298
|
+
| `primary_image`, `images` | Store image URL plus a StoreRouter CDN copy (`cdn_url`), with dimensions |
|
|
299
|
+
| `identifiers` | `product_id`, `gtin`, `product_group_id` |
|
|
300
|
+
| `categories`, `breadcrumbs` | The store's own categories and breadcrumb trail |
|
|
301
|
+
| `details` | `materials`, `fit`, `dimensions`, `patterns` |
|
|
302
|
+
| `audience` | `genders`, `age_groups` |
|
|
303
|
+
| `rating`, `reviews` | Aggregate rating and individual reviews |
|
|
304
|
+
| `promotions`, `videos`, `tags` | Promotions with codes, product videos, and store tags |
|
|
305
|
+
| `additional_attributes` | Name/value pairs as the store states them |
|
|
306
|
+
| `enrichment` | Added by StoreRouter (see below) |
|
|
307
|
+
| `item_condition`, `language_code` | schema.org item condition, and the language of the text |
|
|
308
|
+
| `updated_at` | When StoreRouter last refreshed the product |
|
|
309
|
+
|
|
310
|
+
StoreRouter adds `enrichment` itself. It holds:
|
|
311
|
+
|
|
312
|
+
- the product `type`;
|
|
313
|
+
- a taxonomy `category_path`, root first;
|
|
314
|
+
- `color_family`, `gender`, `age_groups` and `styles`;
|
|
315
|
+
- category-specific `attributes`: fit and fabric for apparel, skin type for
|
|
316
|
+
skincare, notes for fragrance, upholstery for furniture;
|
|
317
|
+
- a `canonical_brand` profile;
|
|
318
|
+
- a one-line `summary`.
|
|
319
|
+
|
|
320
|
+
The full machine-readable schema is the
|
|
321
|
+
[OpenAPI document](https://cdn.storerouter.ai/openapi/platform/v1/openapi.json).
|
|
322
|
+
|
|
323
|
+
## Errors and retries
|
|
324
|
+
|
|
325
|
+
The client retries 429, 500, 502, 503 and 504 responses, and connections that
|
|
326
|
+
fail to open. It waits for `Retry-After` when the server sends one, and uses
|
|
327
|
+
exponential backoff otherwise. Read timeouts are not retried. Set the number
|
|
328
|
+
of retries with `StoreRouter(max_retries=...)` (the default is 2, and 0
|
|
329
|
+
disables retries).
|
|
330
|
+
|
|
331
|
+
When a call fails, it raises a typed error. Each one is an alias of the
|
|
332
|
+
matching `octogen_ai_sdk` error:
|
|
333
|
+
|
|
334
|
+
| Error | When |
|
|
335
|
+
| ------------------------- | ---------------------------------------------------- |
|
|
336
|
+
| `MissingAPIKeyError` | No key passed, and none in the environment |
|
|
337
|
+
| `AuthenticationError` | 401: the key is invalid |
|
|
338
|
+
| `InsufficientCreditError` | 402: the organization is out of credit |
|
|
339
|
+
| `PermissionDeniedError` | 403: the key cannot use this endpoint or catalog |
|
|
340
|
+
| `NotFoundError` | 404: product not found, or store not covered |
|
|
341
|
+
| `ValidationError` | 422: invalid request |
|
|
342
|
+
| `RateLimitError` | 429 after retries; `error.retry_after` is in seconds |
|
|
343
|
+
| `InternalServerError` | 5xx after retries |
|
|
344
|
+
| `APITimeoutError` | The request timed out |
|
|
345
|
+
| `APIConnectionError` | Network failure |
|
|
346
|
+
|
|
347
|
+
`APIError` is the base class of every HTTP error, and `StoreRouterError` is
|
|
348
|
+
the base class of them all. HTTP errors carry `status_code`, `detail` and
|
|
349
|
+
`request_id`.
|
|
350
|
+
|
|
351
|
+
## Configuration
|
|
352
|
+
|
|
353
|
+
| Setting | Argument | Environment variable | Default |
|
|
354
|
+
| -------- | --------------------------------------- | ------------------------------------------------------- | ------------------------------- |
|
|
355
|
+
| API key | `api_key=` | `STOREROUTER_API_KEY` (then `OCTOGEN_PLATFORM_API_KEY`) | required |
|
|
356
|
+
| Base URL | `base_url=` | `STOREROUTER_BASE_URL` | `https://api.storerouter.ai/v1` |
|
|
357
|
+
| Timeout | `timeout=` (seconds or `httpx.Timeout`) | | 60 s |
|
|
358
|
+
| Retries | `max_retries=` | | 2 |
|
|
359
|
+
|
|
360
|
+
Every method also accepts a per-call `timeout=`. Requests use
|
|
361
|
+
`Authorization: Bearer <key>`. Billing is per successful request, and every
|
|
362
|
+
organization gets free credit each month: see
|
|
363
|
+
[pricing](https://storerouter.ai/docs/guides/pricing-and-billing).
|
|
364
|
+
|
|
365
|
+
## Other ways to use StoreRouter
|
|
366
|
+
|
|
367
|
+
- **MCP server for AI agents**: `https://mcp.dev.storerouter.ai/mcp`
|
|
368
|
+
(Streamable HTTP with OAuth). It gives Claude, ChatGPT, Cursor and other MCP
|
|
369
|
+
clients product search, product lookup, and store and brand tools. See the
|
|
370
|
+
[setup guides](https://storerouter.ai/docs/mcp/overview).
|
|
371
|
+
- **LangChain**: [`langchain-storerouter`](https://pypi.org/project/langchain-storerouter/)
|
|
372
|
+
- **TypeScript**: [`@storerouter/sdk`](https://www.npmjs.com/package/@storerouter/sdk)
|
|
373
|
+
- **CLI**: [`@storerouter/cli`](https://www.npmjs.com/package/@storerouter/cli)
|
|
374
|
+
- **REST**: the [API reference](https://storerouter.ai/docs/api-reference)
|
|
375
|
+
and the [OpenAPI document](https://cdn.storerouter.ai/openapi/platform/v1/openapi.json)
|
|
376
|
+
|
|
377
|
+
`storerouter` is built on [`octogen-ai-sdk`](https://pypi.org/project/octogen-ai-sdk/),
|
|
378
|
+
the lower-level async SDK for the same API. It adds the sync client, retries,
|
|
379
|
+
batch lookup and the `STOREROUTER_*` settings. Every `octogen_ai_sdk` model is
|
|
380
|
+
available from `storerouter.models`.
|
|
381
|
+
|
|
382
|
+
## Documentation
|
|
383
|
+
|
|
384
|
+
- [StoreRouter docs](https://storerouter.ai/docs)
|
|
385
|
+
- [API reference](https://storerouter.ai/docs/api-reference)
|
|
386
|
+
- [MCP server](https://storerouter.ai/docs/mcp/overview)
|
|
387
|
+
- [Source and issues](https://github.com/octogen-ai/octogen-dev/tree/main/sdks/storerouter-python)
|
|
388
|
+
|
|
389
|
+
## License
|
|
390
|
+
|
|
391
|
+
Apache-2.0
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
storerouter/__init__.py,sha256=66VL6PRr4gDQomJbWHkGR4bpuDkPvrjSsrDPzDqzdXY,2716
|
|
2
|
+
storerouter/_client.py,sha256=8_6IGJ4fDORHw85w8pNerQzP_lN8-FCHbsaoQBTUfKw,13183
|
|
3
|
+
storerouter/_retry.py,sha256=7FwZBxJQvwQLaTG3rMTwPBULM7UzyNuO5k-wk4LBv4Q,3220
|
|
4
|
+
storerouter/models.py,sha256=7EtFLikeBdY6Iv8hzqZdDzE9sxZ6sceJxc1BZp7ZjtM,907
|
|
5
|
+
storerouter/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
storerouter-0.1.0.dist-info/WHEEL,sha256=MWx7psoF7KNST9G72pGtiBYuaXRXQXBk9pw5R6FzYko,80
|
|
7
|
+
storerouter-0.1.0.dist-info/METADATA,sha256=pSEzf8jW1zxueEonYNjjOQlL4Jf_9olosYebiiloZNA,18365
|
|
8
|
+
storerouter-0.1.0.dist-info/RECORD,,
|