Metadata-Version: 2.4
Name: pricesapi
Version: 0.1.0
Summary: Official Python client for PricesAPI
Author-email: PricesAPI <andrew@pricesapi.io>
License-Expression: MIT
Project-URL: Documentation, https://pricesapi.io/docs
Project-URL: OpenAPI, https://pricesapi.io/.well-known/openapi.yaml
Project-URL: Homepage, https://pricesapi.io
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: requests<3,>=2.31
Provides-Extra: test
Requires-Dist: PyYAML<7,>=6; extra == "test"

# PricesAPI Python

The official Python client for building price comparison, deal-finding, catalogue,
monitoring, and other price-intelligence products with PricesAPI.

The client follows the public OpenAPI contract and currently covers:

- live product discovery;
- credit-free catalogue lookup from a product name or a seller name to a product ID;
- no-scrape Product Snapshot reads, including ordered batches;
- sparse observed Price History reads for one exact product;
- product-cluster membership;
- credit-free Schedules management;
- Async Bulk Search submission and management.

Product Watch and other unreleased resources are not exposed.

## Install

```bash
pip install pricesapi
```

Python 3.10 or newer is required.

## Find products and offers

Always pass the market explicitly. PricesAPI supports global markets; examples use
the United States only for illustration.

```python
import os

from pricesapi import PricesAPI

with PricesAPI(os.environ["PRICESAPI_KEY"]) as client:
    result = client.search_products(
        "Sony WH-1000XM5",
        market="us",
        limit=3,
        offers_limit=10,
    )
```

`market` is required and has no default. A search without one is
`400 INVALID_PARAMETER` rather than a market you did not ask for, and every
route refuses a parameter it does not recognise the same way, naming the key and
listing the ones it accepts — so a misspelled filter costs you a `400`, not a
silently wrong answer with a credit charged for it.

Each candidate carries `headline_price` and `headline_currency` — the price on
the result card, which can exceed every offer beneath it and can belong to a
different variant, so compare offers to offers rather than treating it as the
product's price.

Each candidate also carries `catalog_id` and `cluster_id`. A catalog
ID names one variant in one market; a product cluster ID is Google's universal
grouping of those variants, so the two are not interchangeable. Pass
`catalog_id` with `id_type="catalog"`, and `cluster_id` to
`list_product_cluster_members` with `id_type="cluster"`.

## Find a product ID by name

Product Snapshot and Price History both need an exact product ID. If all you
hold is a product name — or a seller's name — this is how you turn it into one.
The lookup searches the catalogue PricesAPI already holds. It returns no prices
and no offers, never runs Search or a scraper, and never consumes a Search
credit, so it keeps working when your credit balance is exhausted.

```python
found = client.find_catalog_products(q="anko 7.5l air fryer", market="au")

for product in found["products"]:
    print(product["id"], product["title"], product["retained"]["days_with_offers"])
```

Pass `q` (product text), `seller` (a seller name), or both. At least one of the
two is required, and `market` is always required and is never inferred. Omitting
both raises `ValueError` before anything is sent, which is the rule the API
itself answers with `400 INVALID_QUERY`.

Each product carries `id` with `id_type` `"pricesapi"` — the identity the other
reads take — plus `title`, `image_url`, a `retained` summary, at most 10
`sellers`, and `dates_with_offers`, a most-recent-first list of at most 30 dates
we hold at least one offer on. Read `retained` before you spend anything: its
`offer_count`, `days_with_offers`, `first_date_with_offers` and
`last_date_with_offers` describe the product's whole retained range, so they say
what a History call on that ID can return, and all four are zero or `None`
together when nothing is held for that product, which is a real answer rather
than an error. `retained.offer_count` counts the offers held; History's
`times_with_offers` counts the distinct instants they were recorded at, so the
two are different quantities and the catalogue number is never the smaller one.
Each seller carries its own `days_with_offers`, `first_date_with_offers` and
`last_date_with_offers`, and `sellers[]["id"]` is the same identifier History
returns as `offers[]["seller_id"]`, so a per-retailer series joins straight onto
this list.

A seller name on its own works for retailers of any size and returns that
seller's products most recently observed first; adding `q` narrows it to
products whose title also matches. A `seller` term so short or generic that it
names a great many different sellers is refused with `PricesAPIError`, status
`400` and code `SELLER_SCAN_LIMIT_EXCEEDED`. That refusal fails identically on
every retry and carries no `Retry-After`, so name the seller more precisely
instead of repeating the call.

