Metadata-Version: 2.4
Name: discogs-sdk
Version: 0.7.0
Summary: Modern Python client for the Discogs API
Keywords: api,discogs,music,python,sdk
Author: Jean-Marc Fontaine
Author-email: Jean-Marc Fontaine <jm@jmfontaine.net>
License-Expression: Apache-2.0
License-File: LICENSE.txt
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3.15
Classifier: Typing :: Typed
Requires-Dist: httpx2>=2.12
Requires-Dist: pydantic>=2.12
Requires-Dist: pydantic>=2.14 ; python_full_version == '3.15.*'
Requires-Dist: typing-extensions>=4
Requires-Dist: tzdata>=2024.1
Requires-Python: >=3.10
Project-URL: Homepage, https://github.com/jmfontaine/discogs-sdk
Project-URL: Issues, https://github.com/jmfontaine/discogs-sdk/issues
Project-URL: Repository, https://github.com/jmfontaine/discogs-sdk
Description-Content-Type: text/markdown

# discogs-sdk

<p align="center">
  <a href="https://pypi.org/project/discogs-sdk/"><img src="https://img.shields.io/pypi/v/discogs-sdk.svg" alt="PyPI version"></a>
  <a href="https://pypi.org/project/discogs-sdk/"><img src="https://img.shields.io/pypi/pyversions/discogs-sdk.svg" alt="Python versions"></a>
  <a href="https://github.com/jmfontaine/discogs-sdk/actions"><img src="https://github.com/jmfontaine/discogs-sdk/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
  <a href="https://pypi.org/project/discogs-sdk/"><img src="https://img.shields.io/pypi/dm/discogs-sdk.svg" alt="PyPI downloads"></a>
  <a href="https://github.com/jmfontaine/discogs-sdk/blob/main/LICENSE.txt"><img src="https://img.shields.io/pypi/l/discogs-sdk.svg" alt="License"></a>
</p>

