Coverage for src/local_deep_research/utilities/arxiv_api.py: 90%
125 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""Shared arXiv API transport policy (issue #4783).
3Sole owner of the arXiv API request policy: every legacy arXiv API query
4request must traverse one process-wide, monotonic, at least three-second
5single-flight gate and travel through a :class:`SafeSession` with a pinned
6timeout and redirects disabled.
7"""
9from __future__ import annotations
11import threading
12import time
13from collections.abc import Callable, Iterable, Sequence
14from contextlib import contextmanager
15from dataclasses import dataclass
16from datetime import datetime
17from enum import StrEnum
18from typing import (
19 TYPE_CHECKING,
20 Final,
21 Protocol,
22 TypeAlias,
23 assert_never,
24 override,
25)
27import arxiv
28import requests
29from requests import PreparedRequest
31from ..security import SafeSession
32from .resource_utils import safe_close
34if TYPE_CHECKING:
35 from collections.abc import Generator
37 from requests import Response
39# arXiv API Terms of Use: one connection at a time, with at least three
40# seconds between request starts
41# (https://info.arxiv.org/help/api/tou.html). This gate is intentionally
42# process-local: it serializes request starts within a single LDR process
43# only and provides no machine-wide enforcement. Deployments running
44# multiple LDR processes must coordinate those processes externally.
45_ARXIV_API_REQUEST_INTERVAL_SECONDS: Final = 3.0
46_arxiv_api_request_lock = threading.Lock()
47_last_request_started_at: float | None = None
48_monotonic = time.monotonic
49_sleep = time.sleep
51_ARXIV_API_TIMEOUT_SECONDS: Final = 10
54class ArxivSortCriterion(StrEnum):
55 RELEVANCE = "relevance"
56 LAST_UPDATED_DATE = "lastUpdatedDate"
57 SUBMITTED_DATE = "submittedDate"
60class ArxivSortOrder(StrEnum):
61 ASCENDING = "ascending"
62 DESCENDING = "descending"
65@dataclass(frozen=True, slots=True)
66class ArxivQueryRequest:
67 query: str
68 max_results: int
69 sort_by: ArxivSortCriterion = ArxivSortCriterion.RELEVANCE
70 sort_order: ArxivSortOrder = ArxivSortOrder.DESCENDING
73@dataclass(frozen=True, slots=True)
74class ArxivIdRequest:
75 arxiv_id: str
78type ArxivRequest = ArxivQueryRequest | ArxivIdRequest
81class ArxivAuthor(Protocol):
82 name: str
85class ArxivPaper(Protocol):
86 entry_id: str
87 title: str
88 summary: str
89 published: datetime
90 updated: datetime
91 categories: list[str]
92 comment: str | None
93 doi: str | None
94 journal_ref: str | None
96 @property
97 def authors(self) -> Sequence[ArxivAuthor]: ... 97 ↛ anywhereline 97 didn't jump anywhere: it always raised an exception.
99 @property
100 def pdf_url(self) -> str | None: ... 100 ↛ exitline 100 didn't return from function 'pdf_url' because
103_SORT_CRITERIA: Final = {
104 ArxivSortCriterion.RELEVANCE: arxiv.SortCriterion.Relevance,
105 ArxivSortCriterion.LAST_UPDATED_DATE: arxiv.SortCriterion.LastUpdatedDate,
106 ArxivSortCriterion.SUBMITTED_DATE: arxiv.SortCriterion.SubmittedDate,
107}
108_SORT_ORDERS: Final = {
109 ArxivSortOrder.ASCENDING: arxiv.SortOrder.Ascending,
110 ArxivSortOrder.DESCENDING: arxiv.SortOrder.Descending,
111}
114class _Readable(Protocol):
115 def read(self, length: int = ..., /) -> str | bytes: ... 115 ↛ exitline 115 didn't return from function 'read' because
118class _ItemSource(Protocol):
119 def items(self) -> Iterable[tuple[_GetArgument, _GetArgument]]: ... 119 ↛ exitline 119 didn't return from function 'items' because
122_GetArgument: TypeAlias = (
123 None
124 | bool
125 | int
126 | float
127 | str
128 | bytes
129 | PreparedRequest
130 | Iterable["_GetArgument"]
131 | _Readable
132 | _ItemSource
133 | Callable[..., "_GetArgument"]
134)
137@contextmanager
138def arxiv_api_request_gate() -> Generator[None, None, None]:
139 """NOT REENTRANT -- never enter this from code that may already
140 be running under the gate. The lock is a plain threading.Lock held
141 across both the sleep and the request body, so a nested entry
142 deadlocks silently rather than raising. _GatedArxivSession.get()
143 enters it, so anything handed that session as its transport must
144 not wrap its own call in the gate.
146 Run one arXiv API request start under the process-wide policy gate.
148 Entering blocks until at least ``_ARXIV_API_REQUEST_INTERVAL_SECONDS``
149 monotonic seconds have passed since the previous start, then stamps the
150 new start while holding the single-flight lock across the whole body.
151 Failed starts still count for spacing, and the lock is always released.
152 """
153 global _last_request_started_at
154 with _arxiv_api_request_lock:
155 while _last_request_started_at is not None:
156 remaining = (
157 _last_request_started_at
158 + _ARXIV_API_REQUEST_INTERVAL_SECONDS
159 - _monotonic()
160 )
161 if remaining <= 0:
162 break
163 _sleep(remaining)
164 _last_request_started_at = _monotonic()
165 yield
168class _GatedArxivSession(SafeSession):
169 """SafeSession routing arXiv API wire calls through the shared gate."""
171 @override
172 def get(
173 self,
174 url: str | bytes,
175 params: _GetArgument = None,
176 **kwargs: _GetArgument,
177 ) -> Response:
178 request_kwargs: dict[str, _GetArgument] = {
179 **kwargs,
180 "timeout": _ARXIV_API_TIMEOUT_SECONDS,
181 "allow_redirects": False,
182 }
183 match url:
184 case str(): 184 ↛ 186line 184 didn't jump to line 186 because the pattern on line 184 always matched
185 request_url = url
186 case bytes():
187 request_url = url.decode()
188 case unreachable:
189 assert_never(unreachable)
190 with arxiv_api_request_gate():
191 return super().request(
192 "GET", request_url, params=params, **request_kwargs
193 )
196@contextmanager
197def _policy_arxiv_client(
198 *, page_size: int | None = None
199) -> Generator[arxiv.Client, None, None]:
200 """Yield an ``arxiv.Client`` adapted to the shared arXiv API policy.
202 The client is constructed through the ``arxiv.Client`` module attribute
203 with ``delay_seconds=0`` because the shared gate owns spacing.
204 :func:`_install_gated_session` then closes the discarded default
205 session and pins a gated :class:`_GatedArxivSession` onto the
206 client, raising ``ImportError`` if the client's private ``_session``
207 attribute is missing or not a ``requests.Session``. The adapted
208 session is closed on exit for both normal and exceptional scopes.
209 """
210 constructor_kwargs: dict[str, int] = {"delay_seconds": 0}
211 if page_size is not None:
212 constructor_kwargs["page_size"] = page_size
213 client = arxiv.Client(**constructor_kwargs)
214 adapted_session = _install_gated_session(client)
215 try:
216 yield client
217 finally:
218 safe_close(adapted_session, "adapted arXiv client session")
221def _install_gated_session(client: arxiv.Client) -> _GatedArxivSession:
222 """Install the gate-enforcing session on ``client`` or fail loudly.
224 ``arxiv.Client`` keeps its HTTP transport on the private ``_session``
225 attribute (a ``requests.Session`` in arxiv 2.4.0, 2.4.1 and 4.0.1;
226 pyproject.toml does not pin the version for this guard's sake, since
227 the guard fails closed). A future release renaming or retyping that
228 attribute would leave the client's default session in place and
229 silently bypass the shared rate-limit gate, so the mutation is
230 guarded: anything other than a ``requests.Session`` at ``_session``
231 raises ``ImportError`` with maintainer guidance instead of shipping
232 an ungated client. The discarded default session is closed, a
233 :class:`_GatedArxivSession` is installed in its place, and it is
234 returned so the caller can close it on scope exit.
235 """
236 discarded_session = getattr(client, "_session", None)
237 if not isinstance(discarded_session, requests.Session):
238 raise ImportError(
239 "arxiv.Client no longer exposes a requests.Session at "
240 "_session, so the shared arXiv rate-limit gate cannot be "
241 "installed. The installed arxiv release changed the client "
242 "internals; adapt utilities/arxiv_api.py to them, or constrain "
243 "arxiv to a release that keeps a requests.Session at _session "
244 "(2.4.0, 2.4.1 and 4.0.1 do).",
245 )
246 safe_close(discarded_session, "discarded arXiv client session")
247 adapted_session = _GatedArxivSession()
248 client._session = adapted_session
249 return adapted_session
252def _strip_optional(text: str | None) -> str | None:
253 return None if text is None else text.strip()
256def _strip_feed_text(paper: arxiv.Result) -> arxiv.Result:
257 """Strip surrounding whitespace from the text fields LDR reads.
259 arxiv 2.x parsed the Atom feed with feedparser, which strips the
260 leading and trailing whitespace of each of these fields. arxiv 4.x
261 parses it with its own lxml parser and keeps the raw element text,
262 and the arXiv API returns some of it with surrounding whitespace
263 (some abstracts start with two spaces). The id, title, abstract,
264 comment, journal reference, DOI and author names are stripped in
265 place with ``str.strip()``, as feedparser stripped them, so snippets,
266 content and author lists carry none of the whitespace 2.x removed.
267 The SDK already collapses each whitespace run in the title to one
268 space, which leaves at most one space at each end to strip.
269 """
270 paper.entry_id = paper.entry_id.strip()
271 paper.title = paper.title.strip()
272 paper.summary = paper.summary.strip()
273 paper.comment = _strip_optional(paper.comment)
274 paper.journal_ref = _strip_optional(paper.journal_ref)
275 paper.doi = _strip_optional(paper.doi)
276 for author in paper.authors:
277 author.name = author.name.strip()
278 return paper
281def fetch_arxiv_results(request: ArxivRequest) -> list[ArxivPaper]:
282 """Fetch and materialize one typed arXiv request under shared policy.
284 The text fields LDR reads from each result are stripped of
285 surrounding whitespace (``_strip_feed_text``).
286 """
287 match request:
288 case ArxivQueryRequest():
289 search = arxiv.Search(
290 query=request.query,
291 max_results=request.max_results,
292 sort_by=_SORT_CRITERIA[request.sort_by],
293 sort_order=_SORT_ORDERS[request.sort_order],
294 )
295 with _policy_arxiv_client(page_size=request.max_results) as client:
296 return list[ArxivPaper](
297 map(_strip_feed_text, client.results(search))
298 )
299 case ArxivIdRequest(): 299 ↛ 305line 299 didn't jump to line 305 because the pattern on line 299 always matched
300 search = arxiv.Search(id_list=[request.arxiv_id], max_results=1)
301 with _policy_arxiv_client() as client:
302 return list[ArxivPaper](
303 map(_strip_feed_text, client.results(search))
304 )
305 case unreachable:
306 assert_never(unreachable)