`limit` is an integer from 1 to 20 and defaults to 10. A page can be shorter
than `limit` and still continue, so page until `next_cursor` is `None` rather
than until a short page. The cursor is opaque, expires 15 minutes after it is
issued, and is bound to the `q`, `seller`, `market`, `limit` and account that
produced it, so pass it back unchanged alongside the same arguments.

```python
products = []
cursor = None

while True:
    page = client.find_catalog_products(
        seller="Kmart", market="au", limit=20, cursor=cursor
    )
    products.extend(page["products"])
    cursor = page["page"]["next_cursor"]
    if cursor is None:
        break
```

## Read known products without scraping

The same method accepts either a PricesAPI product ID or an external product ID.
The ID authority is always explicit.

```python
product = client.get_product_snapshot(
    "12345",
    id_type="pricesapi",
    market="gb",
    offers_limit=10,
    seller_domains=["amazon.co.uk", "argos.co.uk"],
)

batch = client.batch_get_product_snapshots(
    [
        {
            "id": "12345",
            "id_type": "pricesapi",
            "market": "gb",
            "seller_domains": ["amazon.co.uk"],
        },
        {
            "id": "opaque_product_id",
            "id_type": "catalog",
            "market": "de",
        },
    ]
)
```

`id_type` is `"pricesapi"` for a PricesAPI product ID, `"catalog"` for a
Google catalog ID, and `"cluster"` for a Google product cluster —
the last of which `list_product_cluster_members` takes, not this read.

`seller_domains` is the plural of the `seller_domain` each offer carries, so the
filter and the field it filters on are one word. Entries are exact normalized
hostnames (for example, `amazon.co.uk`), with up to 10 per product, and they are
applied before `offers_limit`. A known product with no matching seller domain
still returns `200` with an empty `offers` array; it is distinct from an unknown
product (`404`). Each offer publishes `seller`, `seller_url` and `seller_domain`;
they were `merchant`, `merchant_url` and `merchant_domain` until 2026-09-17.

## Read sparse observed price history

Price History is a Public Beta for every valid API account. It uses the same
explicit product identifier and market as Snapshot, and returns the individual
observed offers themselves: who charged what, on what listing, and when. It
returns only what was actually observed, and never fills a gap with a zero or a
carried price. It does not consume a Search credit. Coverage is global;
Australia currently has the deepest retained-history cohort, while other markets
continue to accumulate data. Beta limits and pricing are subject to change.

```python
history = client.get_product_history(
    "12345",
    id_type="pricesapi",
    market="gb",
    from_date="2026-01-01",
    to_date="2026-03-31",
)

for offer in history["offers"]:
    shipping = offer["shipping"]
    landed = offer["price"] if shipping is None else offer["price"] + shipping
    print(offer["recorded_at"], offer["seller"], landed, offer["stock_status"])
```

Each offer carries `recorded_at` (an ISO-8601 UTC instant with milliseconds —
when PricesAPI recorded the offer, not when the seller changed the price),
`seller_id`, `seller`, `seller_domain`, `price`, `shipping`, `currency`,
`seller_product_url`, `product_title`, `stock_status` and `delivery_info`.
`seller_id` is a string, the same value the catalogue publishes, and it
identifies the same seller across pages and across products, so a per-retailer
series is a `GROUP BY` in your own code.

`price` is the base price and EXCLUDES shipping, so landed price is the two
added together. Every field except `recorded_at`, `seller_id`, `price` and
`currency` may be `None`: `shipping` is `None` where the seller stated no cost
(and `0` where the seller stated free shipping), and `product_title`,
`stock_status` and `delivery_info` are `None` for every date in the retained
archive, which does not store them, as well as wherever the seller stated no
value. New, used and refurbished listings are all returned and all counted, not
only new stock.

`seller`, `seller_domain` and the removal of `condition` landed on 2026-09-17,
with `merchant` and `merchant_domain` as the previous spellings.

`window` describes the whole requested window rather than the page you are
holding. Its `days_requested` and `days_with_offers` let your application decide
whether the evidence is deep enough — subtract the two for the days carrying no
offer, which is why the response no longer publishes a third number that could
disagree with them — and
`times_with_offers`, `offer_count`, `first_recorded_at` and `last_recorded_at`
say how much there is: `days_with_offers` is the number of distinct days inside
the window you asked for on which we hold at least one offer for that product,
`times_with_offers` counts the distinct instants those offers were recorded at,
and `offer_count` counts the offers themselves. `window` is pinned on the first
page, so every continuation echoes it unchanged.

### Page through a window

`limit` is optional (1 to 1000, default 250), and `page["next_cursor"]` is
`None` on the last page. Pass the cursor back unchanged. The SDK makes exactly
one request per call: it never pages, retries, or polls History for you.

```python
offers = []
cursor = None

while True:
    page = client.get_product_history(
        "12345",
        id_type="pricesapi",
        market="gb",
        from_date="2026-01-01",
        to_date="2026-03-31",
        limit=1000,
        cursor=cursor,
    )
    offers.extend(page["offers"])
    cursor = page["page"]["next_cursor"]
    if cursor is None:
        break
```

A cursor does not live forever. When one expires the call raises
`PricesAPIError` with status `409` and code `CURSOR_EXPIRED`: restart the walk
from the first page with no cursor, and discard the offers the abandoned walk
collected rather than stitching the two halves together.

### Aggregate offers into a daily rollup

`group_by` has been removed. Sending it is `400 INVALID_PARAMETER`, and the
method no longer accepts the argument. Daily and monthly values are yours to
compute now, which means you pick the statistic instead of accepting ours:

```python
from collections import defaultdict
from statistics import median

by_day: dict[str, list[float]] = defaultdict(list)
for offer in offers:
    by_day[offer["recorded_at"][:10]].append(offer["price"])

for day in sorted(by_day):
    prices = by_day[day]
    print(day, min(prices), median(prices), max(prices), len(prices))
```

Key that same loop on `offer["seller_id"]` instead of the date, and you have the
per-retailer price series the old rollups could not express at all.

A window spans at most 1500 days, and `to_date` must not be later than the
current UTC date. The SDK validates the identifiers, the window, `limit` and
`cursor` before sending, so a malformed request costs no round trip.

## Keep searches fresh with Schedules

Schedules management calls use no Search credits.
Background scheduled refreshes use no Search credits. A later customer-initiated
Search follows normal billing, including when it reads a Schedule result. Schedules
is a Beta feature, so pricing models, included allowances, and limits may change.

```python
created = client.create_schedules(
    [
        {"term": "wireless headphones", "market": "us", "frequency_minutes": 1440},
        {"term": "robot vacuum", "market": "gb", "frequency_minutes": 10080},
    ]
)

active = client.list_schedules(status="active", limit=50)
daily_in_gb = client.list_schedules(market="gb", frequency_minutes=1440, limit=50)

for schedule in daily_in_gb["data"]["schedules"]:
    client.update_schedule(schedule["id"], frequency_minutes=10080)
```

`frequency_minutes` is the cadence on every schedule call — the spelling the
read side returns, the one a create, an update and a filter all take. A create
that omits it takes your account default, which `list_schedules` and
`get_schedule` then report back as `frequency_minutes`.

## Run a bounded batch asynchronously

Each item reserves one Search credit. Successful non-empty items consume the credit;
failed, empty, or cancelled-before-start items are refunded. Submit, status, results,
and cancellation calls add no management credit. Async Bulk Search is a Beta feature,
and pricing or limits may change.

```python
job = client.submit_search_job(
    [
        {"q": "running shoes", "market": "us"},
        {"q": "coffee grinder", "market": "fr"},
    ],
    idempotency_key="catalog-refresh-2026-09-12",
)

status = client.get_search_job(job["data"]["id"])
results = client.list_search_job_results(job["data"]["id"], limit=100)
```

The SDK never polls or retries automatically. Your application owns cadence,
backoff, cancellation, and idempotency policy. `PricesAPIError.retry_after` exposes
the server's `Retry-After` hint when one is returned.

## Maintainer release

Releases are manual and must run from a clean checkout whose `HEAD` exactly matches
`origin/main`. The check creates an isolated temporary environment, builds both
artifacts, validates their metadata and contents, then removes every generated file:

```bash
tools/publish-python-sdk.sh check
```

Publishing additionally requires an explicit version matching `pyproject.toml` and
a PyPI API token. Because a project-scoped token cannot exist until the first
`pricesapi` release creates the PyPI project, use a one-time account-scoped token
only for that bootstrap upload. The command requires an explicit bootstrap
confirmation, refuses an already-published version, and verifies the uploaded
artifact hashes before reporting success:

```bash
PYPI_BOOTSTRAP_TOKEN_CONFIRMED=1 \
TWINE_PASSWORD='pypi-…' \
tools/publish-python-sdk.sh publish 0.1.0
```

After the verified first upload, revoke it immediately and create a project-scoped
token for every later release. Never commit either token or save it in shell
history, repository files, build artifacts, or the roadmap.
