Source code for tooluniverse.crossref_tool

import requests
import urllib.parse
from typing import Any, Dict, Optional
from .base_tool import BaseTool
from .base_rest_tool import BaseRESTTool
from .tool_registry import register_tool


[docs] def _requested_offset(response: requests.Response) -> int: """The `offset` Crossref was actually asked for, or 0. Read off the URL requests assembled for the request: `_process_response` is handed the endpoint template only (see BaseRESTTool.run), so the query params are not otherwise reachable from here. A missing or unparseable offset means the first page. The isinstance check is load-bearing, not defensive typing: tests stub the response with a MagicMock, whose `url` attribute is a truthy Mock rather than a string, and urlparse raises TypeError on it. """ url = getattr(response, "url", None) if not isinstance(url, str): return 0 try: query = urllib.parse.urlparse(url).query return int(urllib.parse.parse_qs(query).get("offset", ["0"])[0]) except ValueError: return 0
# Crossref refuses offset + rows beyond this, answering HTTP 400 ("offset must # be a positive integer less than or equal to 9998 ... Use the cursor parameter # to page further"). Measured live: offset=9000 succeeds, offset=9999 does not. # The truncation note must not advise a page that cannot be fetched. _MAX_REACHABLE = 10000
[docs] def _truncation_fields(offset: int, shown: int, total: int) -> Dict[str, Any]: """The repo's disclosure vocabulary for "you are seeing part of a set". Named and module-level so the sentence has one home, and so `truncated` is always emitted alongside it -- a caller must never have to infer partiality by string-matching on prose. """ end = offset + shown truncated = bool(shown) and end < total fields: Dict[str, Any] = {"truncated": truncated} if truncated: note = ( f"Showing results {offset + 1}-{end} of {total} Crossref matches. " f"Re-run with offset={end} for the next page, or raise 'limit' " "(max 100)." ) if total > _MAX_REACHABLE: note += ( f" Note that Crossref caps offset+limit at {_MAX_REACHABLE}, so " f"only the first {_MAX_REACHABLE} of these {total} matches are " "reachable by paging -- narrow the search with 'filter' to get " "at the rest." ) fields["truncation_note"] = note return fields
[docs] @register_tool("CrossrefRESTTool") class CrossrefRESTTool(BaseRESTTool): """Generic REST tool for Crossref API endpoints."""
[docs] def _get_param_mapping(self) -> Dict[str, str]: """Map Crossref-specific parameter names.""" return { "limit": "rows", # limit -> rows # query uses its original name }
[docs] def _process_response( self, response: requests.Response, url: str ) -> Dict[str, Any]: """Process Crossref API response, extracting message wrapper.""" data = response.json() # Crossref wraps responses in a "message" field if isinstance(data, dict) and "message" in data: message = data["message"] # For list endpoints, extract items from message if isinstance(message, dict) and "items" in message: items = message.get("items", []) result = { "status": "success", "data": items, "count": len(items), "url": url, } # Fix-R60-1: `count` is the size of the returned page and was # the only number in the response, so with the documented # `limit` defaults (10 for works, 20 for funders/members) a # search of a million-work corpus came back looking like a # ten-work corpus. Crossref publishes the match total on # every list endpoint; it was simply being dropped. See # tests/unit/test_crossref_total_not_page_size.py for the # live figures. Purely additive: `count`, `data` and `url` # keep the values they always had. total = message.get("total-results") # bool is an int subclass; a JSON `true` here is not a total. if isinstance(total, int) and not isinstance(total, bool): result["total_count"] = total result.update( _truncation_fields( _requested_offset(response), len(items), total ) ) return result else: # For detail endpoints, return the message directly return { "status": "success", "data": message, "url": url, } # Fallback if no message wrapper return { "status": "success", "data": data, "url": url, }