Metadata-Version: 2.4
Name: pricesapi
Version: 0.1.1
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. Omitting the Python keyword raises
`TypeError` before a request is sent. The Search API rejects a missing or
unsupported market with `400 INVALID_MARKET`. An unrecognised query parameter
returns `400 INVALID_PARAMETER`, naming the key and accepted parameters. These
validation failures use no credits.

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 uses no credits, 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
where stored coverage exists, not a quote for a selected History window. All four
are zero or `None` together when nothing is held for that product, 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. Catalogue and History are separate reads, with
different windows; do not assume their counts will reconcile as data changes.
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.

These reads run no Search and no scraper, and they use 1 credit per product
returned — a tenth of a Search — whether you read one product or a batch of
them. A product reported `PRODUCT_NOT_FOUND`, and a read that fails, use no
credit. The batch reserves every item it was asked for and hands the misses
straight back, so an account must be able to cover the whole list before the
read starts. `list_product_cluster_members` is priced per page returned: one
credit however many members the page carries, because `limit` is yours to set,
and a page with no members uses no credit.

```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 runs no Search and no scraper, and it uses 1 credit per day of
offers returned — the unit is the day, never the call. A window is charged for
each day inside it that carried at least one offer, which is exactly the
`window.days_with_offers` the response publishes, so a 365-day window over a
product observed on 18 days uses 18 credits and a dense 1500-day window can use
1500. Because the charge is only known after the read, the account must be able
to cover `window.days_requested` before the read starts; the days that carried
no offers are handed straight back. A window that returns no offers uses no
credit, a failed read uses no credit, and paging uses no credit — the window is
charged once, on the first page, and every request carrying a `cursor` is free.
The free `find_catalog_products` lookup reports `retained.days_with_offers`
across all stored history, not an exact quote for your selected date window.
Use it to understand coverage. 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.

The example below reserves **7 credits up front** for June 1–7, inclusive.
Replace the illustrative ID with an exact ID and matching market from Catalogue.
It reads one page; use the pagination recipe below for the complete window.
Run these examples inside an open `PricesAPI` client context, as in the first
example—not after that context has closed.

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

for offer in history["offers"]:
    shipping = offer["shipping"]
    landed = None if shipping is None else offer["price"] + shipping
    print(offer["recorded_at"], offer["seller"],
          "base:", offer["price"], offer["currency"],
          "landed:", 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, excluding shipping. A landed price is known only when
shipping is known too. History currently returns `shipping=None` on every offer;
that means unknown, not free delivery. If numeric shipping becomes available,
the example handles both zero and positive amounts without treating `None` as zero.

Every field except `recorded_at`, `seller_id`, `price` and `currency` may be
`None`. `product_title`, `stock_status` and `delivery_info` are `None` for offers
recorded at or before `2025-06-30T23:59:59.999Z`, and may be missing later too.
New, used and refurbished listings are all returned and counted. Condition is
not exposed, so this response cannot reconstruct a new-stock-only series.

`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. That is not an immutable export:
late-arriving or removed records can make the total rows served differ from the
initial `offer_count`. Follow the issued cursor until it is `None`.

### 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. Keep the
same ID, ID type, market, dates and limit on every continuation. This alternative
recipe starts a new seven-day read, reserving 7 credits; issued cursor pages add
no credits. Running it after the starter is another first-page read, not a free
continuation of that earlier request.

```python
offers = []
cursor = None

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

A cursor can become unusable because of age, identity or visibility changes.
The call raises `PricesAPIError` with status `409` and code `CURSOR_EXPIRED`.
Do not automatically restart: a request without a cursor starts a new billable
read and needs the full requested-day reservation again. If you choose to
restart, discard the abandoned walk's offers rather than joining the two reads.
The loop also stops on credit or rate-limit errors; it does not retry them.

### 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. First load all pages for one exact product, market and window, and
keep currencies separate. This example summarizes all observed base prices per
UTC day; it is not the removed API's seller-closing-price statistic. Missing days
stay absent, and sellers with more observations contribute more values:

```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))
```

Keying that loop only on `offer["seller_id"]` gives one summary per seller across
the window, not a time series. For a seller's price series, retain each
`recorded_at` and `price`, group by seller and currency, and sort by timestamp.

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 credits.
Background scheduled refreshes use no 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 10 credits, the price of one Search, because it is the
same work through another door. Successful non-empty items consume them;
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.
