Edit on GitHub

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:
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: ...
async def request_text( 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) -> str:
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: ...
def protocol_failure( provider: str, detail: str, *, stage: str = 'paper_search', reason: str | None = None) -> agent_search_gateway.errors.ProtocolFailure:
41def protocol_failure(
42    provider: str,
43    detail: str,
44    *,
45    stage: str = "paper_search",
46    reason: str | None = None,
47) -> ProtocolFailure:
48    return ProtocolFailure(
49        ErrorCode.PROTOCOL_ERROR,
50        f"{provider}/{stage}: {detail}",
51        reason=reason,
52    )
def reject_item(provider: str, reason: str = 'invalid_record_shape') -> None:
55def reject_item(provider: str, reason: str = "invalid_record_shape") -> None:
56    log_event(
57        _LOGGER,
58        logging.DEBUG,
59        "paper_candidate_rejected",
60        provider=provider,
61        reason=reason,
62    )
def as_mapping(value: object) -> Mapping[str, object] | None:
65def as_mapping(value: object) -> Mapping[str, object] | None:
66    return value if isinstance(value, dict) else None
def as_list(value: object) -> list[object] | None:
69def as_list(value: object) -> list[object] | None:
70    return value if isinstance(value, list) else None
def text(value: object) -> str:
73def text(value: object) -> str:
74    return value.strip() if isinstance(value, str) else ""
def string_tuple(value: object) -> tuple[str, ...]:
77def string_tuple(value: object) -> tuple[str, ...]:
78    if not isinstance(value, list | tuple):
79        return ()
80    return tuple(item.strip() for item in value if isinstance(item, str) and item.strip())
def parse_iso_date(value: object) -> datetime.date | None:
83def parse_iso_date(value: object) -> date | None:
84    if not isinstance(value, str) or len(value) < 10:
85        return None
86    try:
87        return date.fromisoformat(value[:10])
88    except ValueError:
89        return None
def nonnegative_int(value: object) -> int | None:
92def nonnegative_int(value: object) -> int | None:
93    if isinstance(value, bool) or not isinstance(value, int) or value < 0:
94        return None
95    return value
def join_url(base: str, path: str) -> str:
98def join_url(base: str, path: str) -> str:
99    return f"{base.rstrip('/')}/{path.lstrip('/')}"