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 run(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
paginated = self.tool_config.get("name") in {
"Crossref_search_works",
"Crossref_list_funders",
"Crossref_search_members",
}
if not paginated:
return super().run(arguments)
arguments = dict(arguments or {})
limit = arguments.get("limit", 10)
offset = arguments.get("offset")
cursor = arguments.get("cursor", "*")
if (
isinstance(limit, bool)
or not isinstance(limit, int)
or not 1 <= limit <= 100
):
return {
"status": "error",
"error": "limit must be an integer between 1 and 100",
"parameter": "limit",
}
if offset is not None and (
isinstance(offset, bool) or not isinstance(offset, int) or offset < 0
):
return {
"status": "error",
"error": "offset must be a non-negative integer",
"parameter": "offset",
}
if not isinstance(cursor, str) or not cursor:
return {
"status": "error",
"error": "cursor must be '*' or a non-empty cursor returned by Crossref",
"parameter": "cursor",
}
# Crossref pages either by offset or by cursor, not both.
if offset is not None and cursor != "*":
return {
"status": "error",
"error": "Pass either offset or cursor, not both",
"parameter": "cursor",
}
arguments["limit"] = limit
arguments["cursor"] = None if offset is not None else cursor
result = super().run(arguments)
if result.get("status") != "success":
return result
metadata = result.setdefault("metadata", {})
total = metadata.get("total_results")
returned = result.get("count", 0)
next_cursor = metadata.get("upstream_next_cursor")
first_page = cursor == "*" and not offset
if offset is not None and isinstance(total, int):
has_more = offset + returned < total
elif cursor == "*" and isinstance(total, int):
has_more = returned < total
else:
has_more = returned == limit and bool(next_cursor)
metadata.update(
{
"returned": returned,
"limit": limit,
"total_results_is_exact": isinstance(total, int),
"has_more": has_more,
"next_cursor": next_cursor if has_more and offset is None else None,
"result_set_complete": first_page
and isinstance(total, int)
and returned == total,
}
)
if offset is not None:
metadata["offset"] = offset
metadata["next_offset"] = offset + returned if has_more else None
metadata.pop("upstream_next_cursor", None)
return result
[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
)
)
next_cursor = message.get("next-cursor")
if total is not None or next_cursor is not None:
result["metadata"] = {
"total_results": total
if isinstance(total, int) and not isinstance(total, bool)
else None,
"upstream_next_cursor": next_cursor,
}
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,
}