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.
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/storerouter)](https://pypi.org/project/storerouter/)
33
+ [![Python](https://img.shields.io/pypi/pyversions/storerouter)](https://pypi.org/project/storerouter/)
34
+ [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.7
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any