discogs-sdk is a modern Python client for the [Discogs API](https://www.discogs.com/developers) covering every documented v2 endpoint, with a fluent chainable syntax and built-in response caching.

```python
from discogs_sdk import Discogs

with Discogs() as client:
    release = client.releases.get(352665)
    print(f"{release.title} ({release.year})")

    for result in client.search(query="Nine Inch Nails", type="artist"):
        print(result.title)
```

## Installation

```bash
pip install discogs-sdk
# or
uv add discogs-sdk
```

Requires Python 3.10+.

## Features

- **Complete Endpoint Coverage** — Every route in the Discogs API v2 documentation
- **Fluent API** — Chain sub-resources naturally: `client.releases.get(id).rating.get()`
- **Lazy Loading** — No HTTP calls until you actually need the data
- **Effortless Pagination** — Browse results without managing pages or offsets
- **Rate Limit Aware** — Bounded retries with `Retry-After` support, the live budget on `client.ratelimit`, and a structured event per request
- **Built-in Caching** — Optional TTL-based caching reduces API calls
- **Flexible Auth** — Supports personal tokens, consumer key/secret, or full OAuth 1.0a
- **Type Safe** — Get autocomplete and IDE support
- **Async & Sync** — Full support for both synchronous and asynchronous workflows

### How it compares

The established alternative is [python3-discogs-client](https://github.com/joalla/discogs_client). Both load data
lazily, paginate automatically, support OAuth 1.0a and back off on HTTP 429, so the differences that matter are:

| | discogs-sdk | python3-discogs-client |
|---|---|---|
| Documented v2 API coverage | Complete | Partial † |
| Sync and async | Both | Sync only |
| Responses | Typed Pydantic models | Untyped attributes |
| Response cache | In-memory or SQLite | None |

† No inventory export or upload, release ratings or have/want stats, collection fields, folder creation,
contributions or submissions.

## Quick start

### Authentication

Set your personal access token from [your Discogs developer settings](https://www.discogs.com/settings/developers) as an environment variable:

```bash
export DISCOGS_TOKEN="your-token-here"
```

```python
from discogs_sdk import Discogs

client = Discogs()  # reads DISCOGS_TOKEN from environment
```

You can also pass credentials explicitly:

```python
client = Discogs(token="your-token-here")
```

The SDK supports three auth modes: personal token, consumer key/secret, and OAuth 1.0a. Exactly one mode is selected
when the client is constructed, and only that mode's credentials are used. The precedence is:

1. An explicit `token` selects personal-token auth and overrides every environment credential.
2. Explicit OAuth access-token credentials select OAuth; any missing half may come from the matching `DISCOGS_*`
   variable, but an unrelated environment token never takes over.
3. Explicit `consumer_key`/`consumer_secret` select consumer auth, without borrowing environment access tokens.
4. With no auth arguments, the environment resolves the mode: `DISCOGS_TOKEN`, then a complete OAuth set, then
   `DISCOGS_CONSUMER_KEY`/`DISCOGS_CONSUMER_SECRET`, then unauthenticated.

An explicitly selected but incomplete credential set raises `ValueError` rather than quietly falling back to a
different account or mode. See [`examples/authentication.py`](examples/authentication.py) for the full OAuth flow.

> [!TIP]
> Use a `.env` file with [python-dotenv](https://pypi.org/project/python-dotenv/) or
> [direnv](https://direnv.net/) to avoid exporting tokens manually in every shell.

> [!NOTE]
> Discogs allows 60 requests/minute authenticated and 25/minute unauthenticated, measured as a moving average over
> the last 60 seconds. The SDK does not pace your requests: it retries a rate-limited **read** a bounded number of
> times (`max_retries`, default 3), honouring `Retry-After` (seconds or an HTTP-date) up to 60 seconds. A longer
> `Retry-After` is not slept through: the error is raised immediately, `RateLimitError.retry_after` carries the raw
> value, and you decide whether to wait. Sustained traffic above the limit still needs pacing on your side, and you can
> still receive `RateLimitError` once the retries are exhausted.

> [!WARNING]
> Mutations are not replayed. A `POST`, `PUT` or `DELETE` is retried only when the failure proves the request never
> reached the server, such as a refused connection. After a read timeout, a dropped connection or a 5xx the change may
> already have been committed, so the SDK raises instead of sending it again — the remote outcome is genuinely unknown
> and only you can decide how to reconcile it.

Every transport failure surfaces as `DiscogsConnectionError`, chained to the underlying `httpx2` error
(`exc.__cause__`). Reads retry the transient ones: network errors, timeouts, and a keep-alive connection the server
closed (`RemoteProtocolError`). Failures that would repeat identically — `LocalProtocolError`, `UnsupportedProtocol`,
`ProxyError`, `DecodingError`, `TooManyRedirects` — are raised at once for every method.

### Fetching resources

```python
# Releases, artists, masters, labels
release = client.releases.get(352665)
print(release.title)  # lazy — HTTP fires here → "The Downward Spiral"

artist = client.artists.get(3857)
print(artist.name)  # "Nine Inch Nails"

master = client.masters.get(3719)
label = client.labels.get(647)
```

These lazy resources are read-only views: assigning to one of their attributes raises `AttributeError`, since the
value would never reach Discogs. Where the API accepts writes, they go through a resource method such as
`release.rating.update()` or `user.wantlist.update()`. Those methods take the fields Discogs documents as typed
keyword parameters: an optional field you omit, or pass as `None`, is not sent, so an update leaves it unchanged, and a
misspelled name raises `TypeError` before any request is made.

Each identifier you pass (username, order id, release id and so on) is sent as a
single, percent-encoded path segment, so a `/`, `?` or `#` in it cannot reach a
different endpoint. `"."` and `".."` raise `ValueError` before any request is made.

### Search

```python
for result in client.search(query="Pretty Hate Machine", type="release", year="1989"):
    print(f"[{result.type}] {result.title}")
    # [release] Nine Inch Nails - Pretty Hate Machine
```

### Sub-resources

```python
# Community rating
rating = client.releases.get(352665).rating.get()
print(f"Average: {rating.rating.average}")  # Average: 4.49

# Artist releases with sorting
for rel in client.artists.get(3857).releases.list(sort="year", sort_order="desc"):
    print(f"{rel.title} ({rel.year})")

# Master versions with filters
for v in client.masters.get(3719).versions.list(format="Vinyl", country="US"):
    print(f"{v.title} [{v.format}]")
```

### Async usage

```python
import asyncio
from discogs_sdk import AsyncDiscogs


async def main():
    async with AsyncDiscogs() as client:  # reads DISCOGS_TOKEN from environment
        # Must await lazy resources in async mode
        release = await client.releases.get(352665)
        print(release.title)

        # Async iteration for paginated results
        async for result in client.search(query="Nine Inch Nails"):
            print(result.title)


asyncio.run(main())
```

### Collection

```python
user = client.users.get("your_username")

# Folders
folders = user.collection.folders.list()
user.collection.folders.create(name="Industrial")

# Browse folder contents
for item in user.collection.folders.get(0).releases.list(sort="added"):
    print(item.basic_information.title)

# Add a release. The response identifies the copy you just created, which is
# how you tell it apart from copies of the same release you already own.
created = user.collection.folders.get(1).releases.create(release_id=352665)

# Deep chaining: folder -> release -> instance -> fields
instances = user.collection.folders.get(1).releases.get(352665).instances
instances.get(created.instance_id).fields.update(field_id=1, value="Signed copy")

# Collection value
value = user.collection.value.get()
print(f"Median: {value.median}, Maximum: {value.maximum}")

# Wantlist — the add takes only the release id; Discogs drops notes and ratings
# sent to it, so they go in the update call
user.wantlist.create(release_id=352665)
user.wantlist.update(352665, notes="Original pressing", rating=4)
for want in user.wantlist.list():
    print(want.basic_information.title)
```

### Marketplace

```python
# Listings
listing = client.marketplace.listings.get(123456789)
# The response identifies the new listing; it is not a full Listing.
created = client.marketplace.listings.create(
    release_id=352665,
    condition="Very Good Plus (VG+)",
    price=25.00,
)
client.marketplace.listings.update(created.listing_id, price=22.50)
client.marketplace.listings.delete(created.listing_id)

# Orders
for order in client.marketplace.orders.list(status="Payment Received"):
    print(f"Order {order.id}: {order.status}")

# Fee lookup
fee = client.marketplace.fee.get(price=25.00, currency="USD")
```

### Error handling

```python
from discogs_sdk import NotFoundError, RateLimitError, AuthenticationError

try:
    release = client.releases.get(999999999)
    _ = release.title
except NotFoundError as exc:
    print(f"Not found: {exc.method} {exc.url}")
except RateLimitError as exc:
    print(f"Rate limited, retry after {exc.retry_after}s")
    if exc.ratelimit:  # the 429's own X-Discogs-Ratelimit* headers, when present
        print(f"{exc.ratelimit.used}/{exc.ratelimit.limit} used this window")
except AuthenticationError:
    print("Bad credentials")
```

Every `DiscogsAPIError` names the request that failed: `exc.method` (upper-case) and
`exc.url` (fully resolved, query string included, as in `RequestEvent.url`), so an
error raised while iterating a listing points at the page that failed. `exc.ratelimit`
holds the `RateLimit` from the failing response's `X-Discogs-Ratelimit*` headers for
any status, or `None` when they are missing. All three are `None` on an exception you
construct yourself without them.

The full exception hierarchy:

```
DiscogsError
├── DiscogsConnectionError
├── CacheMissError
└── DiscogsAPIError
    ├── AuthenticationError  (401)
    ├── ForbiddenError       (403)
    ├── NotFoundError        (404)
    ├── ValidationError      (422)
    └── RateLimitError       (429)
```

The OAuth helpers in `discogs_sdk.oauth` (`get_request_token`, `get_access_token`
and their `async_` variants) raise from the same hierarchy: an error status maps as
above (401 → `AuthenticationError`, 400 → `DiscogsAPIError`, …), a transport failure
raises `DiscogsConnectionError`, and a successful response missing either
`oauth_token` or `oauth_token_secret` raises `DiscogsAPIError` naming any
`oauth_problem`. They never retry: the access-token exchange is sent at most once.

`client.exports.request()` and `client.uploads.create()`, `change()` and `delete()`
return the new job's id, read from the `Location` header of the response, for
`exports.get()`, `exports.download()` and `uploads.get()`. A successful response
whose `Location` is missing or does not end in an integer raises `DiscogsAPIError`
with that 2xx status: Discogs accepted the request, so the job probably exists. Find
it with `exports.list()` or `uploads.list()` instead of submitting again.

`str(exc)` on a `DiscogsAPIError` is a short summary, `"<status>: <message>"`. The
message is the body's `message` when that is a non-empty string, otherwise the body
itself, cut to 500 characters so a gateway error page does not flood logs; the errors
above that the SDK raises on a 2xx describe the problem instead. The full payload
stays in `exc.response_body`.

Every SDK exception survives `pickle`, `copy.copy` and `copy.deepcopy` with its
attributes (`status_code`, `response_body`, `retry_after`, `ratelimit`, `method`,
`url`) and any notes, so an error raised in a `ProcessPoolExecutor` or
`multiprocessing` worker reaches the parent as the same class. As with any pickled
exception, `__cause__`, `__context__` and the traceback are not carried over.

A paginated iterator (`.list()`, `search()`) that raises keeps its place. Whether the
request for a page fails, the page's envelope has the wrong shape, or one of its items
fails validation (Pydantic's `ValidationError`), `page`, the totals and the URLs stay as
they were before that page, and the next `next()` or `anext()` requests the same page
again. Items already yielded are not repeated, and none are skipped.

A wrong shape raises `DiscogsError` naming the endpoint, the key and the JSON type
received, for example `GET /users/trent_reznor/submissions: "submissions" is null,
expected an object`. That covers a body that is not an object, a `pagination` or a
non-empty `pagination.urls` that is not an object, and an items key that is not an
array (or, for nested items, not an object on the way). An absent items key is an
empty page.

#### Exceptions outside `DiscogsError`

Apart from the `ValueError`s that reject invalid arguments, such as incomplete
credentials, the SDK lets three exceptions through on purpose, and
`except DiscogsError` catches none of them:

- `json.JSONDecodeError` (a `ValueError`) when a 2xx response that the SDK reads as
  JSON has a body that is not JSON; a body that is not even UTF-8 raises
  `UnicodeDecodeError`, also a `ValueError`. Error statuses are mapped before the
  endpoint parses the body, and a success is not an API error, so the SDK raises the
  parse failure instead of a fallback value.
- `pydantic.ValidationError` (a `ValueError`) when a 2xx JSON body does not fit the
  model, such as a release whose `id` is not a number. The response was a success,
  so this is a parsing failure rather than an API error. Only a list page's envelope
  is checked by the SDK itself, as described above; its items go to Pydantic like
  any other model.
- `FileNotFoundError` (an `OSError`) from `client.uploads.create()`, `change()` or
  `delete()` when `file` names a path that does not exist. The file is read before
  any request is sent, so there is no response to map.

Transport failures are not among them: they surface as `DiscogsConnectionError`, as
described under [Authentication](#authentication).

Two unrelated classes are named `ValidationError`. `discogs_sdk.ValidationError` is
the HTTP 422 `DiscogsAPIError` above and is not a `ValueError`;
`pydantic.ValidationError` is the model parsing failure. Imported under the same
name, one silently shadows the other, so alias them:

```python
from discogs_sdk import ValidationError as ApiValidationError
from pydantic import ValidationError as ModelValidationError
```

## Examples

The [`examples/`](examples/) directory has runnable scripts for every feature:

- [`quickstart.py`](examples/quickstart.py) — first requests, lazy loading, search
- [`authentication.py`](examples/authentication.py) — all auth modes including OAuth 1.0a
- [`database.py`](examples/database.py) — releases, artists, masters, labels, search
- [`marketplace.py`](examples/marketplace.py) — listings, orders, fees, inventory
- [`collection.py`](examples/collection.py) — folders, instances, fields, wantlist
- [`async_usage.py`](examples/async_usage.py) — async client with await and async for
- [`advanced.py`](examples/advanced.py) — error handling, caching, custom HTTP clients, exports

## Configuration

| Parameter | Default | Description |
|---|---|---|
| `access_token_secret` | `None` | OAuth access token secret |
| `access_token` | `None` | OAuth access token |
| `base_url` | `https://api.discogs.com` | API base URL; pagination `next` links are rebased onto it, and a link to any origin other than it or `https://api.discogs.com` raises `DiscogsError` |
| `cache_dir` | `None` | Directory for SQLite cache; in-memory when omitted. Used only with `cache=True` |
| `cache_ttl` | `3600.0` | Cache time-to-live in seconds. Used only with `cache=True` |
| `cache` | `False` | `True` builds the built-in backend from `cache_dir` and `cache_ttl`; or pass a `ResponseCache` instance (see [Custom backends](#custom-backends)), which carries its own TTL and storage and is never closed by the client |
| `consumer_key` | `None` | OAuth consumer key |
| `consumer_secret` | `None` | OAuth consumer secret |
| `http_client` | `None` | Custom `httpx2.Client` or `httpx2.AsyncClient` |
| `max_retries` | `3` | Max retries, `>= 0` (`0` disables retries, a negative value raises `ValueError`); reads retry on 429/5xx, network errors, timeouts and dropped connections, mutations only on pre-send failures. A `Retry-After` above 60s raises immediately instead |
| `media_type` | `"discogs"` | Format of text fields: `"discogs"` (Discogs markup), `"html"` or `"plaintext"`; sent in the `Accept` header, and cached responses are kept apart per value |
| `on_request` | `None` | Callback receiving a `RequestEvent` per request |
| `timeout` | `30.0` | Request timeout in seconds for the HTTP client the SDK creates; an injected `http_client` keeps its own (see [Custom HTTP clients](#custom-http-clients)) |
| `token` | `None` | Personal access token |
| `user_agent` | `discogs-sdk/<version> +https://github.com/jmfontaine/discogs-sdk` | `User-Agent` header; a non-empty value replaces the default entirely |

With `cache=False`, the default, `cache_dir` and `cache_ttl` are ignored and no cache or directory is created; a
`ResponseCache` instance ignores them too. `cache_ttl=0` is accepted and expires every entry at once, so each read
refetches: it is not a way to say "no limit".

See [Authentication](#authentication) for how a credential mode is selected.

### Caching

Only successful `GET`/`HEAD` responses are cached, and a `GET` body that is not valid JSON (a gateway's HTML page, an
empty body) never is, so a bad response fails once instead of on every read; inventory export downloads, which are CSV,
are cached as they are. A body that is valid JSON but the wrong shape for its model is still cached until it expires.
Entries are keyed by method, fully resolved URL, the effective `Accept` representation, and a non-reversible digest of
the selected mode's credentials, so two clients sharing one cache — or one SQLite directory — never serve each other's
private responses, and unauthenticated traffic gets its own namespace. No token or secret is stored in a key.

Job status reads, `client.exports.get(id)` and `client.uploads.get(id)`, are never cached: each one reaches the API and
stores nothing, so a polling loop sees the job finish. A resolved proxy keeps its data, though, so poll with a new
`get()` call each time. Inside `cache_only()` these reads raise `CacheMissError`. `exports.list()`, `uploads.list()`
and `exports.download()` are cached as usual.

A successful write (`POST`, `PUT` or `DELETE` with a 2xx answer) clears the whole response cache, so no entry cached
before the write is served after it: not the written URL, not a list page, not a related folder or count. The rule
holds inside `no_cache()` too, and a cache shared between clients is cleared for all of them; entries are refetched on
demand, which costs requests. A failed write leaves the cache alone, and a cache that fails to clear is logged without
failing the write. Proxies and pages already resolved keep their in-memory data, so fetch a new proxy or iterate again
to see the change. Discogs itself may take a while to show a write, and a refetch made in that window caches what it
returned.

A cache built from `cache=True` belongs to the client, and `client.close()` closes it. A `ResponseCache` instance you
pass in stays yours, like an injected `http_client`: `client.close()` never closes it, so one cache can outlive any of
the clients sharing it. Call its `close()` yourself once every client using it is done.

The built-in caches drop an expired entry when the same request looks it up again, and remove every expired entry at
least once per 100 stores; the SQLite cache also does when it opens, so `cache.db` does not carry expired rows over
from earlier runs. Entries still within `cache_ttl` stay, so the cache's size is bounded by the TTL times the rate of
new responses, not by a fixed count. Deleted rows free pages inside `cache.db` for reuse; the file itself does not
shrink.

The SQLite cache never fails a request. If its database is locked by another process, read-only, full or corrupt,
it logs a warning on the `discogs_sdk` logger and carries on: a lookup counts as a miss and a store is skipped, so the
response still reaches you. Using the cache after it has been closed raises `RuntimeError`. The database runs in WAL
mode, so `cache.db-wal` and `cache.db-shm` may appear next to `cache.db`.

The SQLite database holds the full bodies of your private responses (identity, orders with buyer addresses,
collection), so put `cache_dir` somewhere only you can read, such as `~/.cache/my-app`, never a shared path like
`/tmp`. A directory the cache creates is private (`0o700`) and `cache.db` is `0o600`; on POSIX, an existing `cache.db`
that is a symlink or belongs to another user raises `PermissionError`. Entries keep only the `Content-Type`, `ETag` and
`Last-Modified` response headers.

`client.no_cache()` bypasses the cache for the current execution context. Scopes nest, the previous state is
restored even when the block raises, and each context carries its own scope, so leaving it in one thread or task
never affects another (a pool worker, or a task created before the block, does not see it; see
[Concurrency](#concurrency)):

```python
with client.no_cache():
    fresh = client.releases.get(352665).title  # always hits the API
```

`client.cache_only()` is the mirror image: inside the scope a request the cache cannot serve — a miss, an expired
entry, a non-`GET`, a job status read, a disabled cache, an injected `http_client` without SDK credentials or an
enclosing `no_cache()` — raises `CacheMissError` before any network I/O. It nests and restores exactly like
`no_cache()`, so you can try everything for free first:

```python
from discogs_sdk import CacheMissError

with client.cache_only():
    try:
        title = client.releases.get(352665).title  # never hits the API
    except CacheMissError as exc:
        print(f"Not cached: {exc.method} {exc.url}")
```

#### Custom backends

The cache types are exported from `discogs_sdk`. To store responses elsewhere, subclass `ResponseCache` and implement
four methods: `get(key)` returns a `CacheEntry`, the `(status_code, headers, body)` tuple, or `None` on a miss or an
expired entry; `set(key, status_code, headers, body)` stores one; `clear()` drops every entry; `close()` releases
whatever the backend holds. The base constructor takes the `ttl` in seconds, and the backend is the one that enforces
it. A backend must be thread-safe: so that disk I/O never blocks the event loop, `AsyncDiscogs` may call `get`, `set`
and `clear` from worker threads, while `clear_cache()` and `close()` run on the calling thread, and concurrent requests
overlap. The example takes a lock for that:

```python
import threading

from discogs_sdk import CacheEntry, Discogs, ResponseCache


class DictCache(ResponseCache):
    """Unbounded dict, no expiry."""

    def __init__(self) -> None:
        super().__init__(ttl=0)
        self._lock = threading.Lock()
        self._store: dict[str, CacheEntry] = {}

    def get(self, key: str) -> CacheEntry | None:
        with self._lock:
            return self._store.get(key)

    def set(
        self, key: str, status_code: int, headers: dict[str, str], body: bytes
    ) -> None:
        with self._lock:
            self._store[key] = (status_code, headers, body)

    def clear(self) -> None:
        with self._lock:
            self._store.clear()

    def close(self) -> None:
        pass


client = Discogs(cache=DictCache())
```

`MemoryCache(ttl)` and `SQLiteCache(ttl, cache_dir)` are the backends `cache=True` builds. Create one yourself to pass
the same instance to several clients, `Discogs` and `AsyncDiscogs` alike. Clients with the same credentials share
entries; keys carry a non-reversible digest of the credentials, so clients with different ones still never see each
other's private responses. Close the cache yourself once every client using it is done:

```python
from pathlib import Path

from discogs_sdk import Discogs, SQLiteCache

shared = SQLiteCache(ttl=3600, cache_dir=Path.home() / ".cache" / "my-app")
first = Discogs(token="YOUR_TOKEN", cache=shared)
second = Discogs(token="YOUR_TOKEN", cache=shared)
print(first.releases.get(352665).title)  # fetched from the API
print(second.releases.get(352665).title)  # served from the shared cache
first.close()
second.close()
shared.close()  # a client never closes a cache passed in
```

### Observability

Every live response carries Discogs' `X-Discogs-Ratelimit`, `X-Discogs-Ratelimit-Used` and
`X-Discogs-Ratelimit-Remaining` headers. `client.ratelimit` holds them as a `RateLimit(limit, used, remaining)`
parsed from the most recent **network** response: a cache hit never touches it, and a response without the headers
leaves the last known value in place. It is `None` until the first live response.

`on_request` receives one `RequestEvent` per logical request, invoked synchronously — after a cache hit, or after the
final network response and before any error is raised, so the event exists even when the call raises. Intermediate
retried attempts do not emit; the final event carries `attempts`. A call that ends in `DiscogsConnectionError` or
`CacheMissError` emits nothing, since there is no response. Exceptions raised by the callback propagate unchanged;
keep it cheap and never block in it, because the async client calls it from the event loop.

| Field | Meaning |
|---|---|
| `method` | Upper-case HTTP method |
| `url` | Fully resolved URL, query string included |
| `status_code` | Status of the response that ended the call |
| `source` | `"network"` or `"cache"` |
| `elapsed_ms` | Final network attempt, or the cache lookup |
| `attempts` | Network attempts (`1` = no retry); `0` on a cache hit |
| `stored` | Whether this call wrote the response to the cache |
| `ratelimit` | `RateLimit` from the response; always `None` on a cache hit |

A minimal pacing loop:

```python
import time

client = Discogs(
    token="...", on_request=lambda e: print(e.source, e.status_code, e.elapsed_ms)
)
title = client.releases.get(352665).title  # lazy — the GET fires here
if client.ratelimit and client.ratelimit.remaining < 10:
    time.sleep(60)
```

### Concurrency

One client may be shared by several threads (`Discogs`) or tasks (`AsyncDiscogs`). A few things are tied to a
context or an object rather than to the client.

`no_cache()` and `cache_only()` apply to the current execution context only. A task copies its context when it is
created, so a task created before the block does not see the scope. A pool worker runs in its own context, so
`with client.no_cache(): pool.map(...)` still serves the workers from the cache; on a free-threaded 3.14+ build, where
a new thread starts with a copy of its creator's context, a worker spawned inside the block keeps the scope for its
lifetime instead. Either way, enter the scope inside the worker, or run the work in a copy of the context taken inside
the scope. Take a new copy for each call: one `Context` cannot run in two threads at once, and sharing it raises
`RuntimeError`.

```python
import contextvars
from concurrent.futures import ThreadPoolExecutor


def title(release_id):
    return client.releases.get(release_id).title


with ThreadPoolExecutor() as pool:
    with client.no_cache():
        # copy_context() runs here, inside the scope
        future = pool.submit(contextvars.copy_context().run, title, 352665)
    print(future.result())  # The Downward Spiral, fetched from the API
```

An unresolved `AsyncDiscogs` proxy awaited from several tasks at once sends one request and hands every task the same
model (or the same exception; a failure is not kept, so the next await tries again). That request runs in the context
of the task that awaited first, so its `no_cache()` or `cache_only()` scope, or the lack of one, applies to all of them.
Enter the scope before the first await, or call `get()` again inside it for a proxy of your own. Cancelling one waiting
task leaves the request and the other tasks alone.

A page returned by `.list()` or `search()` is a single-pass iterator for one consumer. Several threads or tasks
iterating the same page race on its position and can fetch a page twice and yield its items twice, and a page that
is exhausted yields nothing on a second pass. Call `.list()` or `search()` again for each consumer and each pass.

`on_request` runs on the thread or task that made the request, once that request completes, so events arrive in
completion order rather than start order, and with several threads the callback can run in several of them at once.
`client.ratelimit` is the value from whichever live response completed last, not necessarily the one with the lowest
`remaining`: under concurrency it is a hint for pacing, not a global count.

The cache that `cache=True` builds locks internally. A custom `ResponseCache` needs its own locking whenever several
threads share it, and with `AsyncDiscogs` that is always the case: the async client may call the cache from worker
threads so that disk I/O never blocks the event loop, and it calls its methods without synchronisation.

### Custom HTTP clients

An injected `http_client` must be an `httpx2` client — the SDK is built on [httpx2](https://github.com/pydantic/httpx2),
Pydantic's maintained continuation of httpx. It owns its transport configuration and its lifecycle: `client.close()`
never closes it. SDK credentials, User-Agent and media type are still applied per request, so they describe the request
without mutating your client's defaults, and its own `httpx2.Auth` cannot replace credentials you gave the SDK.

An injected client's timeout is part of that transport configuration. The `timeout` argument only configures a client
the SDK creates, so `Discogs(http_client=httpx2.Client(timeout=99.0), timeout=1.0)` sends every request with the
injected client's 99-second timeout, even though `client.timeout` reports `1.0`. To control the timeout of an injected
client, set it on that client.

When the SDK has no credentials of its own, your client's authentication is preserved and its responses are never
cached, even with `cache=True`, because the SDK cannot tell whose account they belong to. Inside `cache_only()` every
request from such a client therefore raises `CacheMissError`.

## Field naming

Model fields use clean Python names. Where the Discogs API uses inconsistent or cryptic keys, the SDK provides a readable alias while still accepting the raw API name during deserialization:

| API field | Python attribute | Models |
|---|---|---|
| `anv` | `name_variation` | `ArtistCredit` |
| `catno` | `catalog_number` | `LabelCredit`, `Company`, `LabelRelease`, `MasterVersion`, `SearchResult` |
| `created_ts` | `created_at` | `Export`, `Upload`, `List_` |
| `curr_abbr` | `currency_code` | `OriginalPrice`, `User` |
| `curr_id` | `currency_id` | `OriginalPrice` |
| `extraartists` | `extra_artists` | `Release`, `Track` |
| `finished_ts` | `finished_at` | `Export`, `Upload` |
| `from` | `from_user` | `OrderMessage` |
| `list_id` | `id` | `List_` |
| `modified_ts` | `modified_at` | `List_` |
| `namevariations` | `name_variations` | `Artist` |
| `qty` | `quantity` | `Format` |
| `sublabels` | `sub_labels` | `Label` |
| `uri150` | `uri_150` | `Image` |

The Python name is the typed attribute. API names still resolve as attributes at runtime, but only dynamically: a type
checker treats them like any misspelled attribute, so read them through `getattr` where one is in play:

```python
release = client.releases.get(352665)  # The Downward Spiral
print(release.extra_artists)  # Python name, typed
print(getattr(release, "extraartists"))  # API name, runtime only — same value
```

An API name that is a Python keyword, such as `from`, is reachable only through `getattr(message, "from")`.

An API name resolves to the same normalized value as the Python name, so `getattr(export, "created_ts")` is a
`datetime` too. To read what Discogs sent, use [`raw`](#original-api-values).

## Timestamps

Discogs sends timestamps as strings in several shapes:

| Shape | Example | Where |
|---|---|---|
| ISO 8601 with an offset | `2005-01-03T23:17:26-08:00` | most fields |
| A space instead of `T` | `2011-08-30 14:21:45-07:00` | `User.registered` |
| No offset | `2026-10-11T07:07:34` | `Export` and `Upload` (`created_at`, `finished_at`) |

The SDK parses every one of them into a timezone-aware `datetime`, so you can compare and sort values from any
endpoint. This covers `Release` (`date_added`, `date_changed`), `CollectionItem.date_added`, `ListSummary`
(`date_added`, `date_changed`), `List_` (`created_at`, `modified_at`), `User.registered`, `Listing.posted`, `Order`
(`created`, `last_activity`), `OrderMessage.timestamp`, `Export` and `Upload` (`created_at`, `finished_at`).

- An offset that Discogs sends stays as sent.
- A value without an offset is read as `America/Los_Angeles` time and gets the fixed offset in force then (`-08:00` or
  `-07:00`), the same kind of `tzinfo` as a value that arrives with one. Discogs does not document its zone; this is an
  assumption. An export requested at 14:07:34 UTC reported `07:07:34`, and the offsets Discogs does send switch between
  `-08:00` and `-07:00` with daylight saving. In the hour repeated when daylight saving ends, the SDK picks the first
  occurrence.
- An empty string becomes `None`.
- A value the SDK cannot parse becomes `None`, and the SDK logs a warning on the `discogs_sdk` logger. The model still
  loads, and `raw` keeps the string.

`model_dump(mode="json")` writes ISO 8601 with an explicit offset, and `model_validate` reads it back unchanged.

## Original API values

Every model keeps the mapping it was validated from in `raw`. Use it to read a value as Discogs sent it, before
renaming or parsing:

```python
release = client.releases.get(352665)  # The Downward Spiral
print(release.date_added)  # datetime(2005, 1, 3, 23, 17, 26, tzinfo=...)
print(release.raw["date_added"])  # '2005-01-03T23:17:26-08:00'
print(release.extra_artists[0].raw)  # a nested model holds its own part of the payload
```

`raw` is the decoded response itself, not a copy; its type is a read-only `Mapping`, but code holding the same dict can
still change it, and later reads see the change. Holding a model keeps the whole payload in memory. `model_dump()` and
`==` ignore `raw`. A model you build with keyword arguments or with `model_validate(model.model_dump())` reports that
input instead of an API response.

## Contributing

Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.

## License

discogs-sdk is licensed under the [Apache License 2.0](LICENSE.txt).
