agent_search_gateway.providers.academic.common
Small boundary helpers shared by academic provider adapters.
1"""Small boundary helpers shared by academic provider adapters.""" 2 3from __future__ import annotations 4 5import logging 6from collections.abc import Mapping 7from datetime import date 8from typing import Any, Protocol 9 10from ...errors import ErrorCode, ProtocolFailure 11from ...observability import log_event 12 13_LOGGER = logging.getLogger(__name__) 14 15 16class AcademicHttpExecutor(Protocol): 17 async def request_json( 18 self, 19 method: str, 20 url: str, 21 *, 22 stage: str, 23 headers: Mapping[str, str] | None = None, 24 params: Mapping[str, Any] | None = None, 25 json_body: object | None = None, 26 ) -> object: ... 27 28 async def request_text( 29 self, 30 method: str, 31 url: str, 32 *, 33 stage: str, 34 headers: Mapping[str, str] | None = None, 35 params: Mapping[str, Any] | None = None, 36 json_body: object | None = None, 37 ) -> str: ... 38 39 40def protocol_failure( 41 provider: str, 42 detail: str, 43 *, 44 stage: str = "paper_search", 45 reason: str | None = None, 46) -> ProtocolFailure: 47 return ProtocolFailure( 48 ErrorCode.PROTOCOL_ERROR, 49 f"{provider}/{stage}: {detail}", 50 reason=reason, 51 ) 52 53 54def reject_item(provider: str, reason: str = "invalid_record_shape") -> None: 55 log_event( 56 _LOGGER, 57 logging.DEBUG, 58 "paper_candidate_rejected", 59 provider=provider, 60 reason=reason, 61 ) 62 63 64def as_mapping(value: object) -> Mapping[str, object] | None: 65 return value if isinstance(value, dict) else None 66 67 68def as_list(value: object) -> list[object] | None: 69 return value if isinstance(value, list) else None 70 71 72def text(value: object) -> str: 73 return value.strip() if isinstance(value, str) else "" 74 75 76def string_tuple(value: object) -> tuple[str, ...]: 77 if not isinstance(value, list | tuple): 78 return () 79 return tuple(item.strip() for item in value if isinstance(item, str) and item.strip()) 80 81 82def parse_iso_date(value: object) -> date | None: 83 if not isinstance(value, str) or len(value) < 10: 84 return None 85 try: 86 return date.fromisoformat(value[:10]) 87 except ValueError: 88 return None 89 90 91def nonnegative_int(value: object) -> int | None: 92 if isinstance(value, bool) or not isinstance(value, int) or value < 0: 93 return None 94 return value 95 96 97def join_url(base: str, path: str) -> str: 98 return f"{base.rstrip('/')}/{path.lstrip('/')}"
class
AcademicHttpExecutor(typing.Protocol):
17class AcademicHttpExecutor(Protocol): 18 async def request_json( 19 self, 20 method: str, 21 url: str, 22 *, 23 stage: str, 24 headers: Mapping[str, str] | None = None, 25 params: Mapping[str, Any] | None = None, 26 json_body: object | None = None, 27 ) -> object: ... 28 29 async def request_text( 30 self, 31 method: str, 32 url: str, 33 *, 34 stage: str, 35 headers: Mapping[str, str] | None = None, 36 params: Mapping[str, Any] | None = None, 37 json_body: object | None = None, 38 ) -> str: ...
Base class for protocol classes.
Protocol classes are defined as::
class Proto(Protocol):
def meth(self) -> int:
...
Such classes are primarily used with static type checkers that recognize structural subtyping (static duck-typing).
For example::
class C:
def meth(self) -> int:
return 0
def func(x: Proto) -> int:
return x.meth()
func(C()) # Passes static type check
See PEP 544 for details. Protocol classes decorated with @typing.runtime_checkable act as simple-minded runtime protocols that check only the presence of given attributes, ignoring their type signatures. Protocol classes can be generic, they are defined as::
class GenProto[T](Protocol):
def meth(self) -> T:
...
AcademicHttpExecutor(*args, **kwargs)
1739def _no_init_or_replace_init(self, *args, **kwargs): 1740 cls = type(self) 1741 1742 if cls._is_protocol: 1743 raise TypeError('Protocols cannot be instantiated') 1744 1745 # Already using a custom `__init__`. No need to calculate correct 1746 # `__init__` to call. This can lead to RecursionError. See bpo-45121. 1747 if cls.__init__ is not _no_init_or_replace_init: 1748 return 1749 1750 # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`. 1751 # The first instantiation of the subclass will call `_no_init_or_replace_init` which 1752 # searches for a proper new `__init__` in the MRO. The new `__init__` 1753 # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent 1754 # instantiation of the protocol subclass will thus use the new 1755 # `__init__` and no longer call `_no_init_or_replace_init`. 1756 for base in cls.__mro__: 1757 init = base.__dict__.get('__init__', _no_init_or_replace_init) 1758 if init is not _no_init_or_replace_init: 1759 cls.__init__ = init 1760 break 1761 else: 1762 # should not happen 1763 cls.__init__ = object.__init__ 1764 1765 cls.__init__(self, *args, **kwargs)
async def
request_json( self, method: str, url: str, *, stage: str, headers: Mapping[str, str] | None = None, params: Mapping[str, typing.Any] | None = None, json_body: object | None = None) -> object:
def
protocol_failure( provider: str, detail: str, *, stage: str = 'paper_search', reason: str | None = None) -> agent_search_gateway.errors.ProtocolFailure:
def
reject_item(provider: str, reason: str = 'invalid_record_shape') -> None:
def
as_mapping(value: object) -> Mapping[str, object] | None:
def
as_list(value: object) -> list[object] | None:
def
text(value: object) -> str:
def
string_tuple(value: object) -> tuple[str, ...]:
def
parse_iso_date(value: object) -> datetime.date | None:
def
nonnegative_int(value: object) -> int | None:
def
join_url(base: str, path: str) -> str: