oalex 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- oalex/__init__.py +34 -0
- oalex/_backoff.py +103 -0
- oalex/_cache.py +60 -0
- oalex/_rate_limit.py +39 -0
- oalex/client.py +415 -0
- oalex/errors.py +49 -0
- oalex/py.typed +0 -0
- oalex/types.py +252 -0
- oalex-0.2.0.dist-info/METADATA +209 -0
- oalex-0.2.0.dist-info/RECORD +12 -0
- oalex-0.2.0.dist-info/WHEEL +4 -0
- oalex-0.2.0.dist-info/licenses/LICENSE +21 -0
oalex/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""oalex: async Python client for the OpenAlex scholarly works API.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from oalex import Client
|
|
7
|
+
|
|
8
|
+
async def main() -> None:
|
|
9
|
+
async with Client(api_key="...") as oa:
|
|
10
|
+
works = await oa.search("attention is all you need", per_page=5)
|
|
11
|
+
for w in works:
|
|
12
|
+
print(w.id, w.title)
|
|
13
|
+
|
|
14
|
+
asyncio.run(main())
|
|
15
|
+
|
|
16
|
+
See https://help.openalex.org/api/ for the underlying API reference.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from oalex.client import Client
|
|
20
|
+
from oalex.errors import OalexError, OalexRateLimited, OalexRequestError, OalexUnavailable
|
|
21
|
+
from oalex.types import Author, Work, parse_work
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Author",
|
|
25
|
+
"Client",
|
|
26
|
+
"OalexError",
|
|
27
|
+
"OalexRateLimited",
|
|
28
|
+
"OalexRequestError",
|
|
29
|
+
"OalexUnavailable",
|
|
30
|
+
"Work",
|
|
31
|
+
"parse_work",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
__version__ = "0.2.0"
|
oalex/_backoff.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""HTTP retry with exponential backoff.
|
|
2
|
+
|
|
3
|
+
OpenAlex returns 429 (rate-limited) and 5xx (server busy) under load.
|
|
4
|
+
Most of these failures are transient; the upstream's Retry-After header
|
|
5
|
+
tells us when the burst has cleared. ``with_backoff`` runs the request,
|
|
6
|
+
retries on retryable statuses (429 + 5xx) and transport errors with a
|
|
7
|
+
1s/2s/4s schedule (three retries, ~7s of waiting in the worst case), and
|
|
8
|
+
honors Retry-After when the server suggests a longer wait.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import logging
|
|
15
|
+
from collections.abc import Awaitable, Callable
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
_log = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
_RETRYABLE_STATUS: frozenset[int] = frozenset({429, 500, 502, 503, 504})
|
|
22
|
+
|
|
23
|
+
# 1s, 2s, 4s: three retries plus the initial attempt gives four tries
|
|
24
|
+
# and a worst-case ~7s wait. Higher caps don't help in practice: when an
|
|
25
|
+
# upstream's 429 cooldown is on the order of minutes, retrying longer
|
|
26
|
+
# just delays the user without succeeding.
|
|
27
|
+
_DEFAULT_DELAYS: tuple[float, ...] = (1.0, 2.0, 4.0)
|
|
28
|
+
|
|
29
|
+
# Retry-After above this means the wait isn't a burst cooldown. On a 429
|
|
30
|
+
# it's almost always the daily credit budget, which resets at midnight UTC,
|
|
31
|
+
# so we hand the response back instead of sleeping on it.
|
|
32
|
+
_MAX_RETRY_AFTER_SECONDS = 30.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def with_backoff(
|
|
36
|
+
do_request: Callable[[], Awaitable[httpx.Response]],
|
|
37
|
+
*,
|
|
38
|
+
delays: tuple[float, ...] = _DEFAULT_DELAYS,
|
|
39
|
+
) -> httpx.Response:
|
|
40
|
+
"""Run ``do_request()`` with exponential backoff on retryable failures.
|
|
41
|
+
|
|
42
|
+
Returns the final :class:`httpx.Response`, which may still be a 429 or
|
|
43
|
+
5xx once retries run out; the caller decides how to surface it. A
|
|
44
|
+
transport error (connection refused, timeout) is retried the same way
|
|
45
|
+
and re-raised after the last attempt.
|
|
46
|
+
|
|
47
|
+
``delays`` is the sequence of inter-attempt sleeps; total attempts =
|
|
48
|
+
``len(delays) + 1``.
|
|
49
|
+
"""
|
|
50
|
+
last_attempt = len(delays)
|
|
51
|
+
for attempt in range(last_attempt + 1):
|
|
52
|
+
try:
|
|
53
|
+
response = await do_request()
|
|
54
|
+
except httpx.TransportError as exc:
|
|
55
|
+
if attempt == last_attempt:
|
|
56
|
+
raise
|
|
57
|
+
_log.warning(
|
|
58
|
+
"oalex: network error on attempt %d/%d (%s); retrying",
|
|
59
|
+
attempt + 1, last_attempt + 1, exc,
|
|
60
|
+
)
|
|
61
|
+
sleep_for = delays[attempt]
|
|
62
|
+
else:
|
|
63
|
+
if response.status_code not in _RETRYABLE_STATUS or attempt == last_attempt:
|
|
64
|
+
return response
|
|
65
|
+
if _budget_exhausted(response):
|
|
66
|
+
return response
|
|
67
|
+
sleep_for = delays[attempt]
|
|
68
|
+
hint = _parse_retry_after(response.headers.get("retry-after"))
|
|
69
|
+
if hint is not None:
|
|
70
|
+
if hint > _MAX_RETRY_AFTER_SECONDS:
|
|
71
|
+
return response
|
|
72
|
+
sleep_for = max(sleep_for, hint)
|
|
73
|
+
_log.warning(
|
|
74
|
+
"oalex: HTTP %d on attempt %d/%d; retrying",
|
|
75
|
+
response.status_code, attempt + 1, last_attempt + 1,
|
|
76
|
+
)
|
|
77
|
+
_log.info("oalex: backing off %.1fs", sleep_for)
|
|
78
|
+
await asyncio.sleep(sleep_for)
|
|
79
|
+
raise AssertionError("unreachable: the final attempt returns or raises")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _budget_exhausted(response: httpx.Response) -> bool:
|
|
83
|
+
# A 429 also fires for bursts over 100 req/s while credits remain; only
|
|
84
|
+
# a zero balance means waiting seconds is pointless.
|
|
85
|
+
return (
|
|
86
|
+
response.status_code == 429
|
|
87
|
+
and response.headers.get("x-ratelimit-remaining", "").strip() == "0"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _parse_retry_after(value: str | None) -> float | None:
|
|
92
|
+
"""Parse a Retry-After header. Spec allows seconds-as-int OR HTTP-date.
|
|
93
|
+
|
|
94
|
+
We honor seconds. HTTP-date is rare and computing the delta is
|
|
95
|
+
error-prone (timezone, clock skew); falling back to the scheduled
|
|
96
|
+
delay is preferable to parsing it wrong.
|
|
97
|
+
"""
|
|
98
|
+
if value is None:
|
|
99
|
+
return None
|
|
100
|
+
try:
|
|
101
|
+
return float(value.strip())
|
|
102
|
+
except ValueError:
|
|
103
|
+
return None
|
oalex/_cache.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Filesystem-backed key/value cache for HTTP responses.
|
|
2
|
+
|
|
3
|
+
Repeated CLI / REPL invocations on the same record waste network time
|
|
4
|
+
and, since OpenAlex started metering list and search calls, daily
|
|
5
|
+
credits too. A small disk cache keyed by the request URL sidesteps
|
|
6
|
+
that. Entries older than ``ttl_seconds`` are treated as misses.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import tempfile
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
_log = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DiskCache:
|
|
22
|
+
"""Filesystem-backed key/value cache. Values are bytes; keys are arbitrary strings."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, directory: str | os.PathLike[str], ttl_seconds: int) -> None:
|
|
25
|
+
self._dir = Path(directory)
|
|
26
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
self._ttl = ttl_seconds
|
|
28
|
+
|
|
29
|
+
def get(self, key: str) -> bytes | None:
|
|
30
|
+
path = self._path_for(key)
|
|
31
|
+
# Another process can prune the directory between stat and read;
|
|
32
|
+
# either way that's a miss, not an error.
|
|
33
|
+
try:
|
|
34
|
+
if (time.time() - path.stat().st_mtime) > self._ttl:
|
|
35
|
+
return None
|
|
36
|
+
return path.read_bytes()
|
|
37
|
+
except FileNotFoundError:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
def set(self, key: str, value: bytes) -> None:
|
|
41
|
+
path = self._path_for(key)
|
|
42
|
+
# A per-writer temp file: two processes caching the same key must not
|
|
43
|
+
# interleave writes into one shared .tmp before the rename.
|
|
44
|
+
try:
|
|
45
|
+
fd, tmp = tempfile.mkstemp(dir=self._dir, suffix=".tmp")
|
|
46
|
+
try:
|
|
47
|
+
with os.fdopen(fd, "wb") as fh:
|
|
48
|
+
fh.write(value)
|
|
49
|
+
os.replace(tmp, path)
|
|
50
|
+
except BaseException:
|
|
51
|
+
os.unlink(tmp)
|
|
52
|
+
raise
|
|
53
|
+
except OSError as exc:
|
|
54
|
+
# The response is already in hand; a full disk or read-only cache
|
|
55
|
+
# dir shouldn't turn a successful fetch into a failure.
|
|
56
|
+
_log.warning("oalex: could not write cache entry %s: %s", path.name, exc)
|
|
57
|
+
|
|
58
|
+
def _path_for(self, key: str) -> Path:
|
|
59
|
+
digest = hashlib.sha256(key.encode("utf-8")).hexdigest()
|
|
60
|
+
return self._dir / f"{digest}.bin"
|
oalex/_rate_limit.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Async rate limiter: at most one call per ``min_interval`` seconds, in order.
|
|
2
|
+
|
|
3
|
+
OpenAlex rejects bursts above 100 requests/second with a 429. The
|
|
4
|
+
client's default of one request per 0.1s stays an order of magnitude
|
|
5
|
+
under that, which leaves room for several processes sharing one IP.
|
|
6
|
+
Process-local; clients in separate processes keep their own limiters
|
|
7
|
+
and can collectively exceed the rate. That's acceptable for a
|
|
8
|
+
single-process client library; distributed deployments should layer
|
|
9
|
+
their own coordination.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RateLimiter:
|
|
19
|
+
"""Fixed-interval async rate limiter.
|
|
20
|
+
|
|
21
|
+
``acquire()`` returns immediately if at least ``min_interval`` seconds
|
|
22
|
+
have passed since the last release; otherwise sleeps until that's
|
|
23
|
+
true. Safe to call from many coroutines on one loop via an internal lock.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, min_interval_seconds: float) -> None:
|
|
27
|
+
if min_interval_seconds <= 0:
|
|
28
|
+
raise ValueError("min_interval_seconds must be positive")
|
|
29
|
+
self._interval = min_interval_seconds
|
|
30
|
+
self._lock = asyncio.Lock()
|
|
31
|
+
self._last_call = 0.0
|
|
32
|
+
|
|
33
|
+
async def acquire(self) -> None:
|
|
34
|
+
async with self._lock:
|
|
35
|
+
now = time.monotonic()
|
|
36
|
+
wait = self._interval - (now - self._last_call)
|
|
37
|
+
if wait > 0:
|
|
38
|
+
await asyncio.sleep(wait)
|
|
39
|
+
self._last_call = time.monotonic()
|
oalex/client.py
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
"""Async client for OpenAlex's ``/works`` endpoint.
|
|
2
|
+
|
|
3
|
+
The rate limiter uses ``asyncio.Lock``, so one instance belongs to one
|
|
4
|
+
event loop and isn't thread-safe. Both ``async with Client() as client:``
|
|
5
|
+
and manual ``aclose()`` are supported.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
from collections.abc import AsyncIterator, Sequence
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Final, Literal, overload
|
|
17
|
+
from urllib.parse import quote, urlencode
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from oalex._backoff import _parse_retry_after, with_backoff
|
|
22
|
+
from oalex._cache import DiskCache
|
|
23
|
+
from oalex._rate_limit import RateLimiter
|
|
24
|
+
from oalex.errors import OalexRateLimited, OalexRequestError, OalexUnavailable
|
|
25
|
+
from oalex.types import Work, _strip_doi_url, _strip_openalex_id_url, parse_work
|
|
26
|
+
|
|
27
|
+
_log = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
_API_BASE: Final = "https://api.openalex.org"
|
|
30
|
+
_API_KEY_ENV: Final = "OPENALEX_API_KEY"
|
|
31
|
+
_DEFAULT_CACHE_TTL_SECONDS: Final = 24 * 60 * 60
|
|
32
|
+
_DEFAULT_TIMEOUT: Final = 30.0
|
|
33
|
+
_DEFAULT_MIN_INTERVAL: Final = 0.1
|
|
34
|
+
# The API accepted per-page=200 when last checked (Sep 2026) even though the
|
|
35
|
+
# current docs say 100. Clamp here so an oversized value never becomes a 400.
|
|
36
|
+
_MAX_PER_PAGE: Final = 200
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _default_cache_dir() -> Path:
|
|
40
|
+
return Path.home() / ".cache" / "oalex"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Client:
|
|
44
|
+
"""Async client for the OpenAlex Works API.
|
|
45
|
+
|
|
46
|
+
OpenAlex meters usage against a daily credit budget (since February
|
|
47
|
+
2026). Requests without an API key share a small keyless budget,
|
|
48
|
+
enough for roughly a hundred searches a day. A free key from
|
|
49
|
+
https://openalex.org/settings/api raises it tenfold. Fetching a single
|
|
50
|
+
work by id is free either way; searches and filtered lists are not.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
api_key: OpenAlex API key, sent as a Bearer token so it never lands
|
|
54
|
+
in URLs, cache keys, or error messages. Falls back to the
|
|
55
|
+
``OPENALEX_API_KEY`` environment variable.
|
|
56
|
+
email: Sent as ``mailto=``. OpenAlex has ignored it since the
|
|
57
|
+
polite pool was retired; it stays accepted so older callers
|
|
58
|
+
keep working.
|
|
59
|
+
cache_dir: Disk cache location. Defaults to ``~/.cache/oalex/``.
|
|
60
|
+
ttl_seconds: Cache TTL in seconds. Defaults to 24 hours.
|
|
61
|
+
min_interval_seconds: Minimum time between requests. Defaults
|
|
62
|
+
to 0.1s (10 req/sec; OpenAlex's hard ceiling is 100).
|
|
63
|
+
timeout: Per-request HTTP timeout in seconds. Defaults to 30s.
|
|
64
|
+
Ignored when ``client`` is given; configure that client instead.
|
|
65
|
+
client: Optional pre-built :class:`httpx.AsyncClient` to reuse.
|
|
66
|
+
When provided, ``aclose()`` does NOT close it; that's the
|
|
67
|
+
caller's responsibility.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
*,
|
|
73
|
+
api_key: str | None = None,
|
|
74
|
+
email: str | None = None,
|
|
75
|
+
cache_dir: str | os.PathLike[str] | None = None,
|
|
76
|
+
ttl_seconds: int = _DEFAULT_CACHE_TTL_SECONDS,
|
|
77
|
+
min_interval_seconds: float = _DEFAULT_MIN_INTERVAL,
|
|
78
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
79
|
+
client: httpx.AsyncClient | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
key = api_key if api_key is not None else os.environ.get(_API_KEY_ENV)
|
|
82
|
+
self._headers = {"Authorization": f"Bearer {key.strip()}"} if key and key.strip() else {}
|
|
83
|
+
self._email = email.strip() if email and email.strip() else None
|
|
84
|
+
cache_path = Path(cache_dir) if cache_dir is not None else _default_cache_dir()
|
|
85
|
+
self._cache = DiskCache(cache_path, ttl_seconds=ttl_seconds)
|
|
86
|
+
self._rate = RateLimiter(min_interval_seconds)
|
|
87
|
+
self._owns_client = client is None
|
|
88
|
+
self._client = client if client is not None else httpx.AsyncClient(timeout=timeout)
|
|
89
|
+
|
|
90
|
+
async def __aenter__(self) -> Client:
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
94
|
+
await self.aclose()
|
|
95
|
+
|
|
96
|
+
async def aclose(self) -> None:
|
|
97
|
+
"""Close the underlying HTTP client if we own it."""
|
|
98
|
+
if self._owns_client:
|
|
99
|
+
await self._client.aclose()
|
|
100
|
+
|
|
101
|
+
async def search(
|
|
102
|
+
self,
|
|
103
|
+
query: str,
|
|
104
|
+
*,
|
|
105
|
+
per_page: int = 25,
|
|
106
|
+
year_min: int | None = None,
|
|
107
|
+
year_max: int | None = None,
|
|
108
|
+
) -> Sequence[Work]:
|
|
109
|
+
"""Search OpenAlex's full-text index and return the first page.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
query: Freeform query string passed to OpenAlex's ``search=``.
|
|
113
|
+
per_page: Maximum number of results to return, clamped to
|
|
114
|
+
1..200. Use :meth:`iter_search` for more than one page.
|
|
115
|
+
year_min: Earliest publication year, inclusive.
|
|
116
|
+
year_max: Latest publication year, inclusive.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Works in OpenAlex's relevance order. Records without a
|
|
120
|
+
parseable id are skipped.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
OalexRateLimited: Daily credit budget exhausted, or 429s outlasted retries.
|
|
124
|
+
OalexRequestError: OpenAlex rejected the query (bad filter, bad key).
|
|
125
|
+
OalexUnavailable: Transient upstream failure (5xx after
|
|
126
|
+
retries, network error, malformed JSON).
|
|
127
|
+
"""
|
|
128
|
+
params = _search_params(query, per_page=per_page, year_min=year_min, year_max=year_max)
|
|
129
|
+
payload = await self._get_json("/works", params)
|
|
130
|
+
return _parse_results(payload)
|
|
131
|
+
|
|
132
|
+
async def iter_search(
|
|
133
|
+
self,
|
|
134
|
+
query: str,
|
|
135
|
+
*,
|
|
136
|
+
year_min: int | None = None,
|
|
137
|
+
year_max: int | None = None,
|
|
138
|
+
per_page: int = _MAX_PER_PAGE,
|
|
139
|
+
max_results: int | None = None,
|
|
140
|
+
) -> AsyncIterator[Work]:
|
|
141
|
+
"""Yield search results across pages using OpenAlex's cursor paging.
|
|
142
|
+
|
|
143
|
+
Each page is one metered list call, so on the keyless budget a
|
|
144
|
+
broad query can run the day's credits out long before the result
|
|
145
|
+
set ends. Set ``max_results`` unless you really want everything.
|
|
146
|
+
Offset paging stops at 10,000 results; cursor paging doesn't.
|
|
147
|
+
"""
|
|
148
|
+
params = _search_params(query, per_page=per_page, year_min=year_min, year_max=year_max)
|
|
149
|
+
cursor: str | None = "*"
|
|
150
|
+
yielded = 0
|
|
151
|
+
while cursor is not None and (max_results is None or yielded < max_results):
|
|
152
|
+
payload = await self._get_json("/works", {**params, "cursor": cursor})
|
|
153
|
+
works = _parse_results(payload)
|
|
154
|
+
for work in works:
|
|
155
|
+
yield work
|
|
156
|
+
yielded += 1
|
|
157
|
+
# Return mid-page rather than at the top of the loop, which
|
|
158
|
+
# would first pay for a page we'd throw away.
|
|
159
|
+
if max_results is not None and yielded >= max_results:
|
|
160
|
+
return
|
|
161
|
+
meta = payload.get("meta")
|
|
162
|
+
next_cursor = meta.get("next_cursor") if isinstance(meta, dict) else None
|
|
163
|
+
# Stop on an empty page even if a next_cursor came back; trusting
|
|
164
|
+
# it would let a misbehaving cursor bill us per empty page forever.
|
|
165
|
+
cursor = next_cursor if isinstance(next_cursor, str) and works else None
|
|
166
|
+
|
|
167
|
+
async def fetch_work(self, work_id: str) -> Work | None:
|
|
168
|
+
"""Fetch a single Work by OpenAlex ID or DOI.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
work_id: A bare OpenAlex ID (``W2626778328``), an OpenAlex URL
|
|
172
|
+
(``https://openalex.org/W2626778328``, the form ``Work.url``
|
|
173
|
+
and ``raw["referenced_works"]`` use), an ``openalex:``
|
|
174
|
+
prefixed ID, or a ``doi:`` prefixed DOI
|
|
175
|
+
(``doi:10.1038/nature12373``). Bare DOIs are not
|
|
176
|
+
recognized; use :meth:`fetch_doi` for those.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
A :class:`Work`, or ``None`` if OpenAlex returns 404 or the id
|
|
180
|
+
has a prefix this client doesn't know. Merged-away ids follow
|
|
181
|
+
OpenAlex's redirect to the surviving record.
|
|
182
|
+
|
|
183
|
+
Raises:
|
|
184
|
+
OalexRateLimited: Daily credit budget exhausted.
|
|
185
|
+
OalexRequestError: OpenAlex rejected the request.
|
|
186
|
+
OalexUnavailable: Transient upstream failure.
|
|
187
|
+
"""
|
|
188
|
+
path = _work_path(work_id)
|
|
189
|
+
if path is None:
|
|
190
|
+
return None
|
|
191
|
+
return await self._fetch_work_at(path)
|
|
192
|
+
|
|
193
|
+
async def fetch_doi(self, doi: str) -> Work | None:
|
|
194
|
+
"""Fetch a Work by DOI.
|
|
195
|
+
|
|
196
|
+
Accepts a bare DOI (``10.1038/nature12373``), a ``doi:`` prefixed
|
|
197
|
+
one, or a ``doi.org`` URL.
|
|
198
|
+
"""
|
|
199
|
+
bare = _strip_doi_url(doi.removeprefix("doi:"))
|
|
200
|
+
if bare is None:
|
|
201
|
+
return None
|
|
202
|
+
return await self._fetch_work_at(_doi_path(bare))
|
|
203
|
+
|
|
204
|
+
async def fetch_referenced(
|
|
205
|
+
self,
|
|
206
|
+
work_id: str,
|
|
207
|
+
*,
|
|
208
|
+
limit: int = 10,
|
|
209
|
+
) -> Sequence[Work]:
|
|
210
|
+
"""Resolve a Work's ``referenced_works`` array into full :class:`Work` records.
|
|
211
|
+
|
|
212
|
+
OpenAlex stores each Work's outgoing citation list as an array of
|
|
213
|
+
work URLs. This fetches the parent, then fetches neighbors in array
|
|
214
|
+
order until ``limit`` of them resolve. Neighbor fetches are
|
|
215
|
+
singleton lookups, which OpenAlex doesn't charge credits for; a
|
|
216
|
+
batched ``ids.openalex`` filter would be one request but is billed
|
|
217
|
+
as a list call.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
work_id: Parent paper ID (accepts the same forms as
|
|
221
|
+
:meth:`fetch_work`).
|
|
222
|
+
limit: Maximum number of referenced Works to return.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Referenced Works in array order. A neighbor that 404s or keeps
|
|
226
|
+
failing after retries is skipped and the next one takes its
|
|
227
|
+
place. Returns ``()`` when the parent doesn't exist or has no
|
|
228
|
+
``referenced_works``.
|
|
229
|
+
|
|
230
|
+
Raises:
|
|
231
|
+
OalexUnavailable: Persistent failure fetching the parent.
|
|
232
|
+
Neighbor failures never escalate.
|
|
233
|
+
"""
|
|
234
|
+
return await self._fetch_graph_neighbors(work_id, field="referenced_works", limit=limit)
|
|
235
|
+
|
|
236
|
+
async def fetch_related(
|
|
237
|
+
self,
|
|
238
|
+
work_id: str,
|
|
239
|
+
*,
|
|
240
|
+
limit: int = 10,
|
|
241
|
+
) -> Sequence[Work]:
|
|
242
|
+
"""Resolve a Work's ``related_works`` array into full :class:`Work` records.
|
|
243
|
+
|
|
244
|
+
Same fan-out shape as :meth:`fetch_referenced`, but reads
|
|
245
|
+
``related_works``, OpenAlex's similarity neighborhood computed from
|
|
246
|
+
topic overlap. It isn't a citation relationship: a "related" Work
|
|
247
|
+
need not cite the source Work or be cited by it.
|
|
248
|
+
"""
|
|
249
|
+
return await self._fetch_graph_neighbors(work_id, field="related_works", limit=limit)
|
|
250
|
+
|
|
251
|
+
async def _fetch_work_at(self, path: str) -> Work | None:
|
|
252
|
+
payload = await self._get_json(path, allow_404=True)
|
|
253
|
+
return parse_work(payload) if payload is not None else None
|
|
254
|
+
|
|
255
|
+
async def _fetch_graph_neighbors(
|
|
256
|
+
self,
|
|
257
|
+
work_id: str,
|
|
258
|
+
*,
|
|
259
|
+
field: str,
|
|
260
|
+
limit: int,
|
|
261
|
+
) -> Sequence[Work]:
|
|
262
|
+
path = _work_path(work_id)
|
|
263
|
+
if path is None:
|
|
264
|
+
return ()
|
|
265
|
+
parent = await self._get_json(path, allow_404=True)
|
|
266
|
+
if parent is None:
|
|
267
|
+
return ()
|
|
268
|
+
neighbor_urls = parent.get(field)
|
|
269
|
+
if not isinstance(neighbor_urls, list):
|
|
270
|
+
return ()
|
|
271
|
+
ids = [oa_id for url in neighbor_urls if (oa_id := _strip_openalex_id_url(url))]
|
|
272
|
+
return await self._resolve_works(ids, limit=limit)
|
|
273
|
+
|
|
274
|
+
async def _resolve_works(self, ids: Sequence[str], *, limit: int) -> Sequence[Work]:
|
|
275
|
+
results: list[Work] = []
|
|
276
|
+
pending = list(ids)
|
|
277
|
+
# Fetch in waves sized to the shortfall so a 404 or dead neighbor gets
|
|
278
|
+
# replaced by the next id in order, without fetching past the limit
|
|
279
|
+
# when everything resolves.
|
|
280
|
+
while pending and len(results) < limit:
|
|
281
|
+
wave, pending = pending[: limit - len(results)], pending[limit - len(results) :]
|
|
282
|
+
fetched = await asyncio.gather(*(self._fetch_neighbor(ref_id) for ref_id in wave))
|
|
283
|
+
results.extend(work for work in fetched if work is not None)
|
|
284
|
+
return tuple(results)
|
|
285
|
+
|
|
286
|
+
async def _fetch_neighbor(self, ref_id: str) -> Work | None:
|
|
287
|
+
try:
|
|
288
|
+
return await self._fetch_work_at(f"/works/{quote(ref_id, safe='')}")
|
|
289
|
+
except (OalexUnavailable, OalexRequestError) as exc:
|
|
290
|
+
_log.warning("oalex: neighbor fetch failed for %s: %s", ref_id, exc)
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
@overload
|
|
294
|
+
async def _get_json(
|
|
295
|
+
self, path: str, params: dict[str, str] | None = ..., *, allow_404: Literal[False] = ...
|
|
296
|
+
) -> dict[str, Any]: ...
|
|
297
|
+
|
|
298
|
+
@overload
|
|
299
|
+
async def _get_json(
|
|
300
|
+
self, path: str, params: dict[str, str] | None = ..., *, allow_404: Literal[True]
|
|
301
|
+
) -> dict[str, Any] | None: ...
|
|
302
|
+
|
|
303
|
+
async def _get_json(
|
|
304
|
+
self,
|
|
305
|
+
path: str,
|
|
306
|
+
params: dict[str, str] | None = None,
|
|
307
|
+
*,
|
|
308
|
+
allow_404: bool = False,
|
|
309
|
+
) -> dict[str, Any] | None:
|
|
310
|
+
merged: dict[str, str] = {"mailto": self._email} if self._email else {}
|
|
311
|
+
if params:
|
|
312
|
+
merged.update(params)
|
|
313
|
+
cache_key = path + "?" + urlencode(sorted(merged.items()))
|
|
314
|
+
cached = self._cache.get(cache_key)
|
|
315
|
+
if cached is not None:
|
|
316
|
+
return _decode(cached)
|
|
317
|
+
|
|
318
|
+
async def do_request() -> httpx.Response:
|
|
319
|
+
# Inside the retried callable so retries also wait their turn.
|
|
320
|
+
await self._rate.acquire()
|
|
321
|
+
# OpenAlex answers merged-away ids with a 301 to the survivor.
|
|
322
|
+
return await self._client.get(
|
|
323
|
+
_API_BASE + path,
|
|
324
|
+
params=merged,
|
|
325
|
+
headers=self._headers,
|
|
326
|
+
follow_redirects=True,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
response = await with_backoff(do_request)
|
|
331
|
+
except httpx.HTTPError as exc:
|
|
332
|
+
raise OalexUnavailable(f"request to {path} failed: {exc!r}") from exc
|
|
333
|
+
status = response.status_code
|
|
334
|
+
if allow_404 and status == 404:
|
|
335
|
+
return None
|
|
336
|
+
if status == 429:
|
|
337
|
+
retry_after = _parse_retry_after(response.headers.get("retry-after"))
|
|
338
|
+
raise OalexRateLimited(
|
|
339
|
+
f"OpenAlex rate limit hit on {path}: {_error_detail(response)}",
|
|
340
|
+
retry_after=retry_after,
|
|
341
|
+
)
|
|
342
|
+
if status >= 500:
|
|
343
|
+
raise OalexUnavailable(f"OpenAlex returned {status} for {path}")
|
|
344
|
+
if status >= 400:
|
|
345
|
+
raise OalexRequestError(
|
|
346
|
+
f"OpenAlex returned {status} for {path}: {_error_detail(response)}",
|
|
347
|
+
status_code=status,
|
|
348
|
+
)
|
|
349
|
+
payload = _decode(response.content)
|
|
350
|
+
# Cache after decoding so an HTML error page served with a 200 by some
|
|
351
|
+
# proxy doesn't get pinned for a whole TTL.
|
|
352
|
+
self._cache.set(cache_key, response.content)
|
|
353
|
+
return payload
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _search_params(
|
|
357
|
+
query: str, *, per_page: int, year_min: int | None, year_max: int | None
|
|
358
|
+
) -> dict[str, str]:
|
|
359
|
+
params = {
|
|
360
|
+
"search": query,
|
|
361
|
+
"per-page": str(min(max(per_page, 1), _MAX_PER_PAGE)),
|
|
362
|
+
}
|
|
363
|
+
if year_min is not None or year_max is not None:
|
|
364
|
+
# Open-ended ranges ("2018-") are valid OpenAlex filter syntax.
|
|
365
|
+
lo = str(year_min) if year_min is not None else ""
|
|
366
|
+
hi = str(year_max) if year_max is not None else ""
|
|
367
|
+
params["filter"] = f"publication_year:{lo}-{hi}"
|
|
368
|
+
return params
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _work_path(work_id: str) -> str | None:
|
|
372
|
+
bare = _strip_openalex_id_url(work_id)
|
|
373
|
+
if bare is not None:
|
|
374
|
+
return f"/works/{quote(bare, safe='')}"
|
|
375
|
+
prefix, sep, raw = work_id.partition(":")
|
|
376
|
+
if not sep:
|
|
377
|
+
return f"/works/{quote(work_id, safe='')}"
|
|
378
|
+
if prefix == "openalex":
|
|
379
|
+
return f"/works/{quote(raw, safe='')}"
|
|
380
|
+
if prefix == "doi":
|
|
381
|
+
return _doi_path(raw)
|
|
382
|
+
return None
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _doi_path(doi: str) -> str:
|
|
386
|
+
# DOIs may contain '#', '?' and ';' (old SICI-style Wiley DOIs do);
|
|
387
|
+
# unescaped, httpx would read those as a fragment or query string.
|
|
388
|
+
return f"/works/doi:{quote(doi, safe='/:')}"
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _decode(body: bytes) -> dict[str, Any]:
|
|
392
|
+
try:
|
|
393
|
+
payload = json.loads(body)
|
|
394
|
+
except json.JSONDecodeError as exc:
|
|
395
|
+
raise OalexUnavailable("response was not JSON") from exc
|
|
396
|
+
if not isinstance(payload, dict):
|
|
397
|
+
raise OalexUnavailable(f"expected a JSON object, got {type(payload).__name__}")
|
|
398
|
+
return payload
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _parse_results(payload: dict[str, Any]) -> list[Work]:
|
|
402
|
+
raw_results = payload.get("results")
|
|
403
|
+
if not isinstance(raw_results, list):
|
|
404
|
+
return []
|
|
405
|
+
return [work for raw in raw_results if isinstance(raw, dict) and (work := parse_work(raw))]
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _error_detail(response: httpx.Response) -> str:
|
|
409
|
+
try:
|
|
410
|
+
body = response.json()
|
|
411
|
+
except ValueError:
|
|
412
|
+
return response.text[:200]
|
|
413
|
+
if isinstance(body, dict):
|
|
414
|
+
return str(body.get("message") or body.get("error") or body)[:200]
|
|
415
|
+
return str(body)[:200]
|
oalex/errors.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Errors raised by the oalex client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OalexError(Exception):
|
|
7
|
+
"""Base for all oalex-defined exceptions."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OalexUnavailable(OalexError):
|
|
11
|
+
"""Raised when a transient failure prevents resolving a request.
|
|
12
|
+
|
|
13
|
+
Network errors, HTTP 5xx after exhausted retries, and malformed JSON
|
|
14
|
+
responses surface here. Distinct from "not found": ``fetch_work``
|
|
15
|
+
returns ``None`` for legitimate 404s rather than raising, so callers
|
|
16
|
+
can tell "this id doesn't exist in OpenAlex" apart from "OpenAlex is
|
|
17
|
+
having a bad day."
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, reason: str) -> None:
|
|
21
|
+
super().__init__(reason)
|
|
22
|
+
self.reason = reason
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OalexRateLimited(OalexUnavailable):
|
|
26
|
+
"""OpenAlex answered 429 and retrying soon won't help.
|
|
27
|
+
|
|
28
|
+
Since February 2026 OpenAlex meters usage against a daily credit
|
|
29
|
+
budget that resets at midnight UTC. Keyless callers get a small one,
|
|
30
|
+
so a busy script can run it dry mid-day. ``retry_after`` is the
|
|
31
|
+
server's Retry-After hint in seconds when it sent one.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, reason: str, *, retry_after: float | None = None) -> None:
|
|
35
|
+
super().__init__(reason)
|
|
36
|
+
self.retry_after = retry_after
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class OalexRequestError(OalexError):
|
|
40
|
+
"""OpenAlex rejected the request itself (a 4xx other than 404 and 429).
|
|
41
|
+
|
|
42
|
+
A bad filter, a malformed id, or an invalid API key lands here.
|
|
43
|
+
Retrying the same request will fail the same way.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, reason: str, *, status_code: int) -> None:
|
|
47
|
+
super().__init__(reason)
|
|
48
|
+
self.reason = reason
|
|
49
|
+
self.status_code = status_code
|
oalex/py.typed
ADDED
|
File without changes
|
oalex/types.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Typed wrappers for OpenAlex Work and Author payloads.
|
|
2
|
+
|
|
3
|
+
OpenAlex's Work JSON is large (50+ fields) and changes shape over time;
|
|
4
|
+
``Work`` exposes the commonly-used fields as typed attributes while
|
|
5
|
+
preserving the raw payload at ``.raw`` for anything the typed surface
|
|
6
|
+
doesn't cover. This keeps the package's surface small and lets callers
|
|
7
|
+
reach into the full response without subclassing.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import date
|
|
15
|
+
from types import MappingProxyType
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class Author:
|
|
21
|
+
"""An author on an OpenAlex Work.
|
|
22
|
+
|
|
23
|
+
``orcid`` is the full ORCID URL when OpenAlex has one. Many records
|
|
24
|
+
don't, in which case it's ``None``.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
orcid: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class Work:
|
|
33
|
+
"""A scholarly work parsed from OpenAlex's ``/works`` endpoint.
|
|
34
|
+
|
|
35
|
+
``id`` is the bare OpenAlex ID (``W2626778328``), not the full URL
|
|
36
|
+
that OpenAlex returns. Use ``.url`` for the canonical URL form.
|
|
37
|
+
|
|
38
|
+
``raw`` holds the full deserialized JSON payload for fields not
|
|
39
|
+
surfaced as typed attributes (topics, keywords, locations,
|
|
40
|
+
counts_by_year, etc.).
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
id: str
|
|
44
|
+
title: str
|
|
45
|
+
abstract: str
|
|
46
|
+
authors: tuple[Author, ...]
|
|
47
|
+
published: date | None = None
|
|
48
|
+
url: str | None = None
|
|
49
|
+
doi: str | None = None
|
|
50
|
+
venue: str | None = None
|
|
51
|
+
pdf_url: str | None = None
|
|
52
|
+
citation_count: int | None = None
|
|
53
|
+
referenced_works: tuple[str, ...] = ()
|
|
54
|
+
"""Bare OpenAlex IDs of works this work cites. Empty when OpenAlex
|
|
55
|
+
has no citation graph for this record (older items, non-citable
|
|
56
|
+
types) or when the field was stripped by a ``select=`` parameter."""
|
|
57
|
+
related_works: tuple[str, ...] = ()
|
|
58
|
+
"""Bare OpenAlex IDs of works OpenAlex considers similar. Computed
|
|
59
|
+
from topic-vector overlap, not a citation relationship."""
|
|
60
|
+
field_name: str | None = None
|
|
61
|
+
"""``primary_topic.field.display_name`` when present: a coarse
|
|
62
|
+
discipline label ("Computer Science", "Medicine", "Mathematics")."""
|
|
63
|
+
raw: Mapping[str, Any] = field(default_factory=lambda: MappingProxyType({}))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_work(raw: Mapping[str, Any] | None) -> Work | None:
|
|
67
|
+
"""Parse an OpenAlex Work JSON payload into a :class:`Work`.
|
|
68
|
+
|
|
69
|
+
Returns ``None`` when the payload is empty or lacks a parseable
|
|
70
|
+
``id``. OpenAlex sometimes returns placeholder records (merged
|
|
71
|
+
duplicates, withdrawn entries) that we don't want to surface to
|
|
72
|
+
callers.
|
|
73
|
+
"""
|
|
74
|
+
if not raw:
|
|
75
|
+
return None
|
|
76
|
+
openalex_id = _strip_openalex_id_url(raw.get("id"))
|
|
77
|
+
if not openalex_id:
|
|
78
|
+
return None
|
|
79
|
+
title = (raw.get("title") or raw.get("display_name") or "").strip()
|
|
80
|
+
abstract = _reconstruct_abstract(raw.get("abstract_inverted_index"))
|
|
81
|
+
published = _parse_publication_date(
|
|
82
|
+
raw.get("publication_date"), raw.get("publication_year")
|
|
83
|
+
)
|
|
84
|
+
doi = _strip_doi_url(raw.get("doi"))
|
|
85
|
+
venue = _extract_venue(raw)
|
|
86
|
+
pdf_url = _extract_pdf_url(raw)
|
|
87
|
+
authors = _extract_authors(raw)
|
|
88
|
+
raw_count = raw.get("cited_by_count")
|
|
89
|
+
citation_count = raw_count if isinstance(raw_count, int) else None
|
|
90
|
+
referenced = _extract_id_list(raw.get("referenced_works"))
|
|
91
|
+
related = _extract_id_list(raw.get("related_works"))
|
|
92
|
+
field_name = _extract_field_name(raw)
|
|
93
|
+
return Work(
|
|
94
|
+
id=openalex_id,
|
|
95
|
+
title=title,
|
|
96
|
+
abstract=abstract,
|
|
97
|
+
authors=authors,
|
|
98
|
+
published=published,
|
|
99
|
+
url=raw.get("id") if isinstance(raw.get("id"), str) else None,
|
|
100
|
+
doi=doi,
|
|
101
|
+
venue=venue,
|
|
102
|
+
pdf_url=pdf_url,
|
|
103
|
+
citation_count=citation_count,
|
|
104
|
+
referenced_works=referenced,
|
|
105
|
+
related_works=related,
|
|
106
|
+
field_name=field_name,
|
|
107
|
+
raw=MappingProxyType(dict(raw)),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _strip_openalex_id_url(value: Any) -> str | None:
|
|
112
|
+
"""``https://openalex.org/W123`` → ``W123``. Anything else → ``None``.
|
|
113
|
+
|
|
114
|
+
OpenAlex always returns its own ids as full URLs; this canonicalizes
|
|
115
|
+
on the bare form so callers don't have to slice URLs themselves.
|
|
116
|
+
"""
|
|
117
|
+
if not isinstance(value, str) or not value:
|
|
118
|
+
return None
|
|
119
|
+
marker = "openalex.org/"
|
|
120
|
+
if marker not in value:
|
|
121
|
+
return None
|
|
122
|
+
bare = value.split(marker, 1)[1]
|
|
123
|
+
return bare or None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _strip_doi_url(value: Any) -> str | None:
|
|
127
|
+
"""``https://doi.org/10.x/y`` → ``10.x/y``. Lower-cases per spec.
|
|
128
|
+
|
|
129
|
+
DOIs are case-insensitive per the spec; lower-casing here means
|
|
130
|
+
downstream dedup logic that hashes the DOI doesn't miss matches
|
|
131
|
+
when OpenAlex returns a mixed-case form.
|
|
132
|
+
"""
|
|
133
|
+
if not isinstance(value, str) or not value:
|
|
134
|
+
return None
|
|
135
|
+
stripped = value
|
|
136
|
+
for prefix in ("https://doi.org/", "http://doi.org/"):
|
|
137
|
+
if stripped.startswith(prefix):
|
|
138
|
+
stripped = stripped.removeprefix(prefix)
|
|
139
|
+
break
|
|
140
|
+
return stripped.lower() or None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _reconstruct_abstract(inverted: Any) -> str:
|
|
144
|
+
"""Flatten OpenAlex's ``{word: [positions]}`` index back to a string.
|
|
145
|
+
|
|
146
|
+
Each word can appear at multiple positions; we sort by position and
|
|
147
|
+
join with spaces. Quirky but preserves word order without storing
|
|
148
|
+
the abstract verbatim (OpenAlex's copyright workaround).
|
|
149
|
+
"""
|
|
150
|
+
if not inverted or not isinstance(inverted, dict):
|
|
151
|
+
return ""
|
|
152
|
+
positions: list[tuple[int, str]] = []
|
|
153
|
+
for word, posns in inverted.items():
|
|
154
|
+
if not isinstance(posns, list):
|
|
155
|
+
continue
|
|
156
|
+
for pos in posns:
|
|
157
|
+
if isinstance(pos, int):
|
|
158
|
+
positions.append((pos, str(word)))
|
|
159
|
+
positions.sort()
|
|
160
|
+
return " ".join(word for _, word in positions)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _parse_publication_date(date_str: Any, year: Any) -> date | None:
|
|
164
|
+
if isinstance(date_str, str) and date_str:
|
|
165
|
+
try:
|
|
166
|
+
return date.fromisoformat(date_str)
|
|
167
|
+
except ValueError:
|
|
168
|
+
pass
|
|
169
|
+
if isinstance(year, int):
|
|
170
|
+
try:
|
|
171
|
+
return date(year, 1, 1)
|
|
172
|
+
except ValueError:
|
|
173
|
+
return None
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _extract_venue(raw: Mapping[str, Any]) -> str | None:
|
|
178
|
+
primary = raw.get("primary_location") or {}
|
|
179
|
+
source = primary.get("source") if isinstance(primary, dict) else None
|
|
180
|
+
if isinstance(source, dict):
|
|
181
|
+
name = (source.get("display_name") or "").strip()
|
|
182
|
+
if name:
|
|
183
|
+
return name
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _extract_pdf_url(raw: Mapping[str, Any]) -> str | None:
|
|
188
|
+
"""Prefer ``best_oa_location.pdf_url``; fall back to ``open_access.oa_url``.
|
|
189
|
+
|
|
190
|
+
Both fields can be present; best_oa_location is a curated open-access
|
|
191
|
+
landing, oa_url is whatever URL OpenAlex has cached. Either is more
|
|
192
|
+
likely to actually serve a PDF than primary_location.pdf_url, which
|
|
193
|
+
is often null even when an OA copy exists elsewhere.
|
|
194
|
+
"""
|
|
195
|
+
best = raw.get("best_oa_location") or {}
|
|
196
|
+
if isinstance(best, dict):
|
|
197
|
+
url = best.get("pdf_url")
|
|
198
|
+
if isinstance(url, str) and url:
|
|
199
|
+
return url
|
|
200
|
+
oa = raw.get("open_access") or {}
|
|
201
|
+
if isinstance(oa, dict):
|
|
202
|
+
url = oa.get("oa_url")
|
|
203
|
+
if isinstance(url, str) and url:
|
|
204
|
+
return url
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _extract_authors(raw: Mapping[str, Any]) -> tuple[Author, ...]:
|
|
209
|
+
authors: list[Author] = []
|
|
210
|
+
for entry in raw.get("authorships") or []:
|
|
211
|
+
if not isinstance(entry, dict):
|
|
212
|
+
continue
|
|
213
|
+
author = entry.get("author")
|
|
214
|
+
if not isinstance(author, dict):
|
|
215
|
+
continue
|
|
216
|
+
name = (author.get("display_name") or "").strip()
|
|
217
|
+
if not name:
|
|
218
|
+
continue
|
|
219
|
+
orcid = author.get("orcid")
|
|
220
|
+
authors.append(
|
|
221
|
+
Author(name=name, orcid=orcid if isinstance(orcid, str) else None)
|
|
222
|
+
)
|
|
223
|
+
return tuple(authors)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _extract_id_list(value: Any) -> tuple[str, ...]:
|
|
227
|
+
"""Pull bare OpenAlex IDs out of a list of work URLs.
|
|
228
|
+
|
|
229
|
+
OpenAlex returns ``referenced_works`` / ``related_works`` as lists
|
|
230
|
+
of full work URLs (``https://openalex.org/W123``). This strips each
|
|
231
|
+
to its bare form and drops anything that doesn't parse.
|
|
232
|
+
"""
|
|
233
|
+
if not isinstance(value, list):
|
|
234
|
+
return ()
|
|
235
|
+
return tuple(
|
|
236
|
+
oa_id
|
|
237
|
+
for item in value
|
|
238
|
+
if (oa_id := _strip_openalex_id_url(item)) is not None
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _extract_field_name(raw: Mapping[str, Any]) -> str | None:
|
|
243
|
+
topic = raw.get("primary_topic")
|
|
244
|
+
if not isinstance(topic, dict):
|
|
245
|
+
return None
|
|
246
|
+
field_block = topic.get("field")
|
|
247
|
+
if not isinstance(field_block, dict):
|
|
248
|
+
return None
|
|
249
|
+
name = field_block.get("display_name")
|
|
250
|
+
if isinstance(name, str) and name.strip():
|
|
251
|
+
return name.strip()
|
|
252
|
+
return None
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: oalex
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Async Python client for the OpenAlex scholarly works API.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Burton-David/oalex
|
|
6
|
+
Project-URL: Bug Tracker, https://github.com/Burton-David/oalex/issues
|
|
7
|
+
Project-URL: OpenAlex API, https://help.openalex.org/api/
|
|
8
|
+
Author-email: David Burton <david@databurton.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: async,citations,openalex,research,scholarly
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: httpx>=0.27
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=7.4; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# oalex
|
|
35
|
+
|
|
36
|
+
[](https://github.com/Burton-David/oalex/actions/workflows/ci.yml)
|
|
37
|
+
[](https://www.python.org/)
|
|
38
|
+
[](LICENSE)
|
|
39
|
+
|
|
40
|
+
Async Python client for the [OpenAlex](https://openalex.org/) scholarly works API. Typed, rate-limited, disk-cached, and careful with your daily credit budget.
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import asyncio
|
|
44
|
+
from oalex import Client
|
|
45
|
+
|
|
46
|
+
async def main() -> None:
|
|
47
|
+
async with Client(api_key="your-openalex-key") as oa:
|
|
48
|
+
works = await oa.search("attention is all you need", per_page=5)
|
|
49
|
+
for w in works:
|
|
50
|
+
print(w.id, w.title, w.citation_count)
|
|
51
|
+
|
|
52
|
+
vaswani = await oa.fetch_work("W2626778328")
|
|
53
|
+
if vaswani is not None:
|
|
54
|
+
print(vaswani.title, "cites", len(vaswani.referenced_works), "works")
|
|
55
|
+
|
|
56
|
+
asyncio.run(main())
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Why this exists
|
|
60
|
+
|
|
61
|
+
The OpenAlex HTTP API is clean, but every new client ends up re-solving the same things: rebuilding abstracts from the inverted index, retrying 429s and 5xx without burning the daily budget, following redirects for merged records, and caching so repeat lookups cost nothing. `oalex` does those once.
|
|
62
|
+
|
|
63
|
+
## Features
|
|
64
|
+
|
|
65
|
+
- **Typed.** `Work` and `Author` are frozen dataclasses. `Work.raw` exposes the full payload for fields the typed surface doesn't cover.
|
|
66
|
+
- **Async.** `httpx` under the hood; `async with Client(...)` for clean teardown.
|
|
67
|
+
- **API-key aware.** Pass `api_key=` or set `OPENALEX_API_KEY`. The key travels as a Bearer header, so it never shows up in URLs, cache files, or exception messages.
|
|
68
|
+
- **Rate-limited.** One request per 0.1s by default, well under OpenAlex's 100 requests/second ceiling. Retries wait their turn too.
|
|
69
|
+
- **Disk-cached.** `~/.cache/oalex/` with a 24-hour TTL. Only valid JSON is cached.
|
|
70
|
+
- **Retried.** 1s/2s/4s backoff on 429 and 5xx, honoring `Retry-After` up to 30s. When a 429 says the daily budget is spent (`X-RateLimit-Remaining: 0`, or a `Retry-After` longer than 30s), the client raises `OalexRateLimited` at once instead of sleeping.
|
|
71
|
+
- **Cursor paging.** `iter_search` walks past the first page (and past the 10,000-result offset limit).
|
|
72
|
+
- **Citation graph.** `fetch_referenced` and `fetch_related` resolve OpenAlex's `referenced_works` / `related_works` arrays into full `Work` records.
|
|
73
|
+
|
|
74
|
+
## Install
|
|
75
|
+
|
|
76
|
+
Not on PyPI yet. Install from GitHub:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install git+https://github.com/Burton-David/oalex
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Python 3.10+. The only runtime dependency is [httpx](https://www.python-httpx.org/).
|
|
83
|
+
|
|
84
|
+
## OpenAlex credits
|
|
85
|
+
|
|
86
|
+
Since February 2026 OpenAlex meters usage against a daily budget that resets at midnight UTC. These are the numbers the live API reported in September 2026:
|
|
87
|
+
|
|
88
|
+
| Call | oalex method | Credits |
|
|
89
|
+
|------|--------------|---------|
|
|
90
|
+
| Single work by id or DOI | `fetch_work`, `fetch_doi`, each neighbor in `fetch_referenced` / `fetch_related` | 0 |
|
|
91
|
+
| Search page | `search`, each page of `iter_search` | 10 |
|
|
92
|
+
|
|
93
|
+
A keyless client gets 1,000 credits per day, which is about 100 searches. A [free API key](https://openalex.org/settings/api) raises the budget tenfold. OpenAlex has changed these numbers before; its [pricing page](https://help.openalex.org/access/pricing/) is the source of truth. Cache hits cost nothing.
|
|
94
|
+
|
|
95
|
+
## Usage
|
|
96
|
+
|
|
97
|
+
### Search
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
works = await client.search(
|
|
101
|
+
"graph neural networks",
|
|
102
|
+
per_page=25,
|
|
103
|
+
year_min=2020,
|
|
104
|
+
year_max=2024,
|
|
105
|
+
)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`search` returns one page. `per_page` is clamped to 1..200. Either year bound can be left off for an open-ended range.
|
|
109
|
+
|
|
110
|
+
For more than one page, iterate:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
async for work in client.iter_search("graph neural networks", max_results=1000):
|
|
114
|
+
print(work.id, work.title)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Each page is a metered call, so set `max_results` on broad queries.
|
|
118
|
+
|
|
119
|
+
### Fetch a single work
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
# By OpenAlex ID: three equivalent forms
|
|
123
|
+
work = await client.fetch_work("W2626778328")
|
|
124
|
+
work = await client.fetch_work("openalex:W2626778328")
|
|
125
|
+
work = await client.fetch_work("https://openalex.org/W2626778328")
|
|
126
|
+
|
|
127
|
+
# By DOI
|
|
128
|
+
work = await client.fetch_doi("10.1038/nature12373")
|
|
129
|
+
work = await client.fetch_doi("https://doi.org/10.1038/nature12373")
|
|
130
|
+
work = await client.fetch_work("doi:10.1038/nature12373")
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`fetch_work` returns `None` for a 404 and for an id prefix it doesn't recognize (`arxiv:`, `pmid:`). A merged-away id follows OpenAlex's redirect and returns the surviving record.
|
|
134
|
+
|
|
135
|
+
### Citation graph
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
# Papers this paper cites
|
|
139
|
+
refs = await client.fetch_referenced("W2626778328", limit=10)
|
|
140
|
+
|
|
141
|
+
# Papers OpenAlex considers similar (topic overlap, not citations)
|
|
142
|
+
related = await client.fetch_related("W2626778328", limit=10)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Neighbors are fetched concurrently, in array order. A neighbor that 404s or keeps failing is skipped, and the next id in the array takes its place. Errors fetching the parent propagate.
|
|
146
|
+
|
|
147
|
+
### Raw payload access
|
|
148
|
+
|
|
149
|
+
`Work.raw` is a read-only mapping of the full OpenAlex response. Reach into it for fields the typed surface doesn't expose:
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
topics = [t["display_name"] for t in work.raw.get("topics", [])]
|
|
153
|
+
source = (work.raw.get("primary_location") or {}).get("source") or {}
|
|
154
|
+
source_id = source.get("id")
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Configuration
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
Client(
|
|
161
|
+
api_key="...", # default: $OPENALEX_API_KEY, else keyless
|
|
162
|
+
cache_dir="/var/cache/oalex", # default: ~/.cache/oalex
|
|
163
|
+
ttl_seconds=24 * 60 * 60, # default: 24h
|
|
164
|
+
min_interval_seconds=0.1, # default: 0.1s between requests
|
|
165
|
+
timeout=30.0, # default: 30s per request
|
|
166
|
+
client=my_httpx_client, # optional: bring your own AsyncClient
|
|
167
|
+
)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
When you pass your own `httpx.AsyncClient`, `oalex` won't close it on exit, and `timeout` is ignored in favor of that client's settings.
|
|
171
|
+
|
|
172
|
+
`email=` is still accepted and sent as `mailto=`. OpenAlex has ignored it since retiring the polite pool, so new code can leave it off.
|
|
173
|
+
|
|
174
|
+
## Errors
|
|
175
|
+
|
|
176
|
+
- `OalexError` is the base class for everything below.
|
|
177
|
+
- `OalexUnavailable` covers transient failures: network errors, 5xx after retries, and a body that isn't a JSON object. Retrying later is reasonable.
|
|
178
|
+
- `OalexRateLimited` is a subclass of `OalexUnavailable` for 429s. `retry_after` holds the server's `Retry-After` hint in seconds when it sent one. A spent daily budget refills at midnight UTC.
|
|
179
|
+
- `OalexRequestError` covers any other 4xx except 404: a bad filter or an invalid API key, for example. `status_code` holds the HTTP status. Retrying the same request won't help.
|
|
180
|
+
|
|
181
|
+
A 404 is not an error: `fetch_work` and `fetch_doi` return `None`, so "OpenAlex doesn't know this id" stays distinct from "OpenAlex is having a bad day."
|
|
182
|
+
|
|
183
|
+
## Limits
|
|
184
|
+
|
|
185
|
+
- Only the `/works` endpoint is wrapped. Authors, sources, and institutions are reachable only through `Work.raw`.
|
|
186
|
+
- The rate limiter is per process. Several processes sharing one API key also share one daily budget, and nothing here coordinates them.
|
|
187
|
+
- The cache stores responses as individual files and never prunes them. Clear `~/.cache/oalex/` yourself if it grows.
|
|
188
|
+
|
|
189
|
+
## Development
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
git clone https://github.com/Burton-David/oalex
|
|
193
|
+
cd oalex
|
|
194
|
+
python -m venv .venv && source .venv/bin/activate
|
|
195
|
+
pip install -e ".[dev]"
|
|
196
|
+
ruff check oalex tests
|
|
197
|
+
mypy oalex
|
|
198
|
+
pytest
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Tests use `httpx.MockTransport` and never touch the live API.
|
|
202
|
+
|
|
203
|
+
## Credits
|
|
204
|
+
|
|
205
|
+
Extracted from [research-mcp](https://github.com/Burton-David/ResearchAssistantMCP)'s OpenAlex source adapter, where the retry policy and disk-cache design were settled first.
|
|
206
|
+
|
|
207
|
+
## License
|
|
208
|
+
|
|
209
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
oalex/__init__.py,sha256=suOw72DWBPaSp7kdknWm152lLJHVOSHFe1zu-5pXPUk,812
|
|
2
|
+
oalex/_backoff.py,sha256=KmyhuMZ4CcK_vIUdxXvk_Y3XjMG0sxp5Jz-RO1oKmG0,3916
|
|
3
|
+
oalex/_cache.py,sha256=BDiIctptYSYx8zY3TptHcz2er2Qky4JlrrA2mCjcF0U,2173
|
|
4
|
+
oalex/_rate_limit.py,sha256=Sw8-i1TPt6pwQRGFJqNXre5DCA1osL2Ra2Szd3s-mm8,1430
|
|
5
|
+
oalex/client.py,sha256=MxRTeLVDnYEGzQs6EhxApwAHKjk4NM9EqzaKKCJ2q0c,16607
|
|
6
|
+
oalex/errors.py,sha256=LjYwUZ4lHM5wmAJY7gJ4WbgwUKY1gewU-8XfBmLkZ1g,1643
|
|
7
|
+
oalex/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
oalex/types.py,sha256=Ziqtp_zycpUILJubh48BJxle13dC_dU2wH0S_V3Uz18,8644
|
|
9
|
+
oalex-0.2.0.dist-info/METADATA,sha256=-EJJ6YeisdPET1ywYfDC0187MhfCfTr0OZbYEH06L04,8953
|
|
10
|
+
oalex-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
+
oalex-0.2.0.dist-info/licenses/LICENSE,sha256=a7XhdgxAUdy67A_UYSNRSXTA2i936Gwf0QrLm6_L8qk,1069
|
|
12
|
+
oalex-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Burton
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|