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
Metadata
Release files for stavrophora 0.2.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| stavrophora-0.2.1.tar.gz | 19.3 kB | With provenance | Details for stavrophora-0.2.1.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| stavrophora-0.2.1-py3-none-any.whl | Python 3 | none | any | Details for stavrophora-0.2.1-py3-none-any.whl |
Total release size: 48.3 kB
Release files / stavrophora-0.2.1.tar.gz
| Download URL | stavrophora-0.2.1.tar.gz |
|---|---|
| Size | 19.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
773b351d16723ac971df117c08e6f76ea65aaf448def2e28d99293f1351fea32
|
|
BLAKE2b-256 checksum How to use checksums |
6d71dd102938fdd02ce5bedc0f0e4f308cbd3bcdd01e13e9e25d8c28d8d74905
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 8, 2026.
Transparency logRelease files / stavrophora-0.2.1-py3-none-any.whl
| Download URL | stavrophora-0.2.1-py3-none-any.whl |
|---|---|
| Size | 28.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
63ca7c0a2f9a3618b8bc3b73b2e201e90492a80bb007d3214df63bff30f305a1
|
|
BLAKE2b-256 checksum How to use checksums |
6e3be69675bcd8efec0adb1ad2fd38986d5553237440c1dbe505f3988d1fc006
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 8, 2026.
Transparency logSecurity for stavrophora 0.2.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for stavrophora-0.2.1.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 8, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.