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 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
+ [![CI](https://github.com/Burton-David/oalex/actions/workflows/ci.yml/badge.svg)](https://github.com/Burton-David/oalex/actions/workflows/ci.yml)
37
+ [![Python 3.10–3.14](https://img.shields.io/badge/python-3.10%20to%203.14-blue)](https://www.python.org/)
38
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.