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.
@@ -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
@@ -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}"