Stavrophora: Asynchronous Python client for the Crossref API
Samuel Mok -- s.mok@utwente.nl -- 2026
Stavrophora is an async Python client for the Crossref REST API, built on bibliofabric.
License: MIT
Features
- Async by design -- built on
httpx+asynciowith proper connection pooling - Typed end-to-end -- Pydantic v2 models with
extra="allow"(the Crossref spec is incomplete; extra fields are preserved, never dropped) - Cursor pagination --
async for work in session.works.iterate(...)overmessage.next-cursor - Polite pool by default -- one
mailto=line triples your rate limit - Batch DOI lookup -- comma-separated repeated
filter=doi:keys (Crossref's supported OR form) - Scoped works --
session.journals.works(issn),session.funders.works(id),session.prefixes.works(prefix)
Installation
uv add stavrophora
Or with pip: pip install stavrophora. Requires Python >=3.12.
Quick Start
import asyncio
from stavrophora import StavrophoraSession
async def main():
async with StavrophoraSession(mailto="you@example.org") as session:
# Get a single work by DOI
work = await session.works.get("10.1038/nature12373")
print(work.title[0], work.issued.date_parts)
# Search
response = await session.works.search(search="crow nesting", page_size=5)
for item in response.message.items:
print(item.doi, item.is_referenced_by_count)
asyncio.run(main())
No authentication required. Pass a mailto (parameter or STAVROPHORA_MAILTO env var) to use the polite pool: verified live 2026-09, anonymous requests are capped at 1 request/s and 1 concurrent request, polite at 3/s and 3 concurrent (advertised in the x-rate-limit-* response headers). Both the mailto query parameter and mailto: in the User-Agent are honored; stavrophora uses the query parameter.
Basic Usage
Get a single entity
work = await session.works.get("10.1038/nature12373") # DOI (bare or doi.org-prefixed)
journal = await session.journals.get("0028-0836") # ISSN or eISSN
funder = await session.funders.get("10.13039/501100000923")
member = await session.members.get("78") # e.g. Elsevier
prefix = await session.prefixes.get("10.1016") # no /prefixes list route exists
Search, sort, filter, select
from stavrophora.endpoints import WorksFilters
response = await session.works.search(
filters=WorksFilters(type="journal-article", from_pub_date="2026-01-01"),
sort_by="is-referenced-by-count:desc",
select=["DOI", "title", "is-referenced-by-count"],
page_size=20,
)
Crossref has no publisher filter (400, verified live); scope by prefix, member or ror-id instead.
Iterate all results (cursor)
async for work in session.works.iterate(
filters={"from-index-date": "2026-08-01"}, page_size=1000
):
process(work)
The last non-empty page still carries a next-cursor; stavrophora follows cursors until the API returns an empty page (one extra request, by design).
Scoped works
nature_works = session.journals.works("0028-0836")
response = await nature_works.search(page_size=5)
ut_works = session.prefixes.works("10.3990") # UT student theses
async for w in ut_works.iterate(page_size=1000):
...
Batch DOI lookup
found = await session.works.batch_get_by_doi(
["10.1038/nature12373", "10.1103/PhysRevLett.116.061102"]
)
Keys are normalized bare lowercase DOIs; misses are absent from the dict. Requests are batched 50 DOIs per call via repeated filter=doi: keys (the pipe form doi:a|b returns zero results -- verified live).
Registration agency
agency = await session.works.agency("10.1038/nature12373")
print(agency.agency.label) # "Crossref"
Configuration
Settings load from env vars prefixed STAVROPHORA_ (or .env / secrets.env):
STAVROPHORA_MAILTO=s.mok@utwente.nl
All bibliofabric settings (REQUEST_TIMEOUT, MAX_RETRIES, ...) are inherited under the same prefix.
Known Crossref API Quirks
- No pipe-OR in filters.
filter=doi:a|bsilently returns 0 results (OpenAlex-style). Repeatedkey:valuepairs (filter=doi:a,doi:b) are the supported OR form. - Unknown query parameters are rejected with 400 (no silent ignoring). The client therefore sends only
rows,offset,query,filter,sort,order,select,cursor,mailto. pageis not a parameter -- paging isoffset(0-based, capped at 10,000 total) +rows(max 1000, 400 otherwise). Deep paging must usecursor.- Cursor exhaustion is implicit: the last non-empty page still returns a
next-cursor; only the following request returns an emptyitemsand no cursor. /prefixeshas no list route (404) -- only/prefixes/{prefix}and/prefixes/{prefix}/works./licenseshas no single-item route (404) -- only the list.- The
messageenvelope is one level deep for everything: single items live inmessage, lists inmessage.itemswith totals inmessage.total-results. - Rate limits changed 2025-12-01: anonymous 1 req/s / 1 concurrent, polite 3 req/s / 3 concurrent (older docs say 5/10 req/s). Every response carries
x-rate-limit-*headers and the advertised limit identifies the pool (live 2026-09): anonymous1, polite3on query routes and10on cacheable single-record routes (the pre-2025-12 value) -- treat 3 req/s as the polite ceiling. - API is served at both
https://api.crossref.organd.../v1with identical payloads (verified 2026-09); stavrophora pins/v1, the versioned form. - The Crossref spec is incomplete -- several returned fields are missing from the official schema. All models use
extra="allow", same defense as aletheca. - Schemas differ between list and single-item routes (verified live):
/journals/{issn}returnstitleas a bare string while/journalsreturns a list; memberprefixentries are{name, value}objects. The models normalize both shapes (SafeStrList, dict-typed prefix entries). /typesignoresrows/offset(verified live 2026-09): the route always returns the complete list (~30 types) regardless of paging parameters./prefixes/{prefix}returnsmemberandprefixas fullhttps://id.crossref.org/member/297-style URIs (verified live 2026-09). ThePrefixmodel normalizes both the URI and bare forms to bare values ("297","10.1038").
Development
uv sync
uv run pytest # mocked tests (live tests excluded by default)
uv run pytest -m live_api # live API smoke tests
uv run ruff check .
uvx ty check src/
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file stavrophora-0.1.0.tar.gz.
File metadata
- Download URL: stavrophora-0.1.0.tar.gz
- Upload date:
- Size: 18.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
77a16b21e2fd50e425c68c1b2af00dc2ef9e387acb42f51991cb23788e901196
|
|
| MD5 |
135624019b2aa08a086bd8c320cff9c0
|
|
| BLAKE2b-256 |
caaa72bcfd987c6c8a828d43a3488e6271a046cc3207fbc6e45c4c1163822ca7
|
Provenance
The following attestation bundles were made for stavrophora-0.1.0.tar.gz:
Publisher:
python-ci.yml on utsmok/stavrophora
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
stavrophora-0.1.0.tar.gz -
Subject digest:
77a16b21e2fd50e425c68c1b2af00dc2ef9e387acb42f51991cb23788e901196 - Sigstore transparency entry: 2715166424
- Sigstore integration time:
-
Permalink:
utsmok/stavrophora@f7007f410235128497aa6a8a1bc8d064d5384934 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/utsmok
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-ci.yml@f7007f410235128497aa6a8a1bc8d064d5384934 -
Trigger Event:
push
-
Statement type:
File details
Details for the file stavrophora-0.1.0-py3-none-any.whl.
File metadata
- Download URL: stavrophora-0.1.0-py3-none-any.whl
- Upload date:
- Size: 28.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c47199b4619e2729d6ffd6ee43a7a0aa995fb418dee856387696a02820de29fa
|
|
| MD5 |
9efe07525ab46e642eaaae4fa8a2b61f
|
|
| BLAKE2b-256 |
aa96cbfad2ebeea4f9947dff9c6b6ed16ba63ac6d5a19ee6f74088a1c19ddbae
|
Provenance
The following attestation bundles were made for stavrophora-0.1.0-py3-none-any.whl:
Publisher:
python-ci.yml on utsmok/stavrophora
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
stavrophora-0.1.0-py3-none-any.whl -
Subject digest:
c47199b4619e2729d6ffd6ee43a7a0aa995fb418dee856387696a02820de29fa - Sigstore transparency entry: 2715166515
- Sigstore integration time:
-
Permalink:
utsmok/stavrophora@f7007f410235128497aa6a8a1bc8d064d5384934 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/utsmok
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-ci.yml@f7007f410235128497aa6a8a1bc8d064d5384934 -
Trigger Event:
push
-
Statement type: