xippios-core 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xippios/core/__init__.py +80 -0
- xippios/core/_loop.py +113 -0
- xippios/core/_version.py +10 -0
- xippios/core/client/__init__.py +119 -0
- xippios/core/client/_core.py +188 -0
- xippios/core/client/auth.py +147 -0
- xippios/core/client/cache.py +110 -0
- xippios/core/client/config.py +135 -0
- xippios/core/client/ratelimit.py +55 -0
- xippios/core/client/retry.py +83 -0
- xippios/core/config.py +84 -0
- xippios/core/errors.py +201 -0
- xippios/core/http/__init__.py +40 -0
- xippios/core/http/problems.py +88 -0
- xippios/core/http/router.py +144 -0
- xippios/core/mcp/__init__.py +35 -0
- xippios/core/mcp/cli.py +33 -0
- xippios/core/mcp/errors.py +13 -0
- xippios/core/mcp/results.py +89 -0
- xippios/core/mcp/server.py +116 -0
- xippios/core/models.py +207 -0
- xippios/core/pagination.py +112 -0
- xippios/core/py.typed +0 -0
- xippios/core/reference.py +130 -0
- xippios/core/testing/__init__.py +53 -0
- xippios/core/testing/adapters.py +46 -0
- xippios/core/testing/fixtures.py +113 -0
- xippios/core/testing/plugin.py +57 -0
- xippios/core/testing/record.py +41 -0
- xippios/core/testing/replay.py +32 -0
- xippios/core/testing/scrub.py +49 -0
- xippios_core-0.1.0.dist-info/METADATA +230 -0
- xippios_core-0.1.0.dist-info/RECORD +35 -0
- xippios_core-0.1.0.dist-info/WHEEL +4 -0
- xippios_core-0.1.0.dist-info/licenses/LICENSE +201 -0
xippios/core/__init__.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""xippios-core: the shared foundation of the xippios Python SDK family.
|
|
2
|
+
|
|
3
|
+
Only names re-exported here are public API. The optional adapters live in their own
|
|
4
|
+
subpackages and are imported explicitly: ``xippios.core.mcp``, ``xippios.core.http``,
|
|
5
|
+
``xippios.core.testing``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from xippios.core._loop import sync
|
|
9
|
+
from xippios.core._version import __version__
|
|
10
|
+
from xippios.core.client import (
|
|
11
|
+
AsyncClient,
|
|
12
|
+
BasicKeyAuth,
|
|
13
|
+
BearerAuth,
|
|
14
|
+
CacheBackend,
|
|
15
|
+
Client,
|
|
16
|
+
ClientConfig,
|
|
17
|
+
EnvKeyAuth,
|
|
18
|
+
HeaderKeyAuth,
|
|
19
|
+
MemoryCache,
|
|
20
|
+
QueryKeyAuth,
|
|
21
|
+
RateLimit,
|
|
22
|
+
RetryPolicy,
|
|
23
|
+
Timeouts,
|
|
24
|
+
)
|
|
25
|
+
from xippios.core.config import SourceSettings, resolve_api_key
|
|
26
|
+
from xippios.core.errors import (
|
|
27
|
+
AuthError,
|
|
28
|
+
ConfigError,
|
|
29
|
+
InvalidRequestError,
|
|
30
|
+
NotFoundError,
|
|
31
|
+
RateLimitError,
|
|
32
|
+
TransportError,
|
|
33
|
+
UpstreamError,
|
|
34
|
+
ValidationError,
|
|
35
|
+
XippiosError,
|
|
36
|
+
)
|
|
37
|
+
from xippios.core.models import Result, ResultMeta, UpstreamModel, parse, parse_decimal
|
|
38
|
+
from xippios.core.pagination import collect, paginate_cursor, paginate_offset, paginate_pages
|
|
39
|
+
from xippios.core.reference import load_csv, load_json, load_table
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"AsyncClient",
|
|
43
|
+
"AuthError",
|
|
44
|
+
"BasicKeyAuth",
|
|
45
|
+
"BearerAuth",
|
|
46
|
+
"CacheBackend",
|
|
47
|
+
"Client",
|
|
48
|
+
"ClientConfig",
|
|
49
|
+
"ConfigError",
|
|
50
|
+
"EnvKeyAuth",
|
|
51
|
+
"HeaderKeyAuth",
|
|
52
|
+
"InvalidRequestError",
|
|
53
|
+
"MemoryCache",
|
|
54
|
+
"NotFoundError",
|
|
55
|
+
"QueryKeyAuth",
|
|
56
|
+
"RateLimit",
|
|
57
|
+
"RateLimitError",
|
|
58
|
+
"Result",
|
|
59
|
+
"ResultMeta",
|
|
60
|
+
"RetryPolicy",
|
|
61
|
+
"SourceSettings",
|
|
62
|
+
"Timeouts",
|
|
63
|
+
"TransportError",
|
|
64
|
+
"UpstreamError",
|
|
65
|
+
"UpstreamModel",
|
|
66
|
+
"ValidationError",
|
|
67
|
+
"XippiosError",
|
|
68
|
+
"__version__",
|
|
69
|
+
"collect",
|
|
70
|
+
"load_csv",
|
|
71
|
+
"load_json",
|
|
72
|
+
"load_table",
|
|
73
|
+
"paginate_cursor",
|
|
74
|
+
"paginate_offset",
|
|
75
|
+
"paginate_pages",
|
|
76
|
+
"parse",
|
|
77
|
+
"parse_decimal",
|
|
78
|
+
"resolve_api_key",
|
|
79
|
+
"sync",
|
|
80
|
+
]
|
xippios/core/_loop.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""A background event loop so synchronous callers can run the async core (ADR-0002).
|
|
2
|
+
|
|
3
|
+
One daemon thread owns one event loop for the whole process. Sync facades submit
|
|
4
|
+
coroutines to it and block until they finish, which works from plain scripts, from
|
|
5
|
+
Jupyter, and from inside another running loop. Nothing here is public API except
|
|
6
|
+
through ``xippios.core.sync``.
|
|
7
|
+
|
|
8
|
+
Not fork-safe: a process forked after the loop thread started (``multiprocessing`` with
|
|
9
|
+
the ``fork`` start method) inherits a loop whose thread does not exist. Use the
|
|
10
|
+
``spawn`` start method, or fork before the first sync call.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import functools
|
|
15
|
+
import inspect
|
|
16
|
+
import threading
|
|
17
|
+
from collections.abc import Awaitable, Callable, Coroutine
|
|
18
|
+
from typing import Any, Protocol, cast
|
|
19
|
+
|
|
20
|
+
__all__ = ["SyncFunction", "as_async", "run", "sync"]
|
|
21
|
+
|
|
22
|
+
_LOOP_THREAD_NAME = "xippios-core-loop"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SyncFunction[**P, T](Protocol):
|
|
26
|
+
"""A synchronous function derived by ``sync`` from an async one."""
|
|
27
|
+
|
|
28
|
+
__name__: str
|
|
29
|
+
__qualname__: str
|
|
30
|
+
__doc__: str | None
|
|
31
|
+
__wrapped__: Callable[P, Coroutine[Any, Any, T]]
|
|
32
|
+
async_fn: Callable[P, Coroutine[Any, Any, T]]
|
|
33
|
+
|
|
34
|
+
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T: ...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _Runner:
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
40
|
+
self._thread: threading.Thread | None = None
|
|
41
|
+
self._lock = threading.Lock()
|
|
42
|
+
|
|
43
|
+
def loop(self) -> asyncio.AbstractEventLoop:
|
|
44
|
+
with self._lock:
|
|
45
|
+
if self._loop is None or self._loop.is_closed():
|
|
46
|
+
loop = asyncio.new_event_loop()
|
|
47
|
+
thread = threading.Thread(
|
|
48
|
+
target=loop.run_forever, name=_LOOP_THREAD_NAME, daemon=True
|
|
49
|
+
)
|
|
50
|
+
thread.start()
|
|
51
|
+
self._loop, self._thread = loop, thread
|
|
52
|
+
return self._loop
|
|
53
|
+
|
|
54
|
+
def run[T](self, coro: Coroutine[Any, Any, T]) -> T:
|
|
55
|
+
loop = self.loop()
|
|
56
|
+
if threading.current_thread() is self._thread:
|
|
57
|
+
coro.close()
|
|
58
|
+
raise RuntimeError(
|
|
59
|
+
"a sync facade was called from inside the xippios event loop; "
|
|
60
|
+
"await the async function instead"
|
|
61
|
+
)
|
|
62
|
+
return asyncio.run_coroutine_threadsafe(coro, loop).result()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_runner = _Runner()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def run[T](coro: Coroutine[Any, Any, T]) -> T:
|
|
69
|
+
"""Run ``coro`` to completion on the background loop and return its result."""
|
|
70
|
+
return _runner.run(coro)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def sync[**P, T](fn: Callable[P, Coroutine[Any, Any, T]]) -> SyncFunction[P, T]:
|
|
74
|
+
"""Derive a synchronous function from an async one.
|
|
75
|
+
|
|
76
|
+
The result has the same signature and docstring, runs the coroutine on the
|
|
77
|
+
background loop, and exposes the original as ``.async_fn`` so adapters can
|
|
78
|
+
await it natively instead of blocking their own loop. A trailing ``_async`` is
|
|
79
|
+
dropped from the name, so ``get_series = sync(get_series_async)`` is named
|
|
80
|
+
``get_series`` wherever the name is shown, such as in MCP tool names. The loop
|
|
81
|
+
is not fork-safe; see the module docstring.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
fn: An ``async def`` function.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
@functools.wraps(fn)
|
|
88
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
89
|
+
return run(fn(*args, **kwargs))
|
|
90
|
+
|
|
91
|
+
wrapper.__name__ = fn.__name__.removesuffix("_async")
|
|
92
|
+
wrapper.__qualname__ = fn.__qualname__.removesuffix("_async")
|
|
93
|
+
wrapper.async_fn = fn # type: ignore[attr-defined]
|
|
94
|
+
return cast(SyncFunction[P, T], wrapper)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def as_async(fn: Callable[..., Any]) -> Callable[..., Awaitable[Any]]:
|
|
98
|
+
"""Return an awaitable form of ``fn`` for use inside an adapter's event loop.
|
|
99
|
+
|
|
100
|
+
Prefers the ``async_fn`` twin set by ``sync``; returns ``fn`` itself when it is
|
|
101
|
+
already a coroutine function; otherwise wraps a plain function in a worker thread.
|
|
102
|
+
"""
|
|
103
|
+
twin = getattr(fn, "async_fn", None)
|
|
104
|
+
if twin is not None and inspect.iscoroutinefunction(twin):
|
|
105
|
+
return cast(Callable[..., Awaitable[Any]], twin)
|
|
106
|
+
if inspect.iscoroutinefunction(fn):
|
|
107
|
+
return cast(Callable[..., Awaitable[Any]], fn)
|
|
108
|
+
|
|
109
|
+
@functools.wraps(fn)
|
|
110
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
111
|
+
return await asyncio.to_thread(fn, *args, **kwargs)
|
|
112
|
+
|
|
113
|
+
return wrapper
|
xippios/core/_version.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Package version, read from the installed distribution metadata."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("xippios-core")
|
|
7
|
+
except PackageNotFoundError: # pragma: no cover - only in an unbuilt checkout
|
|
8
|
+
__version__ = "0.0.0"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""HTTP client for upstream APIs (conventions §6): httpx, async core, sync facade."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from types import TracebackType
|
|
5
|
+
from typing import Any, Self
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from xippios.core._loop import run
|
|
10
|
+
from xippios.core.client._core import AsyncClient, Params
|
|
11
|
+
from xippios.core.client.auth import (
|
|
12
|
+
BasicKeyAuth,
|
|
13
|
+
BearerAuth,
|
|
14
|
+
EnvKeyAuth,
|
|
15
|
+
HeaderKeyAuth,
|
|
16
|
+
QueryKeyAuth,
|
|
17
|
+
)
|
|
18
|
+
from xippios.core.client.cache import CacheBackend, CachedResponse, MemoryCache, cache_key
|
|
19
|
+
from xippios.core.client.config import ClientConfig, RateLimit, RetryPolicy, Timeouts
|
|
20
|
+
from xippios.core.client.ratelimit import TokenBucket
|
|
21
|
+
from xippios.core.client.retry import send_with_retries
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"AsyncClient",
|
|
25
|
+
"BasicKeyAuth",
|
|
26
|
+
"BearerAuth",
|
|
27
|
+
"CacheBackend",
|
|
28
|
+
"CachedResponse",
|
|
29
|
+
"Client",
|
|
30
|
+
"ClientConfig",
|
|
31
|
+
"EnvKeyAuth",
|
|
32
|
+
"HeaderKeyAuth",
|
|
33
|
+
"MemoryCache",
|
|
34
|
+
"Params",
|
|
35
|
+
"QueryKeyAuth",
|
|
36
|
+
"RateLimit",
|
|
37
|
+
"RetryPolicy",
|
|
38
|
+
"Timeouts",
|
|
39
|
+
"TokenBucket",
|
|
40
|
+
"cache_key",
|
|
41
|
+
"send_with_retries",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Client:
|
|
46
|
+
"""Synchronous facade over ``AsyncClient`` (ADR-0002).
|
|
47
|
+
|
|
48
|
+
Every call runs on the family's background event loop, so it works from scripts,
|
|
49
|
+
notebooks and inside other event loops. ``async_client`` exposes the underlying
|
|
50
|
+
instance for code that wants to await natively.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
config: Base URL, timeouts, retry policy, rate limit, cache and headers.
|
|
54
|
+
auth: An ``httpx.Auth`` adding credentials.
|
|
55
|
+
transport: Replacement transport, for tests.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
config: ClientConfig,
|
|
61
|
+
*,
|
|
62
|
+
auth: httpx.Auth | None = None,
|
|
63
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
self.async_client = AsyncClient(config, auth=auth, transport=transport)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def config(self) -> ClientConfig:
|
|
69
|
+
"""The configuration in use."""
|
|
70
|
+
return self.async_client.config
|
|
71
|
+
|
|
72
|
+
def request(
|
|
73
|
+
self,
|
|
74
|
+
method: str,
|
|
75
|
+
path: str,
|
|
76
|
+
*,
|
|
77
|
+
params: Params = None,
|
|
78
|
+
json: Any = None,
|
|
79
|
+
headers: Mapping[str, str] | None = None,
|
|
80
|
+
raise_on_error: bool = True,
|
|
81
|
+
) -> httpx.Response:
|
|
82
|
+
"""See ``AsyncClient.request``."""
|
|
83
|
+
return run(
|
|
84
|
+
self.async_client.request(
|
|
85
|
+
method,
|
|
86
|
+
path,
|
|
87
|
+
params=params,
|
|
88
|
+
json=json,
|
|
89
|
+
headers=headers,
|
|
90
|
+
raise_on_error=raise_on_error,
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def get(
|
|
95
|
+
self, path: str, *, params: Params = None, headers: Mapping[str, str] | None = None
|
|
96
|
+
) -> httpx.Response:
|
|
97
|
+
"""See ``AsyncClient.get``."""
|
|
98
|
+
return run(self.async_client.get(path, params=params, headers=headers))
|
|
99
|
+
|
|
100
|
+
def get_json(
|
|
101
|
+
self, path: str, *, params: Params = None, headers: Mapping[str, str] | None = None
|
|
102
|
+
) -> Any:
|
|
103
|
+
"""See ``AsyncClient.get_json``."""
|
|
104
|
+
return run(self.async_client.get_json(path, params=params, headers=headers))
|
|
105
|
+
|
|
106
|
+
def close(self) -> None:
|
|
107
|
+
"""Close the background loop's connection pool."""
|
|
108
|
+
run(self.async_client.aclose())
|
|
109
|
+
|
|
110
|
+
def __enter__(self) -> Self:
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
def __exit__(
|
|
114
|
+
self,
|
|
115
|
+
exc_type: type[BaseException] | None,
|
|
116
|
+
exc: BaseException | None,
|
|
117
|
+
tb: TracebackType | None,
|
|
118
|
+
) -> None:
|
|
119
|
+
self.close()
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""The async client: one request path with retries, rate limiting, caching and errors."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
import weakref
|
|
7
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import Any, Self
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from xippios.core.client.cache import CachedResponse, cache_key
|
|
14
|
+
from xippios.core.client.config import ClientConfig
|
|
15
|
+
from xippios.core.client.ratelimit import TokenBucket
|
|
16
|
+
from xippios.core.client.retry import send_with_retries
|
|
17
|
+
from xippios.core.errors import BODY_LIMIT, ValidationError, raise_for_status
|
|
18
|
+
|
|
19
|
+
__all__ = ["AsyncClient", "Params"]
|
|
20
|
+
|
|
21
|
+
Params = Mapping[str, Any] | None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AsyncClient:
|
|
25
|
+
"""Async HTTP client for one upstream (conventions §6).
|
|
26
|
+
|
|
27
|
+
One instance can be shared by sync facades and async servers alike: it keeps one
|
|
28
|
+
``httpx`` connection pool per event loop, while the rate limiter and the cache are
|
|
29
|
+
shared across all of them.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
config: Base URL, timeouts, retry policy, rate limit, cache and headers.
|
|
33
|
+
auth: An ``httpx.Auth`` adding credentials, e.g. a query key or basic auth.
|
|
34
|
+
transport: Replacement transport, for tests.
|
|
35
|
+
sleep: Async sleep used by retries and the rate limiter, replaceable in tests.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
config: ClientConfig,
|
|
41
|
+
*,
|
|
42
|
+
auth: httpx.Auth | None = None,
|
|
43
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
44
|
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
45
|
+
) -> None:
|
|
46
|
+
self.config = config
|
|
47
|
+
self._auth = auth
|
|
48
|
+
self._transport = transport
|
|
49
|
+
self._sleep = sleep
|
|
50
|
+
self._bucket = TokenBucket(config.rate_limit) if config.rate_limit is not None else None
|
|
51
|
+
self._pools: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, httpx.AsyncClient] = (
|
|
52
|
+
weakref.WeakKeyDictionary()
|
|
53
|
+
)
|
|
54
|
+
self._lock = threading.Lock()
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def source(self) -> str:
|
|
58
|
+
"""Source slug from the configuration."""
|
|
59
|
+
return self.config.source
|
|
60
|
+
|
|
61
|
+
def _pool(self) -> httpx.AsyncClient:
|
|
62
|
+
loop = asyncio.get_running_loop()
|
|
63
|
+
with self._lock:
|
|
64
|
+
pool = self._pools.get(loop)
|
|
65
|
+
if pool is None:
|
|
66
|
+
pool = httpx.AsyncClient(
|
|
67
|
+
base_url=self.config.base_url,
|
|
68
|
+
headers={"User-Agent": self.config.user_agent, **self.config.headers},
|
|
69
|
+
timeout=self.config.timeouts.to_httpx(),
|
|
70
|
+
auth=self._auth,
|
|
71
|
+
transport=self._transport,
|
|
72
|
+
)
|
|
73
|
+
self._pools[loop] = pool
|
|
74
|
+
return pool
|
|
75
|
+
|
|
76
|
+
def _cache_key(self, method: str, url: httpx.URL) -> str | None:
|
|
77
|
+
if method != "GET" or self.config.cache is None:
|
|
78
|
+
return None
|
|
79
|
+
return cache_key(method, url)
|
|
80
|
+
|
|
81
|
+
async def request(
|
|
82
|
+
self,
|
|
83
|
+
method: str,
|
|
84
|
+
path: str,
|
|
85
|
+
*,
|
|
86
|
+
params: Params = None,
|
|
87
|
+
json: Any = None,
|
|
88
|
+
headers: Mapping[str, str] | None = None,
|
|
89
|
+
raise_on_error: bool = True,
|
|
90
|
+
) -> httpx.Response:
|
|
91
|
+
"""Send one request and return the response.
|
|
92
|
+
|
|
93
|
+
Retries, rate limiting and caching apply as configured. The source's
|
|
94
|
+
``classify`` hook, if any, sees the response first; otherwise a 4xx/5xx that
|
|
95
|
+
survives the retry policy is raised as the matching error from the taxonomy; a
|
|
96
|
+
transport failure as ``TransportError``.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
method: HTTP method.
|
|
100
|
+
path: Path joined onto the base URL, e.g. ``"/api/api_GET"``.
|
|
101
|
+
params: Query parameters.
|
|
102
|
+
json: JSON body for write methods.
|
|
103
|
+
headers: Extra headers for this request only.
|
|
104
|
+
raise_on_error: ``False`` returns 4xx/5xx responses instead of raising, for
|
|
105
|
+
callers that record fixtures or inspect error bodies.
|
|
106
|
+
"""
|
|
107
|
+
pool = self._pool()
|
|
108
|
+
method = method.upper()
|
|
109
|
+
probe = pool.build_request(method, path, params=params)
|
|
110
|
+
key = self._cache_key(method, probe.url)
|
|
111
|
+
if key is not None and self.config.cache is not None:
|
|
112
|
+
cached = self.config.cache.get(key)
|
|
113
|
+
if cached is not None:
|
|
114
|
+
return _from_cache(cached, probe)
|
|
115
|
+
|
|
116
|
+
async def send() -> httpx.Response:
|
|
117
|
+
if self._bucket is not None:
|
|
118
|
+
await self._bucket.acquire(self._sleep)
|
|
119
|
+
request = pool.build_request(method, path, params=params, json=json, headers=headers)
|
|
120
|
+
return await pool.send(request)
|
|
121
|
+
|
|
122
|
+
response = await send_with_retries(
|
|
123
|
+
send, policy=self.config.retry, method=method, source=self.source, sleep=self._sleep
|
|
124
|
+
)
|
|
125
|
+
if raise_on_error:
|
|
126
|
+
if self.config.classify is not None:
|
|
127
|
+
classified = self.config.classify(response)
|
|
128
|
+
if classified is not None:
|
|
129
|
+
raise classified
|
|
130
|
+
raise_for_status(response, source=self.source)
|
|
131
|
+
if key is not None and self.config.cache is not None and response.status_code == 200:
|
|
132
|
+
self.config.cache.set(
|
|
133
|
+
key,
|
|
134
|
+
CachedResponse(
|
|
135
|
+
status=response.status_code,
|
|
136
|
+
headers=tuple(response.headers.multi_items()),
|
|
137
|
+
content=response.content,
|
|
138
|
+
stored_at=time.time(),
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
return response
|
|
142
|
+
|
|
143
|
+
async def get(
|
|
144
|
+
self, path: str, *, params: Params = None, headers: Mapping[str, str] | None = None
|
|
145
|
+
) -> httpx.Response:
|
|
146
|
+
"""``request("GET", ...)``."""
|
|
147
|
+
return await self.request("GET", path, params=params, headers=headers)
|
|
148
|
+
|
|
149
|
+
async def get_json(
|
|
150
|
+
self, path: str, *, params: Params = None, headers: Mapping[str, str] | None = None
|
|
151
|
+
) -> Any:
|
|
152
|
+
"""GET and decode a JSON body, raising ``ValidationError`` when it is not JSON."""
|
|
153
|
+
response = await self.get(path, params=params, headers=headers)
|
|
154
|
+
try:
|
|
155
|
+
return response.json()
|
|
156
|
+
except ValueError as exc:
|
|
157
|
+
raise ValidationError(
|
|
158
|
+
f"{self.source}: response from {path} is not JSON",
|
|
159
|
+
raw=response.text[:BODY_LIMIT],
|
|
160
|
+
source=self.source,
|
|
161
|
+
) from exc
|
|
162
|
+
|
|
163
|
+
async def aclose(self) -> None:
|
|
164
|
+
"""Close the connection pool bound to the current event loop."""
|
|
165
|
+
loop = asyncio.get_running_loop()
|
|
166
|
+
with self._lock:
|
|
167
|
+
pool = self._pools.pop(loop, None)
|
|
168
|
+
if pool is not None:
|
|
169
|
+
await pool.aclose()
|
|
170
|
+
|
|
171
|
+
async def __aenter__(self) -> Self:
|
|
172
|
+
return self
|
|
173
|
+
|
|
174
|
+
async def __aexit__(
|
|
175
|
+
self,
|
|
176
|
+
exc_type: type[BaseException] | None,
|
|
177
|
+
exc: BaseException | None,
|
|
178
|
+
tb: TracebackType | None,
|
|
179
|
+
) -> None:
|
|
180
|
+
await self.aclose()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _from_cache(cached: CachedResponse, request: httpx.Request) -> httpx.Response:
|
|
184
|
+
response = httpx.Response(
|
|
185
|
+
cached.status, headers=list(cached.headers), content=cached.content, request=request
|
|
186
|
+
)
|
|
187
|
+
response.extensions["xippios_cache"] = "hit"
|
|
188
|
+
return response
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Authentication helpers that read the key from the environment on first use.
|
|
2
|
+
|
|
3
|
+
Every upstream sends its key differently: USDA NASS as a query parameter, UN Comtrade
|
|
4
|
+
in a header, CashCtrl as the HTTP basic user name. These ``httpx.Auth`` classes cover
|
|
5
|
+
those shapes and resolve the key lazily through ``resolve_api_key``, so a package
|
|
6
|
+
imports without credentials and fails with ``ConfigError`` on the first request
|
|
7
|
+
instead of at import time.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import threading
|
|
12
|
+
from collections.abc import Generator
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from pydantic import SecretStr
|
|
16
|
+
|
|
17
|
+
from xippios.core.config import resolve_api_key
|
|
18
|
+
|
|
19
|
+
__all__ = ["BasicKeyAuth", "BearerAuth", "EnvKeyAuth", "HeaderKeyAuth", "QueryKeyAuth"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class EnvKeyAuth(httpx.Auth):
|
|
23
|
+
"""Base class: resolve the key once, then let a subclass attach it to each request.
|
|
24
|
+
|
|
25
|
+
Subclass and implement ``apply`` for a shape not covered below.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
env_var: Variable that holds the key, named as the upstream documents it.
|
|
29
|
+
key: Explicit key; wins over the environment (conventions §7).
|
|
30
|
+
source: Source slug for the ``ConfigError`` message.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
requires_request_body = False
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self, env_var: str, *, key: str | SecretStr | None = None, source: str | None = None
|
|
37
|
+
) -> None:
|
|
38
|
+
self.env_var = env_var
|
|
39
|
+
self.source = source
|
|
40
|
+
self._override = key
|
|
41
|
+
self._key: str | None = None
|
|
42
|
+
self._lock = threading.Lock()
|
|
43
|
+
|
|
44
|
+
def key(self) -> str:
|
|
45
|
+
"""The key, resolved on first use and cached for the life of the object."""
|
|
46
|
+
if self._key is None:
|
|
47
|
+
with self._lock:
|
|
48
|
+
if self._key is None:
|
|
49
|
+
secret = resolve_api_key(self.env_var, self._override, source=self.source)
|
|
50
|
+
self._key = secret.get_secret_value()
|
|
51
|
+
return self._key
|
|
52
|
+
|
|
53
|
+
def apply(self, request: httpx.Request, key: str) -> None:
|
|
54
|
+
"""Attach ``key`` to ``request``. Implemented by subclasses."""
|
|
55
|
+
raise NotImplementedError
|
|
56
|
+
|
|
57
|
+
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
|
|
58
|
+
"""The httpx hook: one request, the key attached."""
|
|
59
|
+
self.apply(request, self.key())
|
|
60
|
+
yield request
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class QueryKeyAuth(EnvKeyAuth):
|
|
64
|
+
"""Send the key as a query parameter, e.g. ``?key=...`` for USDA NASS.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
param: Query parameter name.
|
|
68
|
+
env_var: Variable that holds the key.
|
|
69
|
+
key: Explicit key; wins over the environment.
|
|
70
|
+
source: Source slug for error messages.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
param: str,
|
|
76
|
+
env_var: str,
|
|
77
|
+
*,
|
|
78
|
+
key: str | SecretStr | None = None,
|
|
79
|
+
source: str | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
super().__init__(env_var, key=key, source=source)
|
|
82
|
+
self.param = param
|
|
83
|
+
|
|
84
|
+
def apply(self, request: httpx.Request, key: str) -> None:
|
|
85
|
+
"""Merge the key into the query string."""
|
|
86
|
+
request.url = request.url.copy_merge_params({self.param: key})
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class HeaderKeyAuth(EnvKeyAuth):
|
|
90
|
+
"""Send the key in a header, e.g. ``Ocp-Apim-Subscription-Key`` for UN Comtrade.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
header: Header name.
|
|
94
|
+
env_var: Variable that holds the key.
|
|
95
|
+
key: Explicit key; wins over the environment.
|
|
96
|
+
source: Source slug for error messages.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
header: str,
|
|
102
|
+
env_var: str,
|
|
103
|
+
*,
|
|
104
|
+
key: str | SecretStr | None = None,
|
|
105
|
+
source: str | None = None,
|
|
106
|
+
) -> None:
|
|
107
|
+
super().__init__(env_var, key=key, source=source)
|
|
108
|
+
self.header = header
|
|
109
|
+
|
|
110
|
+
def apply(self, request: httpx.Request, key: str) -> None:
|
|
111
|
+
"""Set the header."""
|
|
112
|
+
request.headers[self.header] = key
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class BearerAuth(EnvKeyAuth):
|
|
116
|
+
"""Send the key as ``Authorization: Bearer <key>``."""
|
|
117
|
+
|
|
118
|
+
def apply(self, request: httpx.Request, key: str) -> None:
|
|
119
|
+
"""Set the Authorization header."""
|
|
120
|
+
request.headers["Authorization"] = f"Bearer {key}"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class BasicKeyAuth(EnvKeyAuth):
|
|
124
|
+
"""Send the key as the HTTP basic user name, as CashCtrl expects.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
env_var: Variable that holds the key.
|
|
128
|
+
password: Basic-auth password; empty for key-as-user-name schemes.
|
|
129
|
+
key: Explicit key; wins over the environment.
|
|
130
|
+
source: Source slug for error messages.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
def __init__(
|
|
134
|
+
self,
|
|
135
|
+
env_var: str,
|
|
136
|
+
*,
|
|
137
|
+
password: str = "",
|
|
138
|
+
key: str | SecretStr | None = None,
|
|
139
|
+
source: str | None = None,
|
|
140
|
+
) -> None:
|
|
141
|
+
super().__init__(env_var, key=key, source=source)
|
|
142
|
+
self.password = password
|
|
143
|
+
|
|
144
|
+
def apply(self, request: httpx.Request, key: str) -> None:
|
|
145
|
+
"""Set the Authorization header."""
|
|
146
|
+
token = base64.b64encode(f"{key}:{self.password}".encode()).decode("ascii")
|
|
147
|
+
request.headers["Authorization"] = f"Basic {token}"
|