diff --git a/README.md b/README.md index 559156a..7ad8a8a 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,74 @@ Prefer the trailing-slash directory form (e.g. `**/work/`) to mean "this project > **Secrets:** keep `api_key` values in `~/.amplifier/keys.env` and reference them via `${VAR}` in `settings.yaml`. Never write a literal key into `settings.yaml`. +#### Authentication — `auth_mode` / `auth_resource` + +Each **target** chooses its authentication mode **independently** — every hook `destination` (the write path) and every query-tool `source` (the read path). A mixed fleet is fine: reach one server with a static key and another with a Microsoft Entra bearer token. A target is **never** both at once. + +| Mode | Selected by | Credential | +|------|-------------|------------| +| **`static`** | `auth_mode: static` — the **default**, unchanged | the target's `api_key`, sent as `Authorization: Bearer ` | +| **`entra`** | `auth_mode: entra` | a Microsoft Entra bearer token from your developer `az login` session, requested for the audience named by `auth_resource` | + +Two new per-target keys (valid on both `destinations` entries and `sources` entries): + +| Sub-key | Required | Default | Description | +|---------|----------|---------|-------------| +| `auth_mode` | no | `static` | `static` (use `api_key`) or `entra` (use a Microsoft Entra bearer token). | +| `auth_resource` | **only when `auth_mode: entra`** | — | The Entra audience the token is requested for — `api://`. | + +Both values support `${VAR}` / `${VAR:default}` environment substitution, exactly like `api_key` — e.g. `auth_resource: "${CI_AUTH_RESOURCE}"`. + +```yaml +# ~/.amplifier/settings.yaml — a mixed fleet: one static destination, one Entra destination, +# plus an Entra read source. auth_mode is chosen per target. +overrides: + hook-context-intelligence: + config: + destinations: + local-dev: # static (default) + url: "http://localhost:8080" + api_key: "${MY_CI_KEY:}" + include: ["**"] + azure-team: # entra + url: "https://ci.example.com" + auth_mode: entra + auth_resource: "api://" + include: ["**"] + tool-context-intelligence-query: + config: + sources: + azure-team: + url: "https://ci.example.com" + auth_mode: entra + auth_resource: "api://" +``` + +**Fail-loud.** A misconfigured target is a **mount error** (fail-fast, naming the offending target): `entra` with an empty `auth_resource`, or `static` with an empty `api_key` — evaluated after `${VAR}` expansion. The hook never silently sends an empty or blank bearer. + +> **Scope of Entra mode in this version.** Entra mode provides **parity with your existing `az login` identity** — it uses your developer `az login` session to obtain the bearer token. It is the **interactive-login** path, not a full enterprise non-interactive auth system. +> +> **Non-interactive environments are not yet served by Entra mode.** CI/CD pipelines and cloud-hosted services that cannot run `az login` should use a static `api_key` for now — if you are a pipeline author, reach for `auth_mode: static` to avoid surprises. Non-interactive credential support (managed identity / OIDC / service principal) is a planned follow-up. +> +> **Server-side prerequisite.** Entra mode requires the **server** to be configured to validate Entra tokens. Against a server that only accepts static keys, use `auth_mode: static`. + +#### Token caching & refresh (Entra) + +Entra mode (`auth_mode: entra`) caches the bearer token **in memory, per process** — shared across a session and its in-process subsessions. (The `static` `api_key` path holds a constant key: nothing is cached or refreshed.) + +The cached token is **reused until shortly before it expires** (Azure CLI tokens typically live ~60–90 min), then **refreshed automatically on the next request**. Net effect: the `az` credential is invoked **at most once per token lifetime per process**, not on every request — so auth adds no meaningful latency to the hot path. + +The refresh safety window is tunable via the env var **`AMPLIFIER_CONTEXT_INTELLIGENCE_TOKEN_REFRESH_MARGIN_S`** (default **300** seconds): the token is refreshed once it is within this many seconds of expiry. + +```bash +# ~/.amplifier/keys.env — refresh 600s (10 min) before expiry instead of the default 300s +AMPLIFIER_CONTEXT_INTELLIGENCE_TOKEN_REFRESH_MARGIN_S=600 +``` + +**Fail-loud.** If a refresh fails (e.g. your `az` session has expired or you ran `az logout`), the error surfaces — a stale or empty token is never sent. Re-running `az login` resolves it. + +**Switching `az account` / tenant mid-session.** Because the token is cached, a freshly switched identity is **not** picked up until the cached token refreshes (within `~margin` of expiry); until then, events would be attributed to the previously cached identity. To switch identities immediately, **start a new session** — a fresh process resets the cache. + #### Other config keys | Key | Source | Default | Description | diff --git a/context/config-resolution.dot b/context/config-resolution.dot index 8cfbd48..b080baa 100644 --- a/context/config-resolution.dot +++ b/context/config-resolution.dot @@ -92,7 +92,7 @@ digraph ConfigResolution { fillcolor="#ede7f6"; color="#673ab7"; - dst_map [label="destinations:\n{ name → Destination(url, api_key,\n include, exclude) }\nnamed map, declaration order"]; + dst_map [label="destinations:\n{ name → Destination(url, api_key,\n include, exclude,\n auth_mode, auth_resource) }\nnamed map, declaration order\nauth_mode: 'static' (default) | 'entra'\nauth_resource: api:// (entra only)\n ${VAR}-substitutable; required for entra"]; dst_legacy [label="legacy scalars\ncontext_intelligence_server_url / _api_key\n→ synthesize {\"default\": …}\nONLY when no destinations block\n(both fields required)", shape=note, fillcolor="#fff3cd"]; dst_fanout [label="fan-out: send to EVERY destination where\ninclude matches AND exclude does not\n(exclude wins; zero / one / several)\nlocal JSONL always written"]; dst_first [label="first destination\n= next(iter(destinations.values()), None)"]; @@ -132,7 +132,7 @@ digraph ConfigResolution { fillcolor="#e0f2f1"; color="#009688"; - q1 [label="1. sources[first]\n.url / .api_key\n(explicit read override;\n absent ⇒ synthesize default from\n tool's explicit scalars)"]; + q1 [label="1. sources[first]\n.url / .api_key\n.auth_mode / .auth_resource\n(explicit read override;\n absent ⇒ synthesize default from\n tool's explicit scalars)"]; q2 [label="2. hook destinations[first]\n.url / .api_key\n(bug-fix bridge: destinations-only\n setups 'just work' for reads)"]; q3 [label="3. env\nAMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL /\n_API_KEY\n(single canonical last-resort fallback)"]; q_none [label="else → None\n→ configuration_error", shape=note, fillcolor="#ffcdd2"]; @@ -142,6 +142,13 @@ digraph ConfigResolution { q3 -> q_none [label="empty?"]; } + // --- auth_mode / auth_resource annotation (Destination + Source) --- + auth_note [ + label="auth_mode / auth_resource (Destination + Source)\n─────────────────────────────────────────────\nauth_mode: 'static' (default) → api_key required\n 'entra' → auth_resource required; api_key optional\nauth_resource: api:// — ${VAR}-substitutable\nPer-target XOR: fail-loud at mount; no silent fallback.\nEntra: delegated token via AzureCliCredential + in-process cache.\n→ See docs/auth-flow.dot for the full token-cache flow.", + shape=note, style=filled, penwidth=1, + fillcolor="#EDE7F6", color="#4527A0", fontsize=10 + ]; + // --- Edges from the hook resolver --- hook_resolver -> ps_config [lhead=cluster_project_slug]; hook_resolver -> bp_config [lhead=cluster_base_path]; @@ -166,4 +173,8 @@ digraph ConfigResolution { ps_default -> blob_root [style=dashed, label="project_slug", color="#999999"]; bp_default -> blob_root [style=dashed, label="base_path", color="#999999"]; ws_slug -> ws_coord [style=invis]; // layout hint + + // --- auth_note connections --- + dst_map -> auth_note [style=dotted, arrowhead=none, color="#4527A0", label="auth fields"]; + q1 -> auth_note [style=dotted, arrowhead=none, color="#4527A0", constraint=false]; } diff --git a/context/config-resolution.png b/context/config-resolution.png new file mode 100644 index 0000000..1683d68 Binary files /dev/null and b/context/config-resolution.png differ diff --git a/context_intelligence/auth.py b/context_intelligence/auth.py new file mode 100644 index 0000000..1743b9e --- /dev/null +++ b/context_intelligence/auth.py @@ -0,0 +1,324 @@ +"""Authentication strategies for context-intelligence HTTP clients. + +Provides a small, composable auth layer: + + strategy = build_auth_strategy(auth_mode="static", api_key="sk-my-key") + # or + strategy = build_auth_strategy(auth_mode="entra", auth_resource="api://...") + headers = strategy.headers() # {"Authorization": "Bearer "} + +Design notes +------------ +- ``AuthStrategy`` is a typing.Protocol — any object with ``headers() -> dict[str, str]`` + satisfies it without inheritance. +- ``build_auth_strategy`` imports ``AzureCliCredential`` LAZILY (inside the entra branch) + so static-mode callers never need ``azure-identity`` installed. +- CACHING: ``AzureCliCredential`` has NO in-process token cache — every ``get_token()`` + call shells out to ``az`` (~487–553 ms, measured). This module caches the returned + ``AccessToken`` until ``expires_on − _SAFETY_MARGIN_S`` so the ``az`` subprocess runs + at most once per token lifetime (~67 min) rather than on every request. +- SINGLETON: A single ``AzureCliCredential`` instance is shared across all in-process + sessions via ``_get_singleton_credential()``. ``build_auth_strategy``/mount performs + ~zero expensive work: no credential construction, no token acquisition. +- CONCURRENCY: the refresh path serialises via a ``threading.Lock``. The hot path is + lock-free (one dict read + one float compare + one f-string). A ``threading.Lock`` + (not ``asyncio.Lock``) is used because a module-level ``asyncio.Lock`` binds to the + event loop that created it; in-process subsessions running on different event loops + would raise "attached to a different event loop". +- ``az account`` switch mid-process is NOT auto-detected per call (probing would defeat + the cache and violate the no-expensive-retrieval rule). Recovery: call ``reset()`` + and let the server's 403 on an unmapped OID serve as the loud signal. +""" + +from __future__ import annotations + +import os +import threading +import time +from typing import Any, Protocol + + +# --------------------------------------------------------------------------- +# Safety margin for token refresh (process-level constant) +# --------------------------------------------------------------------------- + +try: + _SAFETY_MARGIN_S: float = float( + os.environ.get("AMPLIFIER_CONTEXT_INTELLIGENCE_TOKEN_REFRESH_MARGIN_S", "300") + ) +except ValueError: + _SAFETY_MARGIN_S = 300.0 + +# --------------------------------------------------------------------------- +# Protocol +# --------------------------------------------------------------------------- + + +class AuthStrategy(Protocol): + """Minimal auth strategy: produce HTTP headers for a single request batch.""" + + def headers(self) -> dict[str, str]: + """Return the HTTP headers dict to attach to client requests.""" + ... + + +# --------------------------------------------------------------------------- +# Implementations +# --------------------------------------------------------------------------- + + +class ApiKeyAuth: + """Bearer-token auth backed by a static API key.""" + + def __init__(self, api_key: str) -> None: + self._api_key = api_key + + def headers(self) -> dict[str, str]: + return {"Authorization": f"Bearer {self._api_key}"} + + +class _TokenCache: + """In-process token cache keyed by scope string. + + Hot path (cache hit): one dict read + one float compare — lock-free. + Refresh path (miss / near-expiry): ``threading.Lock`` + double-check + ``get_token()``. + + Threading note + -------------- + ``threading.Lock`` is used (not ``asyncio.Lock``) because a module-level + ``asyncio.Lock`` binds to the loop that created it; in-process subsessions that run + on different loops raise "attached to a different event loop". ``threading.Lock`` + is loop-agnostic and correct here. + """ + + __slots__ = ("_cache", "_lock") + + def __init__(self) -> None: + self._cache: dict[str, tuple[str, float]] = {} # {scope: (token, expires_on)} + self._lock = threading.Lock() + + def get(self, scope: str) -> tuple[str, float] | None: + """Return ``(token_str, expires_on)`` for *scope*, or ``None`` if absent.""" + return self._cache.get(scope) + + def store(self, scope: str, token: str, expires_on: float) -> None: + """Store ``(token_str, expires_on)`` for *scope*.""" + self._cache[scope] = (token, expires_on) + + def clear(self) -> None: + """Remove all cached entries.""" + self._cache.clear() + + +class EntraTokenAuth: + """Bearer-token auth backed by an Entra ID (azure-identity) credential. + + CACHING RATIONALE + ----------------- + ``AzureCliCredential`` has no in-process cache — every ``get_token()`` call shells + out to ``az`` (~487–553 ms, measured). This class caches the ``AccessToken`` until + ``expires_on − _SAFETY_MARGIN_S`` so the ``az`` subprocess runs at most once per + token lifetime (~67 min). + + HOT PATH (cache hit) + -------------------- + One dict read + one float compare + one f-string — lock-free, no subprocess, + no await. Cost: microseconds. + + REFRESH PATH (miss or near-expiry) + ----------------------------------- + Acquire ``threading.Lock``, double-check (another thread may have refreshed while + waiting), call ``get_token()`` (blocking subprocess, ~once per token lifetime), + store result, release. Exceptions propagate — nothing is cached on failure so the + next call retries. + + ``headers()`` stays sync; ``get_token()`` is a blocking subprocess that briefly + blocks the caller (and an async event loop) ONLY during the rare refresh + (~once per ~67 min token lifetime) — acceptable; no async machinery is added. + + AZ ACCOUNT SWITCH + ----------------- + Mid-session ``az account`` switch is NOT auto-detected per call (probing would + defeat the cache and violate the no-expensive-retrieval rule). Recovery: call + ``reset()`` and let the server's 403 on an unmapped OID serve as the loud signal. + """ + + def __init__( + self, + credential: Any, + resource: str, + *, + _cache: _TokenCache | None = None, + ) -> None: + """ + Parameters + ---------- + credential: + Any azure-identity ``TokenCredential`` (e.g. ``AzureCliCredential``). + resource: + The Entra resource URI (e.g. ``api://``). The scope + ``/.default`` is passed to ``get_token()``. + _cache: + ``_TokenCache`` instance. When ``None`` (default), a fresh per-instance + cache is created — this is the correct default for direct construction in + tests, ensuring full isolation between test cases. ``build_auth_strategy`` + wires the module-level singleton cache for production and a fresh cache for + injected credentials (tests via the ``credential`` parameter). + """ + self._credential = credential + self._resource = resource + self._cache: _TokenCache = _cache if _cache is not None else _TokenCache() + + def headers(self) -> dict[str, str]: + scope = f"{self._resource}/.default" + margin = _SAFETY_MARGIN_S # module-level attribute read — fast, no subprocess + + # ------------------------------------------------------------------ # + # HOT PATH — lock-free # + # One dict read + one float compare + one f-string = microseconds. # + # No lock, no subprocess, no await on this path. # + # ------------------------------------------------------------------ # + entry = self._cache.get(scope) + if entry is not None: + token_str, expires_on = entry + if time.time() < expires_on - margin: + return {"Authorization": f"Bearer {token_str}"} + + # ------------------------------------------------------------------ # + # REFRESH PATH — serialise, double-check, fetch # + # ------------------------------------------------------------------ # + with self._cache._lock: + # Double-check: another thread may have refreshed while we waited. + entry = self._cache.get(scope) + if entry is not None: + token_str, expires_on = entry + if time.time() < expires_on - margin: + return {"Authorization": f"Bearer {token_str}"} + + # get_token() is a blocking subprocess (~once per token lifetime). + # Exceptions propagate — we never cache a failure. + token = self._credential.get_token(scope) + self._cache.store(scope, token.token, float(token.expires_on)) + return {"Authorization": f"Bearer {token.token}"} + + +# --------------------------------------------------------------------------- +# Module-level singletons +# --------------------------------------------------------------------------- + +_singleton_credential: Any = None +_MODULE_CACHE: _TokenCache = _TokenCache() + + +def reset() -> None: + """Clear the module-level token cache and drop the singleton credential. + + Call after ``az account`` switch mid-process so the next ``headers()`` call + re-authenticates and picks up the new identity. + + Tradeoff: per-call identity probing would defeat the cache and violate the + no-expensive-retrieval rule. A mid-session ``az account`` switch is therefore + NOT auto-detected per call. Recovery: call ``reset()``, then let the server's + 403 on an unmapped OID serve as the loud signal. + + Only affects the module singleton. ``EntraTokenAuth`` instances constructed with + an injected credential (tests, or direct construction) use their own ``_TokenCache`` + and are NOT affected by ``reset()``. + """ + global _singleton_credential + _MODULE_CACHE.clear() + _singleton_credential = None + + +# --------------------------------------------------------------------------- +# Lazy credential factory — isolated here for testability +# --------------------------------------------------------------------------- + + +def _make_cli_credential() -> Any: + """Lazily import and instantiate ``AzureCliCredential``. + + Isolated in its own function so unit tests can patch + ``context_intelligence.auth._make_cli_credential`` without requiring the + ``azure-identity`` package at import time. + """ + from azure.identity import AzureCliCredential # noqa: PLC0415 + + return AzureCliCredential() + + +def _get_singleton_credential() -> Any: + """Return the process-level singleton ``AzureCliCredential``, creating it once. + + All in-process sessions and subsessions share this single instance so that + ``build_auth_strategy()`` (the mount equivalent) performs ~zero work: no new + credential construction, no token acquisition. + """ + global _singleton_credential + if _singleton_credential is None: + _singleton_credential = _make_cli_credential() + return _singleton_credential + + +# --------------------------------------------------------------------------- +# Builder +# --------------------------------------------------------------------------- + + +def build_auth_strategy( + *, + auth_mode: str, + api_key: str = "", + auth_resource: str = "", + credential: Any = None, +) -> AuthStrategy: + """Build and return the appropriate ``AuthStrategy`` for *auth_mode*. + + Parameters + ---------- + auth_mode: + ``"static"`` — use a pre-issued API key. + ``"entra"`` — acquire a delegated token via ``az login`` (V1: AzureCliCredential). + api_key: + Required when ``auth_mode == "static"``. + auth_resource: + Required when ``auth_mode == "entra"``. Typically ``api://``. + credential: + Optional pre-built ``TokenCredential``. + + - When ``None`` and ``auth_mode == "entra"``: the process-level singleton + ``AzureCliCredential`` is used (one instance shared by all sessions; this + call performs ~zero expensive work) and the module-level ``_MODULE_CACHE`` + is shared across all callers — this is the production path. + - When non-``None`` (e.g. a fake in tests): the injected credential is used + with a FRESH per-instance ``_TokenCache`` so tests are fully isolated and + never share state with each other or with the module singleton. + + Raises + ------ + ValueError + On invalid *auth_mode*, missing *api_key* (static), or missing + *auth_resource* (entra). **No silent fallbacks.** + """ + if auth_mode == "static": + if not api_key.strip(): + raise ValueError( + "auth_mode=static requires a non-empty api_key. " + "Pass --api-key or set AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY." + ) + return ApiKeyAuth(api_key) + + if auth_mode == "entra": + if not auth_resource.strip(): + raise ValueError( + "auth_mode=entra requires a non-empty auth_resource. " + "Pass --auth-resource or set AMPLIFIER_CONTEXT_INTELLIGENCE_AUTH_RESOURCE." + ) + if credential is None: + # Production path: singleton credential + shared module cache. + # build_auth_strategy() / mount() performs ~zero expensive work here. + return EntraTokenAuth(_get_singleton_credential(), auth_resource, _cache=_MODULE_CACHE) + # Test/injection path: fresh per-instance cache → full isolation. + return EntraTokenAuth(credential, auth_resource, _cache=_TokenCache()) + + raise ValueError(f"unknown auth_mode {auth_mode!r}. Valid values: 'static', 'entra'.") diff --git a/context_intelligence/client.py b/context_intelligence/client.py index 30a6cb4..2ebe992 100644 --- a/context_intelligence/client.py +++ b/context_intelligence/client.py @@ -13,7 +13,10 @@ import json import logging import urllib.request -from typing import Any +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from context_intelligence.auth import AuthStrategy logger = logging.getLogger("context_intelligence.client") @@ -174,19 +177,34 @@ class CIClient: Base URL of the context-intelligence server (trailing slash is stripped). api_key: API key sent as ``Authorization: Bearer `` on every request. + Used only when *auth_strategy* is not provided (backward compat). + auth_strategy: + Optional pre-built ``AuthStrategy``. When provided, ``headers()`` is + called PER REQUEST so that Entra tokens are refreshed automatically. + When ``None``, an ``ApiKeyAuth(api_key)`` is built implicitly (backward compat). """ - def __init__(self, server_url: str, api_key: str) -> None: + def __init__( + self, + server_url: str, + api_key: str = "", + auth_strategy: "AuthStrategy | None" = None, + ) -> None: + from context_intelligence.auth import ApiKeyAuth # noqa: PLC0415 + self._server_url: str = server_url.rstrip("/") self._api_key: str = api_key + self._strategy: AuthStrategy = ( # type: ignore[assignment] + auth_strategy if auth_strategy is not None else ApiKeyAuth(api_key) + ) # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ def _auth_headers(self) -> dict[str, str]: - """Return the ``Authorization`` header dict.""" - return _build_headers(self._api_key) + """Return the ``Authorization`` header dict, computed per-request via strategy.""" + return self._strategy.headers() # ------------------------------------------------------------------ # Public API @@ -314,15 +332,30 @@ class AsyncCIClient: Base URL of the context-intelligence server (trailing slash is stripped). api_key: API key sent as ``Authorization: Bearer `` on every request. + Used only when *auth_strategy* is not provided (backward compat). + auth_strategy: + Optional pre-built ``AuthStrategy``. When provided, ``headers()`` is + called PER REQUEST so that Entra tokens are refreshed automatically. + When ``None``, an ``ApiKeyAuth(api_key)`` is built implicitly (backward compat). """ - def __init__(self, server_url: str, api_key: str) -> None: + def __init__( + self, + server_url: str, + api_key: str = "", + auth_strategy: "AuthStrategy | None" = None, + ) -> None: + from context_intelligence.auth import ApiKeyAuth # noqa: PLC0415 + if httpx is None: raise ImportError( "httpx is required for AsyncCIClient. Install it with: pip install httpx" ) self._server_url: str = server_url.rstrip("/") self._api_key: str = api_key + self._strategy: AuthStrategy = ( # type: ignore[assignment] + auth_strategy if auth_strategy is not None else ApiKeyAuth(api_key) + ) # ------------------------------------------------------------------ # Public API @@ -359,7 +392,7 @@ async def cypher( } try: async with httpx.AsyncClient() as client: # type: ignore[union-attr] - resp = await client.post(url, json=body, headers=_build_headers(self._api_key)) + resp = await client.post(url, json=body, headers=self._strategy.headers()) resp.raise_for_status() result = resp.json() except Exception: @@ -394,7 +427,7 @@ async def fetch_blob(self, session_id: str, key: str) -> Any | None: url = f"{self._server_url}/blobs/{session_id}/{key}" try: async with httpx.AsyncClient() as client: # type: ignore[union-attr] - resp = await client.get(url, headers=_build_headers(self._api_key)) + resp = await client.get(url, headers=self._strategy.headers()) resp.raise_for_status() return resp.json() except Exception: @@ -419,7 +452,7 @@ async def list_blob_keys(self, session_id: str) -> set[str]: url = f"{self._server_url}/blobs/{session_id}" try: async with httpx.AsyncClient() as client: # type: ignore[union-attr] - resp = await client.get(url, headers=_build_headers(self._api_key)) + resp = await client.get(url, headers=self._strategy.headers()) resp.raise_for_status() result = resp.json() except Exception: diff --git a/context_intelligence/config.py b/context_intelligence/config.py index d0c9a66..d53bdcb 100644 --- a/context_intelligence/config.py +++ b/context_intelligence/config.py @@ -54,7 +54,7 @@ def _env(suffix: str) -> str | None: # Shell-style placeholder expander (used by ToolConfigResolver) # --------------------------------------------------------------------------- -_PLACEHOLDER_RE = re.compile(r"\$\{([^}:]+)(?::([^}]*))?\\}") +_PLACEHOLDER_RE = re.compile(r"\$\{([^}:]+)(?::([^}]*))?}") def _expand_env_placeholders(value: str) -> str: @@ -145,6 +145,7 @@ def resolve_config( *, server_url: str | None = None, api_key: str | None = None, + auth_mode: str = "static", ) -> tuple[str, str]: """Resolve server URL and API key. @@ -154,21 +155,33 @@ def resolve_config( ``AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY``) 3. ``~/.amplifier/settings.yaml`` + Parameters + ---------- + server_url: + Base URL of the CI server (overrides env var / settings.yaml). + api_key: + API key for static-mode auth (overrides env var / settings.yaml). + auth_mode: + ``"static"`` (default) — api_key is required. + ``"entra"`` — api_key is optional; token is acquired via azure-identity. + Returns: - ``(server_url, api_key)`` tuple. + ``(server_url, api_key)`` tuple. In entra mode ``api_key`` may be an + empty string — callers must use an ``AuthStrategy`` to build the header. Raises: - SystemExit: if either value cannot be resolved. + SystemExit: if server_url cannot be resolved, or if api_key is missing + in static mode. """ resolved_url = server_url or os.environ.get("AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL") resolved_key = api_key or os.environ.get("AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY") # Fallback to settings.yaml - if not resolved_url or not resolved_key: + if not resolved_url or (not resolved_key and auth_mode == "static"): settings = _parse_settings_yaml(SETTINGS_PATH) if not resolved_url: resolved_url = settings.get("server_url", "") - if not resolved_key: + if not resolved_key and auth_mode == "static": resolved_key = settings.get("api_key", "") if not resolved_url: @@ -176,10 +189,10 @@ def resolve_config( "No CI server URL found. Set AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL " "or use --server-url, or configure in ~/.amplifier/settings.yaml" ) - if not resolved_key: + if auth_mode == "static" and not resolved_key: raise SystemExit( "No CI API key found. Set AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY " "or use --api-key, or configure in ~/.amplifier/settings.yaml" ) - return resolved_url, resolved_key + return resolved_url, resolved_key or "" diff --git a/context_intelligence/tool_resolver.py b/context_intelligence/tool_resolver.py index 56c9ba4..0fcd67c 100644 --- a/context_intelligence/tool_resolver.py +++ b/context_intelligence/tool_resolver.py @@ -93,11 +93,17 @@ class Source(NamedTuple): url and api_key may be empty strings (→ falsy → that field falls through in the per-field resolution chain). + + auth_mode: ``"static"`` (default) — use api_key as bearer token. + ``"entra"`` — acquire a delegated Entra token via azure-identity. + auth_resource: Entra resource URI (e.g. ``api://``). Required for auth_mode="entra". """ name: str url: str api_key: str + auth_mode: str = "static" + auth_resource: str = "" # --------------------------------------------------------------------------- @@ -133,6 +139,66 @@ def _pick(*candidates: tuple[str | None, str | None]) -> tuple[str | None, str | return None, None +def resolve_query_auth_strategy( + hook_resolver: Any | None, + tool_resolver: "ToolConfigResolver", + api_key: str = "", +) -> Any: + """Build an AuthStrategy for query tool requests. + + Lookup priority (mirrors resolve_query_endpoint field-by-field): + 1. first entry of tool_resolver.sources (auth_mode / auth_resource) + 2. first upload destination on the hook resolver (auth_mode / auth_resource) + 3. env AMPLIFIER_CONTEXT_INTELLIGENCE_AUTH_MODE / _AUTH_RESOURCE (tier-3 fallback) + + The returned strategy always calls ``headers()`` per-request, so Entra tokens + are refreshed by the azure-identity SDK when they near expiry. + + Parameters + ---------- + hook_resolver: + The hook's HookConfigResolver (may be None). + tool_resolver: + The tool's ToolConfigResolver. + api_key: + Resolved API key (from resolve_query_endpoint). Used for static mode. + + Returns + ------- + AuthStrategy + A built auth strategy (``ApiKeyAuth`` or ``EntraTokenAuth``). + """ + from context_intelligence.auth import ApiKeyAuth, build_auth_strategy # noqa: PLC0415 + + read = _first_entry(tool_resolver.sources) + dest = _first_destination(hook_resolver) + + # auth_mode / auth_resource: first non-empty source wins + auth_mode: str = ( + (read.auth_mode if read else "") + or (getattr(dest, "auth_mode", "") if dest else "") + or _env("AUTH_MODE") + or "static" + ) + auth_resource: str = ( + (read.auth_resource if read else "") + or (getattr(dest, "auth_resource", "") if dest else "") + or _env("AUTH_RESOURCE") + or "" + ) + + if auth_mode == "static": + # Return an ApiKeyAuth even for empty key — same graceful-degrade behaviour as before. + return ApiKeyAuth(api_key) + + # For entra (and any future mode), delegate to build_auth_strategy which raises loudly. + return build_auth_strategy( + auth_mode=auth_mode, + api_key=api_key, + auth_resource=auth_resource, + ) + + def resolve_query_endpoint( hook_resolver: Any | None, tool_resolver: "ToolConfigResolver", @@ -290,9 +356,17 @@ def sources(self) -> dict[str, Source]: for name, spec in raw.items(): if not isinstance(spec, dict): continue - url = str(spec.get("url", "") or "").strip() - api_key = str(spec.get("api_key", "") or "").strip() - result[name] = Source(name=name, url=url, api_key=api_key) + url = str(_expand(spec.get("url", "") or "")).strip() + api_key = str(_expand(spec.get("api_key", "") or "")).strip() + auth_mode = str(_expand(spec.get("auth_mode", "static") or "static")).strip() + auth_resource = str(_expand(spec.get("auth_resource", "") or "")).strip() + result[name] = Source( + name=name, + url=url, + api_key=api_key, + auth_mode=auth_mode, + auth_resource=auth_resource, + ) self._sources = result return self._sources @@ -312,6 +386,45 @@ def sources(self) -> dict[str, Source]: self._sources = {} return self._sources + def validate_sources(self) -> dict[str, Source]: + """Validate and return all configured sources. Fail-fast (mirrors validate_destinations). + + Per-source XOR auth validation: + - auth_mode="static" (default): api_key must be non-empty. + - auth_mode="entra": auth_resource must be non-empty; api_key is not required. + - unknown auth_mode: always raises. + - url must always be non-empty for explicitly configured sources. + + Empty sources dict is valid (no explicit read-config; fallback to hook destinations / env). + + Raises: + ValueError: naming the offending source(s) and the empty field(s). + Returns: + The validated sources dict (possibly empty -> fallback to hook resolver / env, OK). + """ + srcs = self.sources + problems: list[str] = [] + for name, src in srcs.items(): + if not src.url: + problems.append(f"{name}: missing url") + if src.auth_mode == "static": + if not src.api_key: + problems.append(f"{name}: missing api_key") + elif src.auth_mode == "entra": + if not src.auth_resource: + problems.append(f"{name}: missing auth_resource (required for auth_mode=entra)") + else: + problems.append( + f"{name}: unknown auth_mode {src.auth_mode!r} (valid: 'static', 'entra')" + ) + if problems: + raise ValueError( + f"context-intelligence sources misconfigured: {', '.join(problems)}. " + f"Set url and api_key (static) or auth_resource (entra) under " + f"overrides.tool-context-intelligence-query.config.sources.." + ) + return srcs + @property def skill_sync_enabled(self) -> bool: """Whether the analytics path syncs watched skills on session start. diff --git a/docs/auth-flow.dot b/docs/auth-flow.dot new file mode 100644 index 0000000..ceff585 --- /dev/null +++ b/docs/auth-flow.dot @@ -0,0 +1,397 @@ +// Auth Flow — Entra Token Cache as Visual Centerpiece +// Context-Intelligence Bundle +// +// Shows the complete per-request auth path for hook dispatch (LoggingHandler) +// and tool queries. The in-process _TokenCache is the design pivot: it makes +// AzureCliCredential (~487–913 ms az subprocess) a ≤-once-per-67-min cost +// rather than a per-request cost. +// +// Shape vocabulary +// box (rounded,filled) — process step / action +// diamond — decision branch +// cylinder — data store / cache (singleton) +// note — annotation / invariant +// +// Color semantics +// #E3F2FD / #1565C0 blue — entry / exit / bearer output +// #1B5E20 / white dark green — HOT PATH (THE FAST LANE, penwidth=3) +// #FFECB3 / #E65100 amber/orange — CACHE DECISION centerpiece (penwidth=3) +// #FFF8E1 / #F57F17 yellow — decision diamond / lock acquire +// #FFF3E0 / #E65100 orange — miss / az refresh path +// #FFEBEE / #C62828 red — FAIL-LOUD / 401 rejection +// #EDE7F6 / #4527A0 deep purple — entra lane / singletons +// #B39DDB / #4527A0 medium purple — singleton nodes +// #F5F5F5 / #9E9E9E gray — static lane (trivial path) +// #E0F7FA / #00838F teal — CI server zone +// #E8F5E9 / #2E7D32 green — success / store / 202-200 +// #ECEFF1 / #546E7A blue-gray — reset() utility path +// +// Render: dot -Tpng auth-flow.dot -o auth-flow.png + +digraph AuthFlow { + rankdir=TB; + fontname="Helvetica"; + fontsize=12; + compound=true; + nodesep=0.7; + ranksep=0.85; + pad=0.5; + + // ─── Global defaults ─────────────────────────────────────────────── + node [fontname="Helvetica", fontsize=11]; + edge [fontname="Helvetica", fontsize=10]; + + // ══════════════════════════════════════════════════════════════════ + // ENTRY + // ══════════════════════════════════════════════════════════════════ + + request_entry [ + label="strategy.headers() called per request\n─────────────────────────────────\n_DestinationDispatcher._post() (hook dispatch)\nor query tool before each GET / POST\nbuild_auth_strategy() at mount → ~zero cost\n(no credential built, no token acquired)", + shape=box, style="rounded,filled", penwidth=2, + fillcolor="#E3F2FD", color="#1565C0" + ]; + + auth_mode_check [ + label="auth_mode?", + shape=diamond, style=filled, penwidth=2, + fillcolor="#FFF8E1", color="#F57F17", fontcolor="#F57F17" + ]; + + // ══════════════════════════════════════════════════════════════════ + // STATIC LANE — simple / small intentionally + // ══════════════════════════════════════════════════════════════════ + + subgraph cluster_static { + label="static lane"; + style=filled; + fillcolor="#F5F5F5"; + color="#9E9E9E"; + fontsize=11; + fontname="Helvetica Bold"; + + node [shape=box, style="rounded,filled", penwidth=1.5]; + + api_key_auth [ + label="ApiKeyAuth\n─────────────────────────────────\nreturn { \"Authorization\": \"Bearer \" }\nConstant — no subprocess, no cache,\nzero cost every call.", + fillcolor="#EEEEEE", color="#757575" + ]; + } + + // ══════════════════════════════════════════════════════════════════ + // ENTRA LANE — EntraTokenAuth (THE FOCUS) + // ══════════════════════════════════════════════════════════════════ + + subgraph cluster_entra { + label="entra lane — EntraTokenAuth (THE FOCUS)"; + style=filled; + fillcolor="#EDE7F6"; + color="#4527A0"; + fontsize=12; + fontname="Helvetica Bold"; + + // ─── Process-level singletons ─────────────────────────────── + subgraph cluster_singletons { + label="Process-level singletons — built once, lazily, shared by all sessions"; + style=filled; + fillcolor="#D1C4E9"; + color="#4527A0"; + fontsize=11; + fontname="Helvetica Bold"; + + singleton_cred [ + label="AzureCliCredential (SINGLETON)\n─────────────────────────────────\n_get_singleton_credential()\nOne instance per process.\nNO token acquired at mount / strategy build.", + shape=box, style="rounded,filled", penwidth=1.5, + fillcolor="#B39DDB", color="#4527A0" + ]; + + singleton_cache [ + label="_MODULE_CACHE (_TokenCache SINGLETON)\n─────────────────────────────────\nscope → (token_str, expires_on)\nOne in-memory dict shared by all callers.\nHot path is lock-free (plain dict.get + float compare).", + shape=cylinder, style=filled, penwidth=2, + fillcolor="#B39DDB", color="#4527A0" + ]; + } + + scope_compute [ + label="scope = f\"{auth_resource}/.default\"", + shape=box, style="rounded,filled", penwidth=1.5, + fillcolor="#EDE7F6", color="#7E57C2" + ]; + + // ╔══════════════════════════════════════════════════════════╗ + // ║ CACHE DECISION — VISUAL CENTERPIECE ║ + // ╚══════════════════════════════════════════════════════════╝ + cache_check [ + label="CACHE DECISION\n─────────────────────────────────────────\ncache.get(scope)\n↓\ntime.time() < expires_on − margin ?", + shape=diamond, style=filled, penwidth=3, + fillcolor="#FFECB3", color="#E65100", fontcolor="#BF360C", + fontsize=13, fontname="Helvetica Bold" + ]; + + margin_note [ + label="margin = AMPLIFIER_CONTEXT_INTELLIGENCE_TOKEN_REFRESH_MARGIN_S\ndefault: 300 s (5 min safety buffer before expiry)", + shape=note, style=filled, penwidth=1, + fillcolor="#FFF8E1", color="#F57F17", fontsize=10 + ]; + + // ─── HOT PATH (cache hit) ─ bold / highlighted ────────────── + hot_path [ + label="HOT PATH — cache hit\n─────────────────────────────────\nlock-free dict read + float compare\n~0.002 ms\nno lock · no subprocess · no await", + shape=box, style="rounded,filled", penwidth=3, + fillcolor="#1B5E20", color="#1B5E20", fontcolor="white", + fontsize=12, fontname="Helvetica Bold" + ]; + + // ─── MISS / near-expiry path ───────────────────────────────── + acquire_lock [ + label="acquire threading.Lock\n─────────────────────────────────\nSerializes concurrent refreshes.\nthreading.Lock (not asyncio.Lock —\nloop-agnostic across in-process subsessions)", + shape=box, style="rounded,filled", penwidth=1.5, + fillcolor="#FFF8E1", color="#F57F17" + ]; + + double_check [ + label="double-check\nanother thread refreshed\nwhile we waited?", + shape=diamond, style=filled, penwidth=1.5, + fillcolor="#FFF8E1", color="#F57F17", fontcolor="#F57F17" + ]; + + get_token [ + label="credential.get_token(scope)\n─────────────────────────────────\naz CLI subprocess\n~487–913 ms (measured)\n≤ once per ~67-min token lifetime", + shape=box, style="rounded,filled", penwidth=2, + fillcolor="#FFF3E0", color="#E65100" + ]; + + store_token [ + label="_cache.store(scope, token, expires_on)\n─────────────────────────────────\nrelease threading.Lock", + shape=box, style="rounded,filled", penwidth=1.5, + fillcolor="#E8F5E9", color="#2E7D32" + ]; + + // ─── FAIL-LOUD path ────────────────────────────────────────── + fail_loud [ + label="FAIL-LOUD\n─────────────────────────────────\nexception propagates to caller\nnothing cached on failure\nnext call retries from scratch", + shape=box, style="rounded,filled", penwidth=2, + fillcolor="#FFEBEE", color="#C62828" + ]; + + } // end cluster_entra + + // ─── reset() — utility path (outside normal call flow) ────────── + reset_node [ + label="reset()\n─────────────────────────────────\nclears _MODULE_CACHE\ndrops singleton credential\n→ next headers() re-authenticates\nUse after: az account switch / tests", + shape=box, style="rounded,filled", penwidth=1.5, + fillcolor="#ECEFF1", color="#546E7A" + ]; + + // ─── Bearer output convergence ─────────────────────────────────── + bearer_header [ + label="Authorization: Bearer ", + shape=box, style="rounded,filled", penwidth=2, + fillcolor="#E3F2FD", color="#1565C0" + ]; + + // ══════════════════════════════════════════════════════════════════ + // CI SERVER VALIDATION + // ══════════════════════════════════════════════════════════════════ + + subgraph cluster_server { + label="CI server — bearer validation"; + style=filled; + fillcolor="#E0F7FA"; + color="#00838F"; + fontsize=12; + fontname="Helvetica Bold"; + + node [shape=box, style="rounded,filled", penwidth=1.5]; + + ci_server [ + label="CI server validates bearer\n─────────────────────────────────\nchecks scp = access_as_user\n(delegated flow only)", + fillcolor="#B2EBF2", color="#00838F" + ]; + + resp_ok [ + label="202 (write) / 200 (read)\n─────────────────────────────────\nscp = access_as_user ✓\ndelegated token accepted", + fillcolor="#E8F5E9", color="#2E7D32" + ]; + + resp_401 [ + label="401 Unauthorized\n─────────────────────────────────\napp / roles token rejected\n(not a delegated scp= token)", + fillcolor="#FFEBEE", color="#C62828" + ]; + + resp_403 [ + label="403 Forbidden\n─────────────────────────────────\nunmapped oid\n→ signal: call reset() + re-auth", + fillcolor="#FFF3E0", color="#E65100" + ]; + } + + // ══════════════════════════════════════════════════════════════════ + // LEGEND + // ══════════════════════════════════════════════════════════════════ + + subgraph cluster_legend { + label="Legend"; + style=filled; + fillcolor="#FAFAFA"; + color="#B0BEC5"; + fontsize=11; + fontname="Helvetica Bold"; + + node [fontsize=10]; + + l_cache [ + label="CACHE DECISION\n(amber diamond, bold border, penwidth=3)\n= fresh check on every call", + shape=diamond, style=filled, + fillcolor="#FFECB3", color="#E65100", fontcolor="#BF360C" + ]; + l_hot [ + label="HOT PATH (dark green, bold border, penwidth=3)\n= lock-free cache hit, ~0.002 ms", + shape=box, style="rounded,filled", + fillcolor="#1B5E20", color="#1B5E20", fontcolor=white + ]; + l_miss [ + label="miss path (orange)\n= az subprocess, ≤ once / ~67 min", + shape=box, style="rounded,filled", + fillcolor="#FFF3E0", color="#E65100" + ]; + l_fail [ + label="FAIL-LOUD (red)\n= exception propagates, nothing cached", + shape=box, style="rounded,filled", + fillcolor="#FFEBEE", color="#C62828" + ]; + l_static [ + label="static lane (gray)\n= constant Bearer, zero cost", + shape=box, style="rounded,filled", + fillcolor="#EEEEEE", color="#757575" + ]; + l_singleton [ + label="SINGLETON (deep purple, cylinder)\n= process-level, shared across sessions", + shape=cylinder, style=filled, + fillcolor="#B39DDB", color="#4527A0" + ]; + l_reset [ + label="reset() (blue-gray)\n= manual recovery after az account switch", + shape=box, style="rounded,filled", + fillcolor="#ECEFF1", color="#546E7A" + ]; + + l_cache -> l_hot [style=invis]; + l_hot -> l_miss [style=invis]; + l_miss -> l_fail [style=invis]; + l_fail -> l_static [style=invis]; + l_static -> l_singleton [style=invis]; + l_singleton -> l_reset [style=invis]; + } + + // ══════════════════════════════════════════════════════════════════ + // EDGES — main flow + // ══════════════════════════════════════════════════════════════════ + + request_entry -> auth_mode_check [penwidth=2]; + + auth_mode_check -> api_key_auth [ + label=" static ", + color="#757575", fontcolor="#757575", penwidth=1.5 + ]; + auth_mode_check -> scope_compute [ + label=" entra ", + color="#4527A0", fontcolor="#4527A0", penwidth=2 + ]; + + scope_compute -> cache_check [penwidth=2, color="#4527A0"]; + + // Margin annotation (no layout effect) + margin_note -> cache_check [ + style=dotted, arrowhead=none, + color="#F57F17", constraint=false + ]; + + // ─── HOT PATH (YES branch) ──────────────────────────────────────── + cache_check -> hot_path [ + label=" YES — fresh\n (hot path, ~0.002 ms) ", + color="#1B5E20", fontcolor="#1B5E20", penwidth=3 + ]; + + // ─── MISS PATH (NO branch) ─────────────────────────────────────── + cache_check -> acquire_lock [ + label=" NO — miss / near-expiry ", + color="#E65100", fontcolor="#E65100", penwidth=2 + ]; + + acquire_lock -> double_check [penwidth=1.5]; + + double_check -> hot_path [ + label=" YES — another thread\n already refreshed ", + color="#1B5E20", fontcolor="#1B5E20", penwidth=1.5 + ]; + double_check -> get_token [ + label=" NO — still stale ", + color="#E65100", fontcolor="#E65100", penwidth=2 + ]; + + get_token -> store_token [ + label=" success ", + color="#2E7D32", fontcolor="#2E7D32", penwidth=1.5 + ]; + get_token -> fail_loud [ + label=" exception ", + color="#C62828", fontcolor="#C62828", penwidth=2 + ]; + + // ─── Singleton wiring (drawn without affecting rank layout) ────── + singleton_cred -> get_token [ + label=" calls get_token() ", + style=dashed, color="#4527A0", fontcolor="#4527A0", + penwidth=1.5, constraint=false + ]; + singleton_cache -> cache_check [ + label=" cache.get(scope) ", + style=dashed, color="#4527A0", fontcolor="#4527A0", + penwidth=1.5, constraint=false + ]; + singleton_cache -> store_token [ + label=" cache.store() ", + style=dashed, color="#4527A0", fontcolor="#4527A0", + penwidth=1.5, constraint=false + ]; + + // ─── Bearer convergence ────────────────────────────────────────── + hot_path -> bearer_header [penwidth=3, color="#1B5E20"]; + store_token -> bearer_header [penwidth=1.5, color="#2E7D32"]; + api_key_auth -> bearer_header [penwidth=1.5, color="#757575"]; + + // ─── POST to CI server ─────────────────────────────────────────── + bearer_header -> ci_server [ + label=" POST {url}/events\n Authorization: Bearer … ", + penwidth=2, color="#00838F", fontcolor="#00838F" + ]; + + ci_server -> resp_ok [ + label=" scp=access_as_user ", + color="#2E7D32", fontcolor="#2E7D32", penwidth=1.5 + ]; + ci_server -> resp_401 [ + label=" roles/app token ", + color="#C62828", fontcolor="#C62828", penwidth=1.5 + ]; + ci_server -> resp_403 [ + label=" unmapped oid ", + color="#E65100", fontcolor="#E65100", penwidth=1.5 + ]; + + // ─── reset() recovery path ─────────────────────────────────────── + resp_403 -> reset_node [ + label=" recovery:\n call reset() ", + style=dashed, color="#546E7A", fontcolor="#546E7A", + penwidth=1.5, constraint=false + ]; + reset_node -> singleton_cache [ + label=" clears ", + style=dashed, color="#546E7A", fontcolor="#546E7A", + penwidth=1.5, constraint=false + ]; + reset_node -> singleton_cred [ + label=" drops ", + style=dashed, color="#546E7A", fontcolor="#546E7A", + penwidth=1.5, constraint=false + ]; +} diff --git a/docs/auth-flow.png b/docs/auth-flow.png new file mode 100644 index 0000000..379ca8a Binary files /dev/null and b/docs/auth-flow.png differ diff --git a/docs/dispatch-circuit-breaker.dot b/docs/dispatch-circuit-breaker.dot index 88c5493..ac82b5b 100644 --- a/docs/dispatch-circuit-breaker.dot +++ b/docs/dispatch-circuit-breaker.dot @@ -173,14 +173,23 @@ digraph DispatchCircuitBreaker { fontcolor="#2E7D32", penwidth=1.5 ]; - check_client -> post [ + node [shape=box, style="rounded,filled", penwidth=1.5]; + + build_auth_header [ + label="build auth header\n─────────────────────────────────\nstrategy.headers()\ncache hit → Bearer (~0.002 ms)\nmiss → az refresh (~487–913 ms, ≤ once / ~67 min)\nSee docs/auth-flow.dot for cache detail.", + fillcolor="#EDE7F6", + color="#673AB7" + ]; + + check_client -> build_auth_header [ label=" No (reuse) ", color="#00838F", fontcolor="#00838F", penwidth=1.5 ]; - create_client -> post [penwidth=1.5]; + create_client -> build_auth_header [penwidth=1.5]; + build_auth_header -> post [penwidth=1.5, color="#673AB7"]; post -> check_result [penwidth=1.5]; check_result -> on_success [ diff --git a/docs/dispatch-circuit-breaker.png b/docs/dispatch-circuit-breaker.png new file mode 100644 index 0000000..a664e1c Binary files /dev/null and b/docs/dispatch-circuit-breaker.png differ diff --git a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/__init__.py b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/__init__.py index 271e24e..6b87c89 100644 --- a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/__init__.py +++ b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/__init__.py @@ -198,6 +198,8 @@ async def on_session_ready(coordinator: Any) -> None: failure_threshold=resolver.dispatch_failure_threshold, queue_capacity=resolver.dispatch_queue_capacity, close_drain_timeout=resolver.close_drain_timeout, + auth_mode=d.auth_mode, + auth_resource=d.auth_resource, ) for d in active.values() ] diff --git a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/config_resolver.py b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/config_resolver.py index 1524fff..53049d1 100644 --- a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/config_resolver.py +++ b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/config_resolver.py @@ -35,13 +35,17 @@ class Destination: """A single context-intelligence fan-out destination. - name: dict key in config['destinations']; identifier + merge key. - url: base URL (app already expanded ${VAR}). POSTs go to f"{url}/events". - api_key: bearer token (app already expanded ${VAR}). - include: pathspec (gitwildmatch) patterns. No default — a destination without - an explicit include has an empty pattern set and matches NOTHING. - Declare include explicitly to receive any sessions. - exclude: pathspec patterns; exclude-wins, per-destination (S3). Default []. + name: dict key in config['destinations']; identifier + merge key. + url: base URL (app already expanded ${VAR}). POSTs go to f"{url}/events". + api_key: bearer token (app already expanded ${VAR}). Required for auth_mode="static". + include: pathspec (gitwildmatch) patterns. No default — a destination without + an explicit include has an empty pattern set and matches NOTHING. + Declare include explicitly to receive any sessions. + exclude: pathspec patterns; exclude-wins, per-destination (S3). Default []. + auth_mode: ``"static"`` (default) — use api_key as bearer token. + ``"entra"`` — acquire a delegated Entra token via azure-identity. + auth_resource: Entra resource URI (e.g. ``api://``). Required for auth_mode="entra". + App already expanded any ${VAR} before mount. """ name: str @@ -49,6 +53,8 @@ class Destination: api_key: str include: tuple[str, ...] = () exclude: tuple[str, ...] = () + auth_mode: str = "static" + auth_resource: str = "" def _slugify_path(path_str: str) -> str: @@ -369,12 +375,16 @@ def destinations(self) -> dict[str, Destination]: api_key = str(spec.get("api_key", "") or "").strip() include = tuple(spec.get("include") or []) exclude = tuple(spec.get("exclude") or []) + auth_mode = str(spec.get("auth_mode", "static") or "static").strip() + auth_resource = str(spec.get("auth_resource", "") or "").strip() result[name] = Destination( name=name, url=url, api_key=api_key, include=include, exclude=exclude, + auth_mode=auth_mode, + auth_resource=auth_resource, ) self._destinations = result return self._destinations @@ -446,8 +456,11 @@ def blob_store_root(self) -> Path: def validate_destinations(self) -> dict[str, Destination]: """Validate and return all configured destinations. Fail-fast (C3). - After the app's ${VAR} expansion, a destination with an empty/missing - url OR api_key is a configuration ERROR, not a silent per-event drop. + Per-target XOR auth validation: + - auth_mode="static" (default): api_key must be non-empty. + - auth_mode="entra": auth_resource must be non-empty; api_key is not required. + - unknown auth_mode: always raises. + - url must always be non-empty. Raises: ValueError: naming the offending destination(s) and the empty field(s). @@ -459,12 +472,20 @@ def validate_destinations(self) -> dict[str, Destination]: for name, dest in dests.items(): if not dest.url: problems.append(f"{name}: missing url") - if not dest.api_key: - problems.append(f"{name}: missing api_key") + if dest.auth_mode == "static": + if not dest.api_key: + problems.append(f"{name}: missing api_key") + elif dest.auth_mode == "entra": + if not dest.auth_resource: + problems.append(f"{name}: missing auth_resource (required for auth_mode=entra)") + else: + problems.append( + f"{name}: unknown auth_mode {dest.auth_mode!r} (valid: 'static', 'entra')" + ) if problems: raise ValueError( f"context-intelligence destinations misconfigured: {', '.join(problems)}. " - f"Set url and api_key (or the expanded ${{VAR}}) under " + f"Set url and api_key (static) or auth_resource (entra) under " f"overrides.hook-context-intelligence.config.destinations.." ) return dests diff --git a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/handlers/logging_handler.py b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/handlers/logging_handler.py index 166b5e5..35b46fc 100644 --- a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/handlers/logging_handler.py +++ b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/handlers/logging_handler.py @@ -84,7 +84,11 @@ def __init__( failure_threshold: int, queue_capacity: int, close_drain_timeout: float, + auth_mode: str = "static", + auth_resource: str = "", ) -> None: + from context_intelligence.auth import AuthStrategy, build_auth_strategy # noqa: PLC0415 + self._name = name self._url = url.rstrip("/") self._api_key = api_key @@ -93,6 +97,12 @@ def __init__( self._failure_threshold = failure_threshold self._queue_capacity = queue_capacity self._close_drain_timeout = close_drain_timeout + # Build the auth strategy ONCE at init; credential SDK handles token refresh internally. + self._strategy: AuthStrategy = build_auth_strategy( + auth_mode=auth_mode, + api_key=api_key, + auth_resource=auth_resource, + ) self._client: httpx.AsyncClient | None = None self._queue: asyncio.Queue[tuple[str, dict[str, Any]]] = asyncio.Queue( maxsize=queue_capacity @@ -132,28 +142,34 @@ async def _worker(self) -> None: self._queue.task_done() async def _post(self, event: str, data: dict[str, Any]) -> None: - """POST one event to this destination. Circuit-breaker per-destination.""" + """POST one event to this destination. Circuit-breaker per-destination. + + The Authorization header is produced PER REQUEST via self._strategy.headers(). + This ensures Entra tokens are refreshed by the azure-identity SDK when they + near expiry — long-lived dispatchers never serve stale tokens. + """ if not self._enabled: return - # Lazy client creation + # Lazy client creation — no auth header baked in; header goes on each post. if self._client is None or self._client.is_closed: - client_kwargs: dict[str, Any] = { - "timeout": httpx.Timeout( + self._client = httpx.AsyncClient( + timeout=httpx.Timeout( connect=_CONNECT_TIMEOUT, write=self._dispatch_timeout, read=_READ_TIMEOUT, pool=_POOL_TIMEOUT, ), - "limits": httpx.Limits(max_connections=1, max_keepalive_connections=1), - } - if self._api_key: - client_kwargs["headers"] = {"Authorization": f"Bearer {self._api_key}"} - self._client = httpx.AsyncClient(**client_kwargs) + limits=httpx.Limits(max_connections=1, max_keepalive_connections=1), + ) try: payload = build_payload(event, self._workspace, data) - response = await self._client.post(f"{self._url}/events", json=payload) + # Per-request header: Entra SDK returns cached token and refreshes near expiry. + auth_headers = self._strategy.headers() + response = await self._client.post( + f"{self._url}/events", json=payload, headers=auth_headers + ) response.raise_for_status() self._consecutive_failures = 0 except RuntimeError as exc: diff --git a/modules/hook-context-intelligence/tests/conftest.py b/modules/hook-context-intelligence/tests/conftest.py index 56af28d..5978edd 100644 --- a/modules/hook-context-intelligence/tests/conftest.py +++ b/modules/hook-context-intelligence/tests/conftest.py @@ -2,7 +2,26 @@ from __future__ import annotations +from typing import Any + +import pytest + # Neo4j fixtures, HookStateService, and reference graph helpers have been # removed — all graph-creation code is now server-side. # This file is intentionally minimal: individual test modules provide their # own fixtures via local helpers or pytest tmp_path. + + +@pytest.fixture(autouse=True) +def _reset_auth_singleton() -> Any: + """Clear the auth module singleton and token cache before/after each test. + + Ensures the process-level _singleton_credential and _MODULE_CACHE do not + leak between tests, so patches of _make_cli_credential are effective and + cached tokens from one test don't pollute the next. + """ + from context_intelligence import auth as _auth_mod + + _auth_mod.reset() + yield + _auth_mod.reset() diff --git a/modules/hook-context-intelligence/tests/test_hook_auth.py b/modules/hook-context-intelligence/tests/test_hook_auth.py new file mode 100644 index 0000000..77ade41 --- /dev/null +++ b/modules/hook-context-intelligence/tests/test_hook_auth.py @@ -0,0 +1,358 @@ +"""Tests for hook dual-auth (slice 2-C). + +Covers: +- Destination dataclass gains auth_mode / auth_resource fields +- validate_destinations() per-target XOR: entra→auth_resource, static→api_key +- _DestinationDispatcher uses auth strategy per-request (not baked into client) +- Entra dispatcher calls strategy.headers() on each post, not at construction +- Static dispatcher backwards-compatible (api_key bearer) +- Mixed fleet: one static + one entra destination, each validated independently +""" + +from __future__ import annotations + +import asyncio +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _resolver(config: dict) -> object: + from amplifier_module_hook_context_intelligence.config_resolver import HookConfigResolver + + coord = MagicMock() + coord.config = {} + coord.get_capability = MagicMock(return_value=None) + return HookConfigResolver(config, coord) + + +class FakeToken: + # expires_on far future so cached tokens are never considered stale in tests + def __init__(self, token: str, expires_on: float = 9_999_999_999.0) -> None: + self.token = token + self.expires_on = expires_on + + +class FakeCredential: + def __init__(self, token: str = "fake-entra-token") -> None: + self._token = token + self.calls: list[tuple[Any, ...]] = [] + + def get_token(self, *scopes: str, **kwargs: Any) -> FakeToken: + self.calls.append(scopes) + return FakeToken(self._token) + + +# --------------------------------------------------------------------------- +# Destination dataclass has auth_mode / auth_resource +# --------------------------------------------------------------------------- + + +class TestDestinationDataclass: + """Destination has auth_mode / auth_resource fields.""" + + def test_default_auth_mode_is_static(self) -> None: + r = _resolver({ + "destinations": { + "team": {"url": "http://ci:8000", "api_key": "sk-key", "include": ["**"]}, + } + }) + d = r.destinations["team"] # type: ignore[attr-defined] + assert d.auth_mode == "static" + + def test_default_auth_resource_is_empty(self) -> None: + r = _resolver({ + "destinations": { + "team": {"url": "http://ci:8000", "api_key": "sk-key", "include": ["**"]}, + } + }) + d = r.destinations["team"] # type: ignore[attr-defined] + assert d.auth_resource == "" + + def test_entra_dest_stores_auth_resource(self) -> None: + r = _resolver({ + "destinations": { + "azure": { + "url": "http://ci:8000", + "auth_mode": "entra", + "auth_resource": "api://53aa4ffd", + "include": ["**"], + }, + } + }) + d = r.destinations["azure"] # type: ignore[attr-defined] + assert d.auth_mode == "entra" + assert d.auth_resource == "api://53aa4ffd" + + +# --------------------------------------------------------------------------- +# validate_destinations() XOR validation +# --------------------------------------------------------------------------- + + +class TestValidateDestinationsXOR: + """Per-target XOR: entra requires auth_resource, static requires api_key.""" + + def test_static_valid_passes(self) -> None: + r = _resolver({ + "destinations": { + "local": {"url": "http://ci:8000", "api_key": "sk", "include": ["**"]}, + } + }) + result = r.validate_destinations() # type: ignore[attr-defined] + assert "local" in result + + def test_entra_valid_passes(self) -> None: + r = _resolver({ + "destinations": { + "azure": { + "url": "http://ci:8000", + "auth_mode": "entra", + "auth_resource": "api://53aa4ffd", + "include": ["**"], + }, + } + }) + result = r.validate_destinations() # type: ignore[attr-defined] + assert "azure" in result + + def test_entra_missing_auth_resource_raises(self) -> None: + r = _resolver({ + "destinations": { + "azure": { + "url": "http://ci:8000", + "auth_mode": "entra", + # no auth_resource + "include": ["**"], + }, + } + }) + with pytest.raises(ValueError, match="azure.*missing auth_resource"): + r.validate_destinations() # type: ignore[attr-defined] + + def test_entra_does_not_require_api_key(self) -> None: + """Entra mode with valid auth_resource and no api_key must NOT raise.""" + r = _resolver({ + "destinations": { + "azure": { + "url": "http://ci:8000", + "auth_mode": "entra", + "auth_resource": "api://53aa4ffd", + # no api_key — must be OK + "include": ["**"], + }, + } + }) + result = r.validate_destinations() # type: ignore[attr-defined] + assert "azure" in result + + def test_unknown_auth_mode_raises(self) -> None: + r = _resolver({ + "destinations": { + "weird": { + "url": "http://ci:8000", + "auth_mode": "kerberos", + "api_key": "k", + "include": ["**"], + }, + } + }) + with pytest.raises(ValueError, match="kerberos"): + r.validate_destinations() # type: ignore[attr-defined] + + def test_mixed_fleet_valid(self) -> None: + """Static + entra destinations coexist; each validates independently.""" + r = _resolver({ + "destinations": { + "local": {"url": "http://local:8000", "api_key": "sk", "include": ["local/**"]}, + "azure": { + "url": "http://azure:8000", + "auth_mode": "entra", + "auth_resource": "api://53aa4ffd", + "include": ["**"], + }, + } + }) + result = r.validate_destinations() # type: ignore[attr-defined] + assert set(result.keys()) == {"local", "azure"} + + def test_mixed_fleet_entra_invalid_raises(self) -> None: + """Mixed fleet raises if entra dest is missing auth_resource.""" + r = _resolver({ + "destinations": { + "local": {"url": "http://local:8000", "api_key": "sk", "include": ["**"]}, + "azure": { + "url": "http://azure:8000", + "auth_mode": "entra", + # missing auth_resource + "include": ["**"], + }, + } + }) + with pytest.raises(ValueError, match="azure"): + r.validate_destinations() # type: ignore[attr-defined] + + +# --------------------------------------------------------------------------- +# _DestinationDispatcher uses strategy per-request (not baked into client) +# --------------------------------------------------------------------------- + + +def _make_dispatcher( + *, + auth_mode: str = "static", + api_key: str = "static-key", + auth_resource: str = "", + credential: Any = None, +) -> object: + """Build a _DestinationDispatcher with injected credential for entra or static key.""" + from amplifier_module_hook_context_intelligence.handlers.logging_handler import ( + _DestinationDispatcher, + ) + from context_intelligence.auth import build_auth_strategy + + strategy = build_auth_strategy( + auth_mode=auth_mode, + api_key=api_key, + auth_resource=auth_resource, + credential=credential, + ) + # The import inside __init__ is a local import so we patch at the source: + # context_intelligence.auth.build_auth_strategy is the canonical location. + with patch("context_intelligence.auth.build_auth_strategy", return_value=strategy): + d = _DestinationDispatcher( + name="test", + url="http://localhost:38000", + api_key=api_key, + workspace="ws", + dispatch_timeout=10.0, + failure_threshold=3, + queue_capacity=256, + close_drain_timeout=0.5, + auth_mode=auth_mode, + auth_resource=auth_resource, + ) + return d + + +class TestDispatcherEntraPerRequestHeader: + """_DestinationDispatcher calls strategy.headers() per-request, not at client construction.""" + + async def test_entra_header_passed_per_post(self) -> None: + """Each _post call passes the header from strategy.headers() to client.post.""" + fake_cred = FakeCredential("entra-bearer-abc") + d = _make_dispatcher(auth_mode="entra", auth_resource="api://53aa4ffd", credential=fake_cred) + + mock_client = AsyncMock() + mock_client.is_closed = False + mock_client.post.return_value = MagicMock(raise_for_status=MagicMock()) + d._client = mock_client # type: ignore[attr-defined] + + await d._post("session:start", {"session_id": "s1"}) # type: ignore[attr-defined] + + # post must have been called with the entra header + _, call_kwargs = mock_client.post.call_args + assert call_kwargs["headers"] == {"Authorization": "Bearer entra-bearer-abc"} + + async def test_static_header_passed_per_post(self) -> None: + """Static _post also passes headers on each call.""" + d = _make_dispatcher(auth_mode="static", api_key="sk-static") + + mock_client = AsyncMock() + mock_client.is_closed = False + mock_client.post.return_value = MagicMock(raise_for_status=MagicMock()) + d._client = mock_client # type: ignore[attr-defined] + + await d._post("session:start", {"session_id": "s1"}) # type: ignore[attr-defined] + + _, call_kwargs = mock_client.post.call_args + assert call_kwargs["headers"] == {"Authorization": "Bearer sk-static"} + + async def test_entra_client_has_no_baked_auth_header(self) -> None: + """The lazy client created by _post has NO Authorization header at construction.""" + from amplifier_module_hook_context_intelligence.handlers.logging_handler import ( + _DestinationDispatcher, + ) + + fake_cred = FakeCredential("some-token") + d = _make_dispatcher(auth_mode="entra", auth_resource="api://53aa4ffd", credential=fake_cred) + + mock_response = MagicMock(raise_for_status=MagicMock()) + + with patch("httpx.AsyncClient") as mock_cls: + mock_instance = AsyncMock() + mock_instance.is_closed = False + mock_instance.post.return_value = mock_response + mock_cls.return_value = mock_instance + + await d._post("session:start", {"session_id": "s1"}) # type: ignore[attr-defined] + + # Check the AsyncClient() constructor was called WITHOUT headers kwarg + _, client_kwargs = mock_cls.call_args + assert "headers" not in client_kwargs, ( + "Authorization must NOT be baked into the httpx.AsyncClient at construction — " + "it must be passed per-request so Entra tokens can refresh." + ) + + async def test_entra_headers_called_each_post_not_once(self) -> None: + """strategy.headers() is called once per _post call, so token can refresh.""" + from context_intelligence.auth import EntraTokenAuth + + fake_cred = FakeCredential("rotating-token") + strategy = EntraTokenAuth(fake_cred, "api://53aa4ffd") + + with patch("context_intelligence.auth.build_auth_strategy", return_value=strategy): + from amplifier_module_hook_context_intelligence.handlers.logging_handler import ( + _DestinationDispatcher, + ) + d = _DestinationDispatcher( + name="t", url="http://h:8000", api_key="", workspace="ws", + dispatch_timeout=10.0, failure_threshold=3, queue_capacity=256, + close_drain_timeout=0.5, auth_mode="entra", auth_resource="api://53aa4ffd", + ) + + mock_client = AsyncMock() + mock_client.is_closed = False + mock_client.post.return_value = MagicMock(raise_for_status=MagicMock()) + d._client = mock_client + + # Post 3 events + for i in range(3): + await d._post(f"event:{i}", {"session_id": "s1"}) + + # strategy.headers() is called once per post (not baked at construction time), + # but the in-process cache means get_token is only called on the FIRST miss. + # Subsequent posts serve the cached token — expected call count is 1, not 3. + assert len(fake_cred.calls) == 1, ( + f"Expected 1 get_token call (cached after first), got {len(fake_cred.calls)}" + ) + + +# --------------------------------------------------------------------------- +# Backward compat: existing static dispatcher tests still pass +# --------------------------------------------------------------------------- + + +class TestDispatcherStaticBackwardCompat: + """Static dispatcher with api_key still works exactly as before.""" + + async def test_static_dispatcher_succeeds(self) -> None: + d = _make_dispatcher(auth_mode="static", api_key="sk-backward-compat") + + mock_client = AsyncMock() + mock_client.is_closed = False + mock_response = MagicMock(raise_for_status=MagicMock()) + mock_client.post.return_value = mock_response + d._client = mock_client # type: ignore[attr-defined] + + await d._post("session:start", {"session_id": "s1"}) # type: ignore[attr-defined] + + mock_client.post.assert_awaited_once() + _, kwargs = mock_client.post.call_args + assert kwargs["headers"]["Authorization"] == "Bearer sk-backward-compat" diff --git a/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/__init__.py b/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/__init__.py index d39298f..bcfaf91 100644 --- a/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/__init__.py +++ b/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/__init__.py @@ -39,6 +39,7 @@ async def mount(coordinator: Any, config: Any) -> None: from .graph_query_tool import GraphQueryTool resolver = ToolConfigResolver(config or {}, coordinator) # built ONCE + resolver.validate_sources() # fail-loud on misconfigured sources (mirrors hook validate_destinations) gq = GraphQueryTool(coordinator, resolver) coordinator.register_capability(_GRAPH_QUERY_TOOL_CAPABILITY, gq) br = BlobReadTool(coordinator, resolver) diff --git a/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/blob_read_tool.py b/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/blob_read_tool.py index 9572684..d0ffa89 100644 --- a/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/blob_read_tool.py +++ b/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/blob_read_tool.py @@ -23,7 +23,11 @@ from amplifier_core import ToolResult from context_intelligence.client import AsyncCIClient -from context_intelligence.tool_resolver import ToolConfigResolver, resolve_query_endpoint +from context_intelligence.tool_resolver import ( + ToolConfigResolver, + resolve_query_auth_strategy, + resolve_query_endpoint, +) _URI_SCHEME = "ci-blob://" _BLOB_DIR = Path("/tmp/ci-blobs") @@ -73,8 +77,11 @@ async def execute(self, input: dict[str, Any]) -> ToolResult: # noqa: A002 "context_intelligence.hook_config_resolver" ) - # (2) Resolve server_url + api_key via three-tier chain + # (2) Resolve server_url + api_key + auth strategy via three-tier chain server_url, api_key = resolve_query_endpoint(self._hook_resolver, self._tool_resolver) + auth_strategy = resolve_query_auth_strategy( + self._hook_resolver, self._tool_resolver, api_key=api_key or "" + ) if not server_url: return ToolResult( success=False, @@ -112,8 +119,10 @@ async def execute(self, input: dict[str, Any]) -> ToolResult: # noqa: A002 safe_session_id = _sanitize_path_component(session_id) safe_key = _sanitize_path_component(key) - # (5) Construct AsyncCIClient - async_client = AsyncCIClient(server_url=server_url, api_key=api_key or "") + # (5) Construct AsyncCIClient with auth strategy + async_client = AsyncCIClient( + server_url=server_url, api_key=api_key or "", auth_strategy=auth_strategy + ) # (6) Fetch blob using original unsanitized values for the server request data = await async_client.fetch_blob(session_id, key) diff --git a/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/graph_query_tool.py b/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/graph_query_tool.py index 85a65db..5763b5b 100644 --- a/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/graph_query_tool.py +++ b/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/graph_query_tool.py @@ -20,7 +20,11 @@ from typing import Any from context_intelligence.client import AsyncCIClient -from context_intelligence.tool_resolver import ToolConfigResolver, resolve_query_endpoint +from context_intelligence.tool_resolver import ( + ToolConfigResolver, + resolve_query_auth_strategy, + resolve_query_endpoint, +) from amplifier_core.models import ToolResult @@ -96,8 +100,8 @@ def skill_sync_enabled(self) -> bool: """ return self._tool_resolver.skill_sync_enabled - def _resolve_server_config(self, coordinator: Any) -> tuple[str | None, str | None, str]: - """Resolve (server_url, api_key, workspace) using the three-tier fallback chain. + def _resolve_server_config(self, coordinator: Any) -> tuple[str | None, str | None, str, Any]: + """Resolve (server_url, api_key, workspace, auth_strategy) using the three-tier fallback chain. Late-mount upgrade: retries hook capability lookup on every call while _hook_resolver is None (hook may mount after the tool). @@ -107,15 +111,20 @@ def _resolve_server_config(self, coordinator: Any) -> tuple[str | None, str | No "context_intelligence.hook_config_resolver" ) url, api_key = resolve_query_endpoint(self._hook_resolver, self._tool_resolver) + auth_strategy = resolve_query_auth_strategy( + self._hook_resolver, self._tool_resolver, api_key=api_key or "" + ) workspace = ( self._hook_resolver.workspace if self._hook_resolver is not None else self._tool_resolver.workspace ) - return url, api_key, workspace + return url, api_key, workspace, auth_strategy async def execute(self, input: dict[str, Any]) -> ToolResult: # noqa: A002 - server_url, api_key, workspace = self._resolve_server_config(self._coordinator) + server_url, api_key, workspace, auth_strategy = self._resolve_server_config( + self._coordinator + ) if not server_url: return ToolResult( @@ -144,6 +153,8 @@ async def execute(self, input: dict[str, Any]) -> ToolResult: # noqa: A002 else: params = raw_params - async_client = AsyncCIClient(server_url=server_url, api_key=api_key or "") + async_client = AsyncCIClient( + server_url=server_url, api_key=api_key or "", auth_strategy=auth_strategy + ) result = await async_client.cypher(query, effective_workspace, params=params) return ToolResult(success=True, output=result) diff --git a/modules/tool-context-intelligence-query/tests/conftest.py b/modules/tool-context-intelligence-query/tests/conftest.py new file mode 100644 index 0000000..88ac9a4 --- /dev/null +++ b/modules/tool-context-intelligence-query/tests/conftest.py @@ -0,0 +1,22 @@ +"""Shared pytest configuration and fixtures for tool-context-intelligence-query tests.""" + +from __future__ import annotations + +from typing import Any + +import pytest + + +@pytest.fixture(autouse=True) +def _reset_auth_singleton() -> Any: + """Clear the auth module singleton and token cache before/after each test. + + Ensures the process-level _singleton_credential and _MODULE_CACHE do not + leak between tests, so patches of _make_cli_credential are effective and + cached tokens from one test don't pollute the next. + """ + from context_intelligence import auth as _auth_mod + + _auth_mod.reset() + yield + _auth_mod.reset() diff --git a/modules/tool-context-intelligence-query/tests/test_blob_read_tool.py b/modules/tool-context-intelligence-query/tests/test_blob_read_tool.py index 7a27146..e130630 100644 --- a/modules/tool-context-intelligence-query/tests/test_blob_read_tool.py +++ b/modules/tool-context-intelligence-query/tests/test_blob_read_tool.py @@ -422,7 +422,10 @@ async def test_api_key_passed_to_async_ci_client(self) -> None: with _patch_async_client(fetch_blob_return={"ok": True}) as (mock_cls, _): await tool.execute({"uri": "ci-blob://my-session/my-key"}) - mock_cls.assert_called_once_with(server_url="http://localhost:8080", api_key="my-secret") + _, call_kwargs = mock_cls.call_args + assert call_kwargs["server_url"] == "http://localhost:8080" + assert call_kwargs["api_key"] == "my-secret" + assert "auth_strategy" in call_kwargs # strategy is now always passed async def test_none_api_key_passes_empty_string(self) -> None: """When api_key is None the AsyncCIClient must receive an empty string.""" @@ -434,7 +437,10 @@ async def test_none_api_key_passes_empty_string(self) -> None: with _patch_async_client(fetch_blob_return={"ok": True}) as (mock_cls, _): await tool.execute({"uri": "ci-blob://my-session/my-key"}) - mock_cls.assert_called_once_with(server_url="http://localhost:8080", api_key="") + _, call_kwargs = mock_cls.call_args + assert call_kwargs["server_url"] == "http://localhost:8080" + assert call_kwargs["api_key"] == "" + assert "auth_strategy" in call_kwargs # strategy is now always passed # --------------------------------------------------------------------------- diff --git a/modules/tool-context-intelligence-query/tests/test_query_auth.py b/modules/tool-context-intelligence-query/tests/test_query_auth.py new file mode 100644 index 0000000..1698ce3 --- /dev/null +++ b/modules/tool-context-intelligence-query/tests/test_query_auth.py @@ -0,0 +1,331 @@ +"""Tests for query-tool dual-auth (slice 2-D). + +Covers: +- Source dataclass gains auth_mode / auth_resource fields +- ToolConfigResolver.sources parses and _expand()s auth fields +- ToolConfigResolver.validate_sources() XOR validation +- resolve_query_auth_strategy returns ApiKeyAuth for static, EntraTokenAuth for entra +- AsyncCIClient uses strategy.headers() per-request +- graph_query_tool and blob_read_tool pass auth_strategy to AsyncCIClient +- Per-target XOR (static source coexists with entra source) +""" + +from __future__ import annotations + +import os +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class FakeToken: + # expires_on far future so cached tokens are never considered stale in tests + def __init__(self, token: str, expires_on: float = 9_999_999_999.0) -> None: + self.token = token + self.expires_on = expires_on + + +class FakeCredential: + def __init__(self, token: str = "entra-token") -> None: + self._token = token + self.calls: list[tuple[Any, ...]] = [] + + def get_token(self, *scopes: str, **kwargs: Any) -> FakeToken: + self.calls.append(scopes) + return FakeToken(self._token) + + +def _tool_resolver(config: dict) -> object: + from context_intelligence.tool_resolver import ToolConfigResolver + + coord = MagicMock() + coord.config = {} + return ToolConfigResolver(config, coord) + + +# --------------------------------------------------------------------------- +# Source dataclass +# --------------------------------------------------------------------------- + + +class TestSourceDataclass: + """Source has auth_mode / auth_resource fields.""" + + def test_default_auth_mode_is_static(self) -> None: + r = _tool_resolver({"sources": {"local": {"url": "http://ci:8000", "api_key": "sk"}}}) + src = r.sources["local"] # type: ignore[attr-defined] + assert src.auth_mode == "static" + + def test_default_auth_resource_is_empty(self) -> None: + r = _tool_resolver({"sources": {"local": {"url": "http://ci:8000", "api_key": "sk"}}}) + src = r.sources["local"] # type: ignore[attr-defined] + assert src.auth_resource == "" + + def test_entra_source_stores_auth_resource(self) -> None: + r = _tool_resolver({ + "sources": { + "azure": { + "url": "http://ci:8000", + "auth_mode": "entra", + "auth_resource": "api://53aa4ffd", + } + } + }) + src = r.sources["azure"] # type: ignore[attr-defined] + assert src.auth_mode == "entra" + assert src.auth_resource == "api://53aa4ffd" + + +# --------------------------------------------------------------------------- +# _expand() applied to auth fields in sources +# --------------------------------------------------------------------------- + + +class TestSourceAuthFieldExpansion: + """`_expand()` (i.e. _expand_env_placeholders) is applied to auth fields.""" + + def test_auth_resource_placeholder_expanded(self) -> None: + r = _tool_resolver({ + "sources": { + "azure": { + "url": "http://ci:8000", + "auth_mode": "entra", + "auth_resource": "${MY_CI_RESOURCE_QTEST}", + } + } + }) + with patch.dict(os.environ, {"MY_CI_RESOURCE_QTEST": "api://abc-123"}, clear=False): + r._sources = None # type: ignore[attr-defined] + src = r.sources["azure"] # type: ignore[attr-defined] + assert src.auth_resource == "api://abc-123" + + def test_auth_resource_with_default_unset_uses_default(self) -> None: + r = _tool_resolver({ + "sources": { + "azure": { + "url": "http://ci:8000", + "auth_mode": "entra", + "auth_resource": "${_UNSET_QTEST:api://fallback}", + } + } + }) + env = {k: v for k, v in os.environ.items() if k != "_UNSET_QTEST"} + with patch.dict(os.environ, env, clear=True): + r._sources = None # type: ignore[attr-defined] + src = r.sources["azure"] # type: ignore[attr-defined] + assert src.auth_resource == "api://fallback" + + +# --------------------------------------------------------------------------- +# validate_sources() XOR validation +# --------------------------------------------------------------------------- + + +class TestValidateSourcesXOR: + """Per-source XOR: entra requires auth_resource, static requires api_key.""" + + def test_static_valid_passes(self) -> None: + r = _tool_resolver({"sources": {"local": {"url": "http://ci:8000", "api_key": "sk"}}}) + result = r.validate_sources() # type: ignore[attr-defined] + assert "local" in result + + def test_entra_valid_passes(self) -> None: + r = _tool_resolver({ + "sources": { + "azure": { + "url": "http://ci:8000", + "auth_mode": "entra", + "auth_resource": "api://53aa4ffd", + } + } + }) + result = r.validate_sources() # type: ignore[attr-defined] + assert "azure" in result + + def test_entra_missing_auth_resource_raises(self) -> None: + r = _tool_resolver({ + "sources": { + "azure": {"url": "http://ci:8000", "auth_mode": "entra"}, + } + }) + with pytest.raises(ValueError, match="azure.*missing auth_resource"): + r.validate_sources() # type: ignore[attr-defined] + + def test_entra_does_not_require_api_key(self) -> None: + r = _tool_resolver({ + "sources": { + "azure": { + "url": "http://ci:8000", + "auth_mode": "entra", + "auth_resource": "api://53aa4ffd", + # no api_key — must be OK + } + } + }) + result = r.validate_sources() # type: ignore[attr-defined] + assert "azure" in result + + def test_unknown_auth_mode_raises(self) -> None: + r = _tool_resolver({ + "sources": { + "weird": {"url": "http://ci:8000", "auth_mode": "kerberos", "api_key": "k"}, + } + }) + with pytest.raises(ValueError, match="kerberos"): + r.validate_sources() # type: ignore[attr-defined] + + def test_empty_sources_passes_with_no_error(self) -> None: + """Empty sources (no explicit read-config) is valid — fallback to hook/env.""" + r = _tool_resolver({}) + result = r.validate_sources() # type: ignore[attr-defined] + assert result == {} + + def test_mixed_sources_validate_independently(self) -> None: + r = _tool_resolver({ + "sources": { + "local": {"url": "http://local:8000", "api_key": "sk"}, + "azure": { + "url": "http://azure:8000", + "auth_mode": "entra", + "auth_resource": "api://53aa4ffd", + }, + } + }) + result = r.validate_sources() # type: ignore[attr-defined] + assert set(result.keys()) == {"local", "azure"} + + +# --------------------------------------------------------------------------- +# resolve_query_auth_strategy +# --------------------------------------------------------------------------- + + +class TestResolveQueryAuthStrategy: + """resolve_query_auth_strategy returns the right strategy.""" + + def test_static_source_returns_api_key_auth(self) -> None: + from context_intelligence.auth import ApiKeyAuth + from context_intelligence.tool_resolver import resolve_query_auth_strategy + + r = _tool_resolver({"sources": {"local": {"url": "http://ci:8000", "api_key": "sk"}}}) + strategy = resolve_query_auth_strategy(None, r, api_key="my-key") # type: ignore[arg-type] + assert isinstance(strategy, ApiKeyAuth) + assert strategy.headers() == {"Authorization": "Bearer my-key"} + + def test_entra_source_returns_entra_token_auth(self) -> None: + from context_intelligence.auth import EntraTokenAuth + from context_intelligence.tool_resolver import resolve_query_auth_strategy + + fake_cred = FakeCredential("entra-query-token") + r = _tool_resolver({ + "sources": { + "azure": { + "url": "http://ci:8000", + "auth_mode": "entra", + "auth_resource": "api://53aa4ffd", + } + } + }) + with patch("context_intelligence.auth._make_cli_credential", return_value=fake_cred): + strategy = resolve_query_auth_strategy(None, r, api_key="") # type: ignore[arg-type] + + assert isinstance(strategy, EntraTokenAuth) + headers = strategy.headers() + assert headers == {"Authorization": "Bearer entra-query-token"} + assert fake_cred.calls[0] == ("api://53aa4ffd/.default",) + + def test_no_source_falls_back_to_api_key_auth(self) -> None: + """When no sources configured, returns ApiKeyAuth from the api_key param.""" + from context_intelligence.auth import ApiKeyAuth + from context_intelligence.tool_resolver import resolve_query_auth_strategy + + r = _tool_resolver({}) # no sources + strategy = resolve_query_auth_strategy(None, r, api_key="fallback-key") # type: ignore[arg-type] + assert isinstance(strategy, ApiKeyAuth) + assert strategy.headers() == {"Authorization": "Bearer fallback-key"} + + def test_hook_dest_entra_used_when_no_source(self) -> None: + """When tool has no sources, falls back to hook destination auth_mode.""" + from context_intelligence.auth import EntraTokenAuth + from context_intelligence.tool_resolver import resolve_query_auth_strategy + + # Simulate a hook resolver with an entra destination + fake_cred = FakeCredential("hook-entra-token") + mock_dest = MagicMock() + mock_dest.auth_mode = "entra" + mock_dest.auth_resource = "api://hook-resource" + mock_dest.url = "http://hook:8000" + mock_dest.api_key = "" + + mock_hook = MagicMock() + mock_hook.destinations = {"azure": mock_dest} + + r = _tool_resolver({}) # no sources + with patch("context_intelligence.auth._make_cli_credential", return_value=fake_cred): + strategy = resolve_query_auth_strategy(mock_hook, r, api_key="") # type: ignore[arg-type] + + assert isinstance(strategy, EntraTokenAuth) + headers = strategy.headers() + assert headers["Authorization"].startswith("Bearer hook-entra-token") + + +# --------------------------------------------------------------------------- +# AsyncCIClient uses strategy per-request +# --------------------------------------------------------------------------- + + +class TestAsyncCIClientAuthStrategy: + """AsyncCIClient.cypher / fetch_blob / list_blob_keys use strategy.headers() per-call.""" + + async def test_entra_strategy_headers_per_cypher_call(self) -> None: + from context_intelligence.auth import EntraTokenAuth + from context_intelligence.client import AsyncCIClient + + fake_cred = FakeCredential("cypher-token") + strategy = EntraTokenAuth(fake_cred, "api://53aa4ffd") + + client = AsyncCIClient(server_url="http://ci:8000", auth_strategy=strategy) + + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = [{"n": 1}] + + with patch("httpx.AsyncClient") as mock_cls: + mock_httpx = AsyncMock() + mock_httpx.__aenter__ = AsyncMock(return_value=mock_httpx) + mock_httpx.__aexit__ = AsyncMock(return_value=False) + mock_httpx.post.return_value = mock_response + mock_cls.return_value = mock_httpx + + await client.cypher("MATCH (n) RETURN n") + + _, call_kwargs = mock_httpx.post.call_args + assert call_kwargs["headers"] == {"Authorization": "Bearer cypher-token"} + + async def test_static_strategy_backward_compat(self) -> None: + """AsyncCIClient(api_key=...) without auth_strategy still works.""" + from context_intelligence.client import AsyncCIClient + + client = AsyncCIClient(server_url="http://ci:8000", api_key="static-key") + + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = [] + + with patch("httpx.AsyncClient") as mock_cls: + mock_httpx = AsyncMock() + mock_httpx.__aenter__ = AsyncMock(return_value=mock_httpx) + mock_httpx.__aexit__ = AsyncMock(return_value=False) + mock_httpx.post.return_value = mock_response + mock_cls.return_value = mock_httpx + + await client.cypher("MATCH (n) RETURN n") + + _, call_kwargs = mock_httpx.post.call_args + assert call_kwargs["headers"] == {"Authorization": "Bearer static-key"} diff --git a/modules/tool-context-intelligence-upload/README.md b/modules/tool-context-intelligence-upload/README.md index c6455c5..2e4385e 100644 --- a/modules/tool-context-intelligence-upload/README.md +++ b/modules/tool-context-intelligence-upload/README.md @@ -82,6 +82,35 @@ context-intelligence-upload \ --job-id my-retry-job-001 ``` +### Authentication + +By default the tool authenticates with a **static API key** (`--api-key`) — unchanged. It can instead use a **Microsoft Entra bearer token** from your developer `az login` session by selecting `--auth-mode entra`. + +| Flag | Required | Description | +|------|----------|-------------| +| `--auth-mode {static,entra}` | optional (default `static`) | `static` sends `--api-key` as the bearer. `entra` obtains a Microsoft Entra bearer token from your `az login` session. | +| `--auth-resource api://` | **required when `--auth-mode entra`** | The Entra audience the token is requested for — `api://`. | + +Both can also be supplied via environment variables (both are `${VAR}`-substitutable): + +| Env var | Equivalent flag | +|---------|-----------------| +| `AMPLIFIER_CONTEXT_INTELLIGENCE_AUTH_MODE` | `--auth-mode` | +| `AMPLIFIER_CONTEXT_INTELLIGENCE_AUTH_RESOURCE` | `--auth-resource` | + +**Replay with an Entra bearer token:** + +```bash +context-intelligence-upload --server-url https://ci.example.com \ + --auth-mode entra --auth-resource api:// +``` + +> **Scope of Entra mode in this version.** Entra mode provides **parity with your existing `az login` identity** — it uses your developer `az login` session to obtain the bearer token. It is the **interactive-login** path, not a full enterprise non-interactive auth system. +> +> **Non-interactive environments are not yet served by Entra mode.** CI/CD pipelines and cloud-hosted services that cannot run `az login` should keep using a static `--api-key` for now — if you are scripting this in a pipeline, use `--auth-mode static` to avoid surprises. Non-interactive credential support (managed identity / OIDC / service principal) is a planned follow-up. +> +> **Server-side prerequisite.** Entra mode requires the **server** to be configured to validate Entra tokens. Against a server that only accepts static keys, use `--auth-mode static`. + --- ## Amplifier Session Usage diff --git a/modules/tool-context-intelligence-upload/amplifier_module_tool_context_intelligence_upload/cli.py b/modules/tool-context-intelligence-upload/amplifier_module_tool_context_intelligence_upload/cli.py index f4a9380..2853796 100644 --- a/modules/tool-context-intelligence-upload/amplifier_module_tool_context_intelligence_upload/cli.py +++ b/modules/tool-context-intelligence-upload/amplifier_module_tool_context_intelligence_upload/cli.py @@ -330,6 +330,29 @@ def _build_parser() -> argparse.ArgumentParser: help="Milliseconds to sleep between events (default: 0; use 50-200 to reduce Neo4j pressure)", ) + # Auth flags + parser.add_argument( + "--auth-mode", + choices=["static", "entra"], + default="static", + dest="auth_mode", + help=( + "Authentication mode: 'static' (default) uses --api-key; " + "'entra' acquires a delegated token via 'az login' (AzureCliCredential)." + ), + ) + parser.add_argument( + "--auth-resource", + default=None, + metavar="RESOURCE", + dest="auth_resource", + help=( + "Entra resource URI, e.g. 'api://'. " + "Required when --auth-mode entra. " + "Also read from AMPLIFIER_CONTEXT_INTELLIGENCE_AUTH_RESOURCE." + ), + ) + return parser @@ -340,15 +363,46 @@ def _build_parser() -> argparse.ArgumentParser: def main() -> None: """CLI entry point — synchronous, exits with an appropriate code.""" + import os + parser = _build_parser() args = parser.parse_args() - # 0. Resolve server config — CLI flags > env vars > settings.yaml + # 0a. Resolve auth mode / resource — CLI flags > env vars + # Apply _expand_env_placeholders so ${VAR} in settings / env-var values expands correctly. + from context_intelligence.config import _expand_env_placeholders + + auth_mode: str = args.auth_mode or os.environ.get( + "AMPLIFIER_CONTEXT_INTELLIGENCE_AUTH_MODE", "static" + ) + auth_resource: str = _expand_env_placeholders( + args.auth_resource + or os.environ.get("AMPLIFIER_CONTEXT_INTELLIGENCE_AUTH_RESOURCE", "") + or "" + ) + + # 0b. Resolve server config — CLI flags > env vars > settings.yaml from context_intelligence.config import resolve_config server_url, api_key = resolve_config( server_url=args.server_url, api_key=args.api_key, + auth_mode=auth_mode, + ) + # Apply placeholder expansion to the resolved URL and key as well (for consistency: + # a settings.yaml value like `context_intelligence_server_url: "http://${MY_HOST}:8000"` + # is already app-expanded by app-cli in hook mode, but the upload CLI reads settings.yaml + # directly via resolve_config → _parse_settings_yaml which does NOT expand placeholders). + server_url = _expand_env_placeholders(server_url) + api_key = _expand_env_placeholders(api_key) + + # 0c. Build auth strategy — fail loud on misconfiguration + from context_intelligence.auth import build_auth_strategy + + auth_strategy = build_auth_strategy( + auth_mode=auth_mode, + api_key=api_key, + auth_resource=auth_resource, ) # 1. Auto-generate job_id if not provided @@ -399,6 +453,7 @@ def main() -> None: api_key=api_key, tracker=tracker, event_delay_s=args.event_delay_ms / 1000.0, + auth_strategy=auth_strategy, ) # 7. Write result JSON to stdout diff --git a/modules/tool-context-intelligence-upload/amplifier_module_tool_context_intelligence_upload/uploader.py b/modules/tool-context-intelligence-upload/amplifier_module_tool_context_intelligence_upload/uploader.py index b037df5..c7e47c6 100644 --- a/modules/tool-context-intelligence-upload/amplifier_module_tool_context_intelligence_upload/uploader.py +++ b/modules/tool-context-intelligence-upload/amplifier_module_tool_context_intelligence_upload/uploader.py @@ -20,6 +20,7 @@ from amplifier_module_hook_context_intelligence.upload import build_payload if TYPE_CHECKING: + from context_intelligence.auth import AuthStrategy from amplifier_module_tool_context_intelligence_upload.progress import ProgressTracker @@ -102,6 +103,8 @@ def run_upload( api_key: str, tracker: ProgressTracker, event_delay_s: float = 0.0, + *, + auth_strategy: AuthStrategy | None = None, ) -> UploadResult: """Replay all events from *sessions* to the server. @@ -112,13 +115,18 @@ def run_upload( server_url: Base URL of the Context Intelligence ingestion server. api_key: - API key used in the ``Authorization: Bearer`` header. + API key used in the ``Authorization: Bearer`` header (static mode). + Ignored when *auth_strategy* is provided. tracker: A :class:`ProgressTracker` instance that is updated after every event. event_delay_s: Seconds to sleep between each successful event POST. Defaults to ``0.0`` (no delay). Set to a positive value (e.g. ``0.05``) to throttle the upload rate and reduce Neo4j write pressure on the server. + auth_strategy: + Optional :class:`~context_intelligence.auth.AuthStrategy` that produces + the ``Authorization`` header. When ``None``, an ``ApiKeyAuth`` is + derived from *api_key* for backward compatibility. Returns ------- @@ -126,9 +134,14 @@ def run_upload( Success result after all sessions complete, or failure result if any HTTP error occurs. """ + if auth_strategy is None: + from context_intelligence.auth import ApiKeyAuth + + auth_strategy = ApiKeyAuth(api_key) + endpoint = f"{server_url}/events" timeout = httpx.Timeout(connect=5.0, read=30.0, write=30.0, pool=5.0) - headers = {"Authorization": f"Bearer {api_key}"} + headers = auth_strategy.headers() total_events_uploaded = 0 total_sessions_uploaded = 0 diff --git a/modules/tool-context-intelligence-upload/pyproject.toml b/modules/tool-context-intelligence-upload/pyproject.toml index baf3ece..671b9fa 100644 --- a/modules/tool-context-intelligence-upload/pyproject.toml +++ b/modules/tool-context-intelligence-upload/pyproject.toml @@ -6,6 +6,7 @@ license = "MIT" dependencies = [ "amplifier-bundle-context-intelligence @ git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main", + "azure-identity>=1.19", "httpx>=0.28.1", "idna>=3.15", "amplifier-module-hook-context-intelligence", diff --git a/modules/tool-context-intelligence-upload/tests/conftest.py b/modules/tool-context-intelligence-upload/tests/conftest.py index b368097..7db9e69 100644 --- a/modules/tool-context-intelligence-upload/tests/conftest.py +++ b/modules/tool-context-intelligence-upload/tests/conftest.py @@ -1,3 +1,22 @@ """Shared pytest configuration and fixtures for tool-context-intelligence-upload tests.""" from __future__ import annotations + +from typing import Any + +import pytest + + +@pytest.fixture(autouse=True) +def _reset_auth_singleton() -> Any: + """Clear the auth module singleton and token cache before/after each test. + + Ensures the process-level _singleton_credential and _MODULE_CACHE do not + leak between tests, so patches of _make_cli_credential are effective and + cached tokens from one test don't pollute the next. + """ + from context_intelligence import auth as _auth_mod + + _auth_mod.reset() + yield + _auth_mod.reset() diff --git a/modules/tool-context-intelligence-upload/tests/test_auth_wiring.py b/modules/tool-context-intelligence-upload/tests/test_auth_wiring.py new file mode 100644 index 0000000..5289ed5 --- /dev/null +++ b/modules/tool-context-intelligence-upload/tests/test_auth_wiring.py @@ -0,0 +1,420 @@ +"""Tests for dual-auth wiring in the upload CLI (entra + static paths). + +TDD RED phase — these tests define the expected behaviour of: +- run_upload() with an injected auth_strategy +- cli.py --auth-mode / --auth-resource flags +- resolve_config() in entra mode (no api_key required) +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers (reused from test_uploader pattern) +# --------------------------------------------------------------------------- + + +def _write_session( + tmp_path: Path, + session_id: str, + events: list[dict[str, Any]], +) -> tuple[Path, dict[str, Any]]: + session_dir = tmp_path / f"session-{session_id}" + session_dir.mkdir(parents=True, exist_ok=True) + metadata = {"session_id": session_id, "format": "context-intelligence"} + (session_dir / "metadata.json").write_text(json.dumps(metadata), encoding="utf-8") + (session_dir / "events.jsonl").write_text( + "\n".join(json.dumps(e) for e in events), + encoding="utf-8", + ) + return session_dir, metadata + + +def _make_events(n: int) -> list[dict[str, Any]]: + return [{"event": f"e-{i}", "workspace": "ws", "data": {"index": i}} for i in range(n)] + + +class FakeToken: + # expires_on far future so cached tokens are never considered stale in tests + def __init__(self, token: str, expires_on: float = 9_999_999_999.0) -> None: + self.token = token + self.expires_on = expires_on + + +class FakeCredential: + def __init__(self, token: str = "faketoken") -> None: + self._token = token + self.calls: list[tuple[Any, ...]] = [] + + def get_token(self, *scopes: str, **kwargs: Any) -> FakeToken: + self.calls.append(scopes) + return FakeToken(self._token) + + +# --------------------------------------------------------------------------- +# run_upload() with injected auth_strategy +# --------------------------------------------------------------------------- + + +class TestRunUploadWithAuthStrategy: + """run_upload accepts an auth_strategy kwarg and uses its headers.""" + + def test_injected_strategy_headers_used_not_api_key(self, tmp_path: Path) -> None: + """When auth_strategy is provided, its headers() are used — NOT api_key.""" + from context_intelligence.auth import EntraTokenAuth + + from amplifier_module_tool_context_intelligence_upload.uploader import run_upload + + fake_cred = FakeCredential("my-entra-token") + strategy = EntraTokenAuth(fake_cred, "api://resource") + + session_dir, metadata = _write_session(tmp_path, "s1", _make_events(1)) + tracker = MagicMock() + + with patch("httpx.Client") as mock_cls: + mock_client = MagicMock() + mock_cls.return_value.__enter__.return_value = mock_client + mock_client.post.return_value = MagicMock(status_code=200) + + run_upload( + sessions=[(session_dir, metadata)], + server_url="https://server", + api_key="", + tracker=tracker, + auth_strategy=strategy, + ) + + _, init_kwargs = mock_cls.call_args + assert init_kwargs["headers"] == {"Authorization": "Bearer my-entra-token"} + + def test_no_strategy_derives_api_key_auth(self, tmp_path: Path) -> None: + """When auth_strategy is None, ApiKeyAuth(api_key) is derived — backward compat.""" + from amplifier_module_tool_context_intelligence_upload.uploader import run_upload + + session_dir, metadata = _write_session(tmp_path, "s1", _make_events(1)) + tracker = MagicMock() + + with patch("httpx.Client") as mock_cls: + mock_client = MagicMock() + mock_cls.return_value.__enter__.return_value = mock_client + mock_client.post.return_value = MagicMock(status_code=200) + + run_upload( + sessions=[(session_dir, metadata)], + server_url="https://server", + api_key="legacy-key", + tracker=tracker, + ) + + _, init_kwargs = mock_cls.call_args + assert init_kwargs["headers"] == {"Authorization": "Bearer legacy-key"} + + def test_strategy_headers_called_once_at_client_construction(self, tmp_path: Path) -> None: + """The strategy's headers() is called once, at httpx.Client construction.""" + from context_intelligence.auth import ApiKeyAuth + + from amplifier_module_tool_context_intelligence_upload.uploader import run_upload + + strategy = ApiKeyAuth("test-static") + session_dir, metadata = _write_session(tmp_path, "s1", _make_events(3)) + tracker = MagicMock() + + with patch("httpx.Client") as mock_cls: + mock_client = MagicMock() + mock_cls.return_value.__enter__.return_value = mock_client + mock_client.post.return_value = MagicMock(status_code=200) + + run_upload( + sessions=[(session_dir, metadata)], + server_url="https://server", + api_key="", + tracker=tracker, + auth_strategy=strategy, + ) + + # httpx.Client is constructed once; headers kwarg should contain strategy headers + assert mock_cls.call_count == 1 + _, kwargs = mock_cls.call_args + assert kwargs.get("headers") == {"Authorization": "Bearer test-static"} + + +# --------------------------------------------------------------------------- +# resolve_config() — entra mode must NOT require api_key +# --------------------------------------------------------------------------- + + +class TestResolveConfigEntraMode: + """resolve_config(auth_mode='entra') must not SystemExit on missing api_key.""" + + def test_entra_mode_no_api_key_does_not_exit(self, tmp_path: Any) -> None: + """In entra mode, empty api_key is legitimate — no SystemExit.""" + from context_intelligence.config import resolve_config + + # No env vars, no settings file, no api_key arg + with patch("context_intelligence.config.SETTINGS_PATH", tmp_path / "nosettings.yaml"): + with patch.dict("os.environ", {}, clear=True): + url, key = resolve_config( + server_url="http://localhost:8000", + auth_mode="entra", + ) + assert url == "http://localhost:8000" + assert key == "" # empty is fine in entra mode + + def test_static_mode_still_requires_api_key(self, tmp_path: Any) -> None: + """Static mode (default) still exits when api_key is absent.""" + from context_intelligence.config import resolve_config + + with patch("context_intelligence.config.SETTINGS_PATH", tmp_path / "nosettings.yaml"): + with patch.dict( + "os.environ", + {"AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL": "http://localhost"}, + clear=True, + ): + with pytest.raises(SystemExit): + resolve_config(server_url="http://localhost") + + +# --------------------------------------------------------------------------- +# CLI --auth-mode / --auth-resource flags +# --------------------------------------------------------------------------- + + +class TestCliAuthFlags: + """_build_parser() must expose --auth-mode and --auth-resource flags.""" + + def test_auth_mode_default_is_static(self) -> None: + """When --auth-mode is not passed, it defaults to 'static'.""" + from amplifier_module_tool_context_intelligence_upload.cli import _build_parser + + args = _build_parser().parse_args( + ["--path", "/tmp", "--server-url", "http://s", "--api-key", "k"] + ) + assert args.auth_mode == "static" + + def test_auth_mode_entra_accepted(self) -> None: + """--auth-mode entra is a valid choice.""" + from amplifier_module_tool_context_intelligence_upload.cli import _build_parser + + args = _build_parser().parse_args( + ["--path", "/tmp", "--server-url", "http://s", "--auth-mode", "entra"] + ) + assert args.auth_mode == "entra" + + def test_auth_resource_default_is_none(self) -> None: + """When --auth-resource is not passed, it defaults to None.""" + from amplifier_module_tool_context_intelligence_upload.cli import _build_parser + + args = _build_parser().parse_args( + ["--path", "/tmp", "--server-url", "http://s", "--api-key", "k"] + ) + assert args.auth_resource is None + + def test_auth_resource_accepts_value(self) -> None: + """--auth-resource is accepted and stored.""" + from amplifier_module_tool_context_intelligence_upload.cli import _build_parser + + args = _build_parser().parse_args( + [ + "--path", + "/tmp", + "--auth-mode", + "entra", + "--auth-resource", + "api://53aa4ffd", + ] + ) + assert args.auth_resource == "api://53aa4ffd" + + def test_invalid_auth_mode_rejected(self) -> None: + """--auth-mode with invalid value causes parse error (exit 2).""" + from amplifier_module_tool_context_intelligence_upload.cli import _build_parser + + with pytest.raises(SystemExit) as exc_info: + _build_parser().parse_args(["--path", "/tmp", "--auth-mode", "kerberos"]) + assert exc_info.value.code == 2 + + +# --------------------------------------------------------------------------- +# CLI main() entra mode — end-to-end via mocked run_upload +# --------------------------------------------------------------------------- + + +class TestCliMainEntraMode: + """main() in entra mode builds an EntraTokenAuth and passes it to run_upload.""" + + def test_entra_mode_calls_run_upload_with_auth_strategy( + self, tmp_path: Path, capsys: Any + ) -> None: + """main() with --auth-mode entra injects an auth_strategy into run_upload.""" + from amplifier_module_tool_context_intelligence_upload.cli import main + + fake_sessions = [(tmp_path, {"session_id": "s1"})] + mock_result = MagicMock() + mock_result.success = True + mock_result.to_dict.return_value = { + "status": "completed", + "sessions_uploaded": 1, + "events_uploaded": 1, + } + + fake_cred = FakeCredential("entra-token") + + with ( + patch( + "sys.argv", + [ + "context-intelligence-upload", + "--path", + str(tmp_path), + "--server-url", + "http://localhost:38000", + "--auth-mode", + "entra", + "--auth-resource", + "api://53aa4ffd", + ], + ), + patch( + "amplifier_module_tool_context_intelligence_upload.cli.discover_and_sort", + return_value=fake_sessions, + ), + patch( + "amplifier_module_tool_context_intelligence_upload.cli.run_upload", + return_value=mock_result, + ) as mock_upload, + patch("amplifier_module_tool_context_intelligence_upload.cli.ProgressTracker"), + patch( + "context_intelligence.auth._make_cli_credential", + return_value=fake_cred, + ), + pytest.raises(SystemExit) as exc_info, + ): + main() + + assert exc_info.value.code == 0 + call_kwargs = mock_upload.call_args.kwargs + # auth_strategy must be present and produce the correct header + strategy = call_kwargs.get("auth_strategy") + assert strategy is not None + # server_url still threaded through + assert call_kwargs["server_url"] == "http://localhost:38000" + + def test_entra_mode_produces_bearer_from_credential(self, tmp_path: Path, capsys: Any) -> None: + """In entra mode the auth_strategy.headers() yields 'Bearer '.""" + from context_intelligence.auth import EntraTokenAuth + + from amplifier_module_tool_context_intelligence_upload.cli import main + + fake_sessions = [(tmp_path, {"session_id": "s1"})] + mock_result = MagicMock() + mock_result.success = True + mock_result.to_dict.return_value = { + "status": "completed", + "sessions_uploaded": 1, + "events_uploaded": 1, + } + + captured_strategy: list[Any] = [] + fake_cred = FakeCredential("my-real-entra-token") + + def _capture_run_upload(**kwargs: Any) -> Any: + captured_strategy.append(kwargs.get("auth_strategy")) + return mock_result + + with ( + patch( + "sys.argv", + [ + "context-intelligence-upload", + "--path", + str(tmp_path), + "--server-url", + "http://localhost:38000", + "--auth-mode", + "entra", + "--auth-resource", + "api://53aa4ffd", + ], + ), + patch( + "amplifier_module_tool_context_intelligence_upload.cli.discover_and_sort", + return_value=fake_sessions, + ), + patch( + "amplifier_module_tool_context_intelligence_upload.cli.run_upload", + side_effect=_capture_run_upload, + ), + patch("amplifier_module_tool_context_intelligence_upload.cli.ProgressTracker"), + patch( + "context_intelligence.auth._make_cli_credential", + return_value=fake_cred, + ), + pytest.raises(SystemExit), + ): + main() + + assert len(captured_strategy) == 1 + strategy = captured_strategy[0] + assert isinstance(strategy, EntraTokenAuth) + # Ask the strategy for its header — should use the fake credential + hdrs = strategy.headers() + assert hdrs == {"Authorization": "Bearer my-real-entra-token"} + + def test_static_mode_unchanged(self, tmp_path: Path, capsys: Any) -> None: + """In static mode, auth_strategy is ApiKeyAuth derived from api_key.""" + from context_intelligence.auth import ApiKeyAuth + + from amplifier_module_tool_context_intelligence_upload.cli import main + + fake_sessions = [(tmp_path, {"session_id": "s1"})] + mock_result = MagicMock() + mock_result.success = True + mock_result.to_dict.return_value = { + "status": "completed", + "sessions_uploaded": 1, + "events_uploaded": 1, + } + + captured_strategy: list[Any] = [] + + def _capture(**kwargs: Any) -> Any: + captured_strategy.append(kwargs.get("auth_strategy")) + return mock_result + + with ( + patch( + "sys.argv", + [ + "context-intelligence-upload", + "--path", + str(tmp_path), + "--server-url", + "http://localhost", + "--api-key", + "sk-static", + ], + ), + patch( + "amplifier_module_tool_context_intelligence_upload.cli.discover_and_sort", + return_value=fake_sessions, + ), + patch( + "amplifier_module_tool_context_intelligence_upload.cli.run_upload", + side_effect=_capture, + ), + patch("amplifier_module_tool_context_intelligence_upload.cli.ProgressTracker"), + pytest.raises(SystemExit), + ): + main() + + assert len(captured_strategy) == 1 + strategy = captured_strategy[0] + assert isinstance(strategy, ApiKeyAuth) + assert strategy.headers() == {"Authorization": "Bearer sk-static"} diff --git a/modules/tool-context-intelligence-upload/uv.lock b/modules/tool-context-intelligence-upload/uv.lock index 6097820..2d31c0f 100644 --- a/modules/tool-context-intelligence-upload/uv.lock +++ b/modules/tool-context-intelligence-upload/uv.lock @@ -5,7 +5,7 @@ requires-python = ">=3.11" [[package]] name = "amplifier-bundle-context-intelligence" version = "0.1.1" -source = { git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=v0.1.1#b722074f17a354816ebf5adcf0881b1562a2cbc5" } +source = { git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=main#2b532dae58d34f1aa6549bc654f6c6c7a8afafca" } [[package]] name = "amplifier-module-hook-context-intelligence" @@ -15,18 +15,20 @@ dependencies = [ { name = "amplifier-bundle-context-intelligence" }, { name = "httpx" }, { name = "idna" }, + { name = "pathspec" }, ] [package.metadata] requires-dist = [ - { name = "amplifier-bundle-context-intelligence", git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=v0.1.1" }, + { name = "amplifier-bundle-context-intelligence", git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=main" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "idna", specifier = ">=3.15" }, + { name = "pathspec", specifier = ">=0.12,<2" }, ] [package.metadata.requires-dev] dev = [ - { name = "amplifier-core", git = "https://github.com/microsoft/amplifier-core?rev=v1.4.1" }, + { name = "amplifier-core", specifier = ">=1.6.0" }, { name = "pyright", specifier = ">=1.1" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=0.24" }, @@ -41,6 +43,7 @@ source = { editable = "." } dependencies = [ { name = "amplifier-bundle-context-intelligence" }, { name = "amplifier-module-hook-context-intelligence" }, + { name = "azure-identity" }, { name = "httpx" }, { name = "idna" }, ] @@ -55,8 +58,9 @@ dev = [ [package.metadata] requires-dist = [ - { name = "amplifier-bundle-context-intelligence", git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=v0.1.1" }, + { name = "amplifier-bundle-context-intelligence", git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=main" }, { name = "amplifier-module-hook-context-intelligence", editable = "../hook-context-intelligence" }, + { name = "azure-identity", specifier = ">=1.19" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "idna", specifier = ">=3.15" }, ] @@ -82,6 +86,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "azure-core" +version = "1.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/f3/b416179e408990df5db0d516283022dde0f5d0111d98c1a848e41853e81c/azure_core-1.41.0.tar.gz", hash = "sha256:f46ff5dfcd230f25cf1c19e8a34b8dc08a337b2503e268bb600a16c00db8ad5a", size = 381042, upload-time = "2026-05-07T23:30:54.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/db/325c6d7312d2200251c52323878281045aaffcb5586612296484e4280eaa/azure_core-1.41.0-py3-none-any.whl", hash = "sha256:522b4011e8180b1a3dcd2024396a4e7fe9ac37fb8597db47163d230b5efe892d", size = 220920, upload-time = "2026-05-07T23:30:56.357Z" }, +] + +[[package]] +name = "azure-identity" +version = "1.25.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "cryptography" }, + { name = "msal" }, + { name = "msal-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/0e/3a63efb48aa4a5ae2cfca61ee152fbcb668092134d3eb8bfda472dd5c617/azure_identity-1.25.3.tar.gz", hash = "sha256:ab23c0d63015f50b630ef6c6cf395e7262f439ce06e5d07a64e874c724f8d9e6", size = 286304, upload-time = "2026-03-13T01:12:20.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/9a/417b3a533e01953a7c618884df2cb05a71e7b68bdbce4fbdb62349d2a2e8/azure_identity-1.25.3-py3-none-any.whl", hash = "sha256:f4d0b956a8146f30333e071374171f3cfa7bdb8073adb8c3814b65567aa7447c", size = 192138, upload-time = "2026-03-13T01:12:22.951Z" }, +] + [[package]] name = "certifi" version = "2026.2.25" @@ -91,6 +124,165 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -100,6 +292,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -155,6 +403,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "msal" +version = "1.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/99/d840198ecf6e8057bbc937f129ae940404485d736cda73253bbff9537f01/msal-1.37.0.tar.gz", hash = "sha256:1b1672a33ee467c1d70b341bb16cafd51bb3c817147a95b93263794b03971bec", size = 182444, upload-time = "2026-05-29T19:49:05.561Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/b0/d807279f4b55d16d1f120d5ac4344c6e39b56732e2a224d40bded7fd67ad/msal-1.37.0-py3-none-any.whl", hash = "sha256:dd17e95a7c71bce75e8108113438ba7c4a086b3bcad4f57a8c09b7af3d753c2d", size = 123725, upload-time = "2026-05-29T19:49:04.335Z" }, +] + +[[package]] +name = "msal-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0" @@ -173,6 +447,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -182,6 +465,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -191,6 +483,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pyright" version = "1.1.408" @@ -233,6 +539,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + [[package]] name = "ruff" version = "0.15.8" @@ -266,3 +587,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8 wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..cedc0d7 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,244 @@ +"""Tests for context_intelligence/auth.py — TDD RED phase. + +Covers: +- ApiKeyAuth.headers() shape +- EntraTokenAuth.headers() with a fake credential +- build_auth_strategy guards: entra+no resource, static+no key, unknown mode +- Static mode does NOT import azure.identity +""" + +from __future__ import annotations + +import sys +from typing import Any +from unittest.mock import MagicMock + +import pytest + + +# --------------------------------------------------------------------------- +# ApiKeyAuth +# --------------------------------------------------------------------------- + + +class TestApiKeyAuth: + """ApiKeyAuth produces the correct Authorization header.""" + + def test_headers_shape(self) -> None: + """headers() returns exactly {'Authorization': 'Bearer '}.""" + from context_intelligence.auth import ApiKeyAuth + + auth = ApiKeyAuth("sk-test-key") + result = auth.headers() + assert result == {"Authorization": "Bearer sk-test-key"} + + def test_headers_is_dict_str_str(self) -> None: + """headers() returns a dict[str, str].""" + from context_intelligence.auth import ApiKeyAuth + + auth = ApiKeyAuth("any-key") + result = auth.headers() + assert isinstance(result, dict) + assert all(isinstance(k, str) and isinstance(v, str) for k, v in result.items()) + + def test_empty_key_still_works(self) -> None: + """ApiKeyAuth with empty string returns Bearer with empty token (guard is in builder).""" + from context_intelligence.auth import ApiKeyAuth + + auth = ApiKeyAuth("") + result = auth.headers() + assert result == {"Authorization": "Bearer "} + + +# --------------------------------------------------------------------------- +# EntraTokenAuth +# --------------------------------------------------------------------------- + + +class FakeToken: + """Minimal fake of azure.core.credentials.AccessToken.""" + + # expires_on far in the future so cached tokens are never considered stale + def __init__(self, token: str, expires_on: float = 9_999_999_999.0) -> None: + self.token = token + self.expires_on = expires_on + + +class FakeCredential: + """Fake azure-identity TokenCredential that records calls.""" + + def __init__(self, token: str = "faketoken") -> None: + self._token = token + self.calls: list[tuple[Any, ...]] = [] + + def get_token(self, *scopes: str, **kwargs: Any) -> FakeToken: + self.calls.append(scopes) + return FakeToken(self._token) + + +class TestEntraTokenAuth: + """EntraTokenAuth calls get_token with the right scope and puts token in header.""" + + def test_get_token_called_with_default_scope(self) -> None: + """get_token called with '/.default'.""" + from context_intelligence.auth import EntraTokenAuth + + cred = FakeCredential("mytoken") + auth = EntraTokenAuth(cred, "api://my-app-id") + auth.headers() + assert len(cred.calls) == 1 + assert cred.calls[0] == ("api://my-app-id/.default",) + + def test_headers_authorization_value(self) -> None: + """headers() returns {'Authorization': 'Bearer '}.""" + from context_intelligence.auth import EntraTokenAuth + + cred = FakeCredential("faketoken") + auth = EntraTokenAuth(cred, "api://my-app-id") + result = auth.headers() + assert result == {"Authorization": "Bearer faketoken"} + + def test_credential_stored_at_construction(self) -> None: + """The credential passed at construction is the one used — not re-instantiated.""" + from context_intelligence.auth import EntraTokenAuth + + cred1 = FakeCredential("token-one") + cred2 = FakeCredential("token-two") + + auth = EntraTokenAuth(cred1, "api://app") + auth.headers() # uses cred1 + + _ = EntraTokenAuth(cred2, "api://app") # cred2 untouched + + assert len(cred1.calls) == 1 + assert len(cred2.calls) == 0 + + def test_different_resources_produce_different_scopes(self) -> None: + """Each EntraTokenAuth passes its own resource to get_token.""" + from context_intelligence.auth import EntraTokenAuth + + cred_a = FakeCredential("tok-a") + cred_b = FakeCredential("tok-b") + auth_a = EntraTokenAuth(cred_a, "api://resource-a") + auth_b = EntraTokenAuth(cred_b, "api://resource-b") + auth_a.headers() + auth_b.headers() + assert cred_a.calls[0] == ("api://resource-a/.default",) + assert cred_b.calls[0] == ("api://resource-b/.default",) + + +# --------------------------------------------------------------------------- +# build_auth_strategy — guards +# --------------------------------------------------------------------------- + + +class TestBuildAuthStrategyGuards: + """build_auth_strategy raises ValueError on bad inputs; never silently falls back.""" + + def test_unknown_mode_raises(self) -> None: + """Unknown auth_mode raises ValueError with mode name in message.""" + from context_intelligence.auth import build_auth_strategy + + with pytest.raises(ValueError, match="unknown auth_mode"): + build_auth_strategy(auth_mode="magic") + + def test_static_no_key_raises(self) -> None: + """static mode without api_key raises ValueError.""" + from context_intelligence.auth import build_auth_strategy + + with pytest.raises(ValueError, match="api_key"): + build_auth_strategy(auth_mode="static", api_key="") + + def test_static_whitespace_key_raises(self) -> None: + """static mode with whitespace-only api_key raises ValueError.""" + from context_intelligence.auth import build_auth_strategy + + with pytest.raises(ValueError, match="api_key"): + build_auth_strategy(auth_mode="static", api_key=" ") + + def test_entra_no_resource_raises(self) -> None: + """entra mode without auth_resource raises ValueError.""" + from context_intelligence.auth import build_auth_strategy + + fake_cred = MagicMock() + with pytest.raises(ValueError, match="auth_resource"): + build_auth_strategy(auth_mode="entra", auth_resource="", credential=fake_cred) + + def test_entra_whitespace_resource_raises(self) -> None: + """entra mode with whitespace-only auth_resource raises ValueError.""" + from context_intelligence.auth import build_auth_strategy + + fake_cred = MagicMock() + with pytest.raises(ValueError, match="auth_resource"): + build_auth_strategy(auth_mode="entra", auth_resource=" ", credential=fake_cred) + + +# --------------------------------------------------------------------------- +# build_auth_strategy — happy paths +# --------------------------------------------------------------------------- + + +class TestBuildAuthStrategyHappy: + """build_auth_strategy returns the right strategy type on valid inputs.""" + + def test_static_returns_api_key_auth(self) -> None: + """static mode returns ApiKeyAuth.""" + from context_intelligence.auth import ApiKeyAuth, build_auth_strategy + + strategy = build_auth_strategy(auth_mode="static", api_key="sk-valid") + assert isinstance(strategy, ApiKeyAuth) + + def test_static_strategy_produces_correct_header(self) -> None: + """Static strategy's header contains the key.""" + from context_intelligence.auth import build_auth_strategy + + strategy = build_auth_strategy(auth_mode="static", api_key="sk-valid") + assert strategy.headers() == {"Authorization": "Bearer sk-valid"} + + def test_entra_with_injected_credential(self) -> None: + """entra mode with injected credential returns EntraTokenAuth.""" + from context_intelligence.auth import EntraTokenAuth, build_auth_strategy + + fake_cred = FakeCredential("injected-token") + strategy = build_auth_strategy( + auth_mode="entra", + auth_resource="api://53aa4ffd", + credential=fake_cred, + ) + assert isinstance(strategy, EntraTokenAuth) + result = strategy.headers() + assert result == {"Authorization": "Bearer injected-token"} + assert fake_cred.calls[0] == ("api://53aa4ffd/.default",) + + +# --------------------------------------------------------------------------- +# Static mode MUST NOT import azure.identity +# --------------------------------------------------------------------------- + + +class TestStaticModeNoAzureIdentityImport: + """Static mode must work even when azure.identity is not installed.""" + + def test_static_build_succeeds_without_azure_identity( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """build_auth_strategy(auth_mode='static') succeeds even if azure.identity fails to import.""" + # Simulate azure.identity being unavailable by blocking the import + monkeypatch.setitem(sys.modules, "azure", None) # type: ignore[arg-type] + monkeypatch.setitem(sys.modules, "azure.identity", None) # type: ignore[arg-type] + + # Reload auth to pick up the monkeypatched sys.modules state + import importlib + + import context_intelligence.auth as auth_mod + + importlib.reload(auth_mod) + + # Static build must NOT touch azure.identity + strategy = auth_mod.build_auth_strategy(auth_mode="static", api_key="sk-key") + assert strategy.headers() == {"Authorization": "Bearer sk-key"} + + # Restore (reload back) + monkeypatch.delitem(sys.modules, "azure", raising=False) + monkeypatch.delitem(sys.modules, "azure.identity", raising=False) + importlib.reload(auth_mod) diff --git a/tests/test_auth_cache.py b/tests/test_auth_cache.py new file mode 100644 index 0000000..121cc1d --- /dev/null +++ b/tests/test_auth_cache.py @@ -0,0 +1,500 @@ +"""Tests for the Entra token cache in auth.py — TDD RED → GREEN. + +TB's five edges + supporting tests: +1. EXPIRY BOUNDARY — strict < comparison; at-boundary → refresh, inside → serve cached. +2. CLOCK-SKEW / MARGIN — safety margin prevents near-expiry serving; env override changes behavior. +3. CONCURRENCY — double-checked lock; threading.Lock works across asyncio event loops. +4. EXCEPTION NOT CACHED — get_token failure propagates; nothing stored; next call retries. +5. reset() — clears module cache; does not break injected-credential isolation. + +Plus: +- Repeated cached calls: get_token called exactly once. +- Scope keying: different resources → independent cache entries. +- build_auth_strategy wiring: injected credential → fresh cache; production → module singleton. +- ApiKeyAuth path: unchanged. +""" + +from __future__ import annotations + +import threading +import time +from typing import Any +from unittest.mock import patch + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers — rich FakeCredential/FakeToken for cache tests +# --------------------------------------------------------------------------- + +_FAR_FUTURE = 9_999_999_999.0 # far future Unix timestamp + + +class FakeToken: + """Minimal AccessToken substitute with both .token and .expires_on.""" + + def __init__(self, token: str, expires_on: float = _FAR_FUTURE) -> None: + self.token = token + self.expires_on = expires_on + + +class FakeCredential: + """Controllable fake TokenCredential for cache tests. + + Parameters + ---------- + token: + Token string returned by get_token(). + expires_on: + expires_on epoch for returned token. + delay: + Seconds to sleep inside get_token() — simulates slow az subprocess. + """ + + def __init__( + self, + token: str = "faketoken", + expires_on: float = _FAR_FUTURE, + delay: float = 0.0, + ) -> None: + self._token = token + self._expires_on = expires_on + self._delay = delay + self.calls: list[tuple[str, ...]] = [] + self._exc: Exception | None = None + + def fail_once(self, exc: Exception) -> None: + """Configure the NEXT get_token() call to raise exc (only once).""" + self._exc = exc + + def get_token(self, *scopes: str, **kwargs: Any) -> FakeToken: + self.calls.append(scopes) + if self._delay: + time.sleep(self._delay) + if self._exc is not None: + exc, self._exc = self._exc, None + raise exc + return FakeToken(self._token, self._expires_on) + + +@pytest.fixture(autouse=True) +def _reset_module_singleton() -> Any: + """Isolate each test: clear the auth module singleton and its cache before/after.""" + from context_intelligence import auth as _auth_mod + + _auth_mod.reset() + yield + _auth_mod.reset() + + +# --------------------------------------------------------------------------- +# Test 1: EXPIRY BOUNDARY +# --------------------------------------------------------------------------- + + +class TestExpiryBoundary: + """The comparison used is ``time.time() < expires_on - margin`` (strict <). + + - INSIDE the valid window (time < expires_on - margin) → serve cached. + - EXACTLY at the boundary (time == expires_on - margin) → False → refresh. + - OUTSIDE the window → also refresh. + + This documents the chosen semantics: on the exact boundary the token is + considered stale and refreshed, which is the conservative safe choice. + """ + + def test_strictly_before_boundary_serves_cached(self, monkeypatch: pytest.MonkeyPatch) -> None: + """time.time() < expires_on - margin → cached token served (no get_token).""" + import time as _time_mod + + import context_intelligence.auth as _auth + + fixed_now = 1_000_000.0 + monkeypatch.setattr(_time_mod, "time", lambda: fixed_now) + monkeypatch.setattr(_auth, "_SAFETY_MARGIN_S", 300.0) + + from context_intelligence.auth import EntraTokenAuth, _TokenCache + + # expires_on - margin = fixed_now + 301 - 300 = fixed_now + 1 + # fixed_now < fixed_now + 1 → True → serve cached + expires_on = fixed_now + 301.0 + cache = _TokenCache() + cache.store("api://app/.default", "cached-tok", expires_on) + cred = FakeCredential("new-tok") + + auth = EntraTokenAuth(cred, "api://app", _cache=cache) + result = auth.headers() + + assert result == {"Authorization": "Bearer cached-tok"} + assert len(cred.calls) == 0 # no get_token invocation + + def test_exactly_at_boundary_refreshes(self, monkeypatch: pytest.MonkeyPatch) -> None: + """time.time() == expires_on - margin: strict < is False → refresh. + + Documents that the chosen comparison is strict (<) not (<=), so the + boundary itself is treated as stale. + """ + import time as _time_mod + + import context_intelligence.auth as _auth + + fixed_now = 1_000_000.0 + monkeypatch.setattr(_time_mod, "time", lambda: fixed_now) + monkeypatch.setattr(_auth, "_SAFETY_MARGIN_S", 300.0) + + from context_intelligence.auth import EntraTokenAuth, _TokenCache + + # expires_on - margin = fixed_now + 300 - 300 = fixed_now + # fixed_now < fixed_now → False → refresh + expires_on = fixed_now + 300.0 + cache = _TokenCache() + cache.store("api://app/.default", "old-tok", expires_on) + cred = FakeCredential("fresh-tok") + + auth = EntraTokenAuth(cred, "api://app", _cache=cache) + result = auth.headers() + + assert result == {"Authorization": "Bearer fresh-tok"} + assert len(cred.calls) == 1 # refreshed + + def test_outside_window_refreshes(self, monkeypatch: pytest.MonkeyPatch) -> None: + """time.time() > expires_on - margin → also refresh.""" + import time as _time_mod + + import context_intelligence.auth as _auth + + fixed_now = 1_000_000.0 + monkeypatch.setattr(_time_mod, "time", lambda: fixed_now) + monkeypatch.setattr(_auth, "_SAFETY_MARGIN_S", 300.0) + + from context_intelligence.auth import EntraTokenAuth, _TokenCache + + # expires_on - margin = fixed_now + 100 - 300 = fixed_now - 200 + # fixed_now < fixed_now - 200 → False → refresh + expires_on = fixed_now + 100.0 + cache = _TokenCache() + cache.store("api://app/.default", "expired-tok", expires_on) + cred = FakeCredential("refreshed-tok") + + auth = EntraTokenAuth(cred, "api://app", _cache=cache) + result = auth.headers() + + assert result == {"Authorization": "Bearer refreshed-tok"} + assert len(cred.calls) == 1 + + +# --------------------------------------------------------------------------- +# Test 2: CLOCK-SKEW / MARGIN +# --------------------------------------------------------------------------- + + +class TestSafetyMargin: + """``_SAFETY_MARGIN_S`` is the sole guard against serving near-expired tokens. + + Default is 300 s. It can be overridden via the + ``AMPLIFIER_CONTEXT_INTELLIGENCE_TOKEN_REFRESH_MARGIN_S`` env variable + (read at module import time) or by directly setting ``_SAFETY_MARGIN_S`` + on the module (for tests). + """ + + def test_near_expiry_token_is_refreshed_with_default_margin( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Token expiring 50 s from now with margin=300 s → stale → refresh.""" + import time as _time_mod + + import context_intelligence.auth as _auth + + fixed_now = 1_000_000.0 + monkeypatch.setattr(_time_mod, "time", lambda: fixed_now) + monkeypatch.setattr(_auth, "_SAFETY_MARGIN_S", 300.0) + + from context_intelligence.auth import EntraTokenAuth, _TokenCache + + expires_on = fixed_now + 50.0 # only 50 s ahead; margin = 300 s → stale + cache = _TokenCache() + cache.store("api://app/.default", "stale-tok", expires_on) + cred = FakeCredential("fresh-tok") + + auth = EntraTokenAuth(cred, "api://app", _cache=cache) + result = auth.headers() + + assert result == {"Authorization": "Bearer fresh-tok"} + assert len(cred.calls) == 1 # refreshed + + def test_reduced_margin_makes_same_token_valid(self, monkeypatch: pytest.MonkeyPatch) -> None: + """With margin reduced to 10 s, a token 50 s from now is NOT stale → serve cached.""" + import time as _time_mod + + import context_intelligence.auth as _auth + + fixed_now = 1_000_000.0 + monkeypatch.setattr(_time_mod, "time", lambda: fixed_now) + # Override margin to 10 s (simulating env AMPLIFIER_CI_TOKEN_REFRESH_MARGIN_S=10) + monkeypatch.setattr(_auth, "_SAFETY_MARGIN_S", 10.0) + + from context_intelligence.auth import EntraTokenAuth, _TokenCache + + # expires_on - margin = fixed_now + 50 - 10 = fixed_now + 40 + # fixed_now < fixed_now + 40 → True → serve cached + expires_on = fixed_now + 50.0 + cache = _TokenCache() + cache.store("api://app/.default", "cached-tok", expires_on) + cred = FakeCredential("new-tok") + + auth = EntraTokenAuth(cred, "api://app", _cache=cache) + result = auth.headers() + + assert result == {"Authorization": "Bearer cached-tok"} + assert len(cred.calls) == 0 # NOT refreshed — margin is smaller + + +# --------------------------------------------------------------------------- +# Test 3: CONCURRENCY +# --------------------------------------------------------------------------- + + +class TestConcurrency: + def test_cold_cache_n_threads_calls_get_token_exactly_once(self) -> None: + """N threads hit a cold cache simultaneously; double-checked lock → one get_token call.""" + from context_intelligence.auth import EntraTokenAuth, _TokenCache + + # Slow credential (50 ms): first thread that gets the lock blocks others + cred = FakeCredential("tok", expires_on=time.time() + 7200, delay=0.05) + cache = _TokenCache() + auth = EntraTokenAuth(cred, "api://app", _cache=cache) + + results: list[dict[str, str]] = [] + errors: list[BaseException] = [] + n = 10 + barrier = threading.Barrier(n) + + def call_headers() -> None: + try: + barrier.wait() # all threads start simultaneously + results.append(auth.headers()) + except BaseException as exc: # noqa: BLE001 + errors.append(exc) + + threads = [threading.Thread(target=call_headers) for _ in range(n)] + for t in threads: + t.start() + for t in threads: + t.join(timeout=10.0) + + assert not errors, f"Thread errors: {errors}" + assert len(results) == n + assert all(r == {"Authorization": "Bearer tok"} for r in results) + assert len(cred.calls) == 1 # exactly once — double-checked lock serialised correctly + + def test_threading_lock_works_across_asyncio_event_loops(self) -> None: + """threading.Lock does not bind to an asyncio event loop. + + A module-level asyncio.Lock binds to the loop that created it and raises + 'attached to a different event loop' when used from a second loop. + threading.Lock has no such constraint. + """ + import asyncio + + from context_intelligence.auth import EntraTokenAuth, _TokenCache + + cache = _TokenCache() + cred = FakeCredential("tok", expires_on=time.time() + 7200) + auth = EntraTokenAuth(cred, "api://app", _cache=cache) + + async def call_headers() -> dict[str, str]: + return auth.headers() + + # asyncio.run() always creates a NEW event loop; two calls → two different loops. + # threading.Lock must survive this without raising. + result1 = asyncio.run(call_headers()) + result2 = asyncio.run(call_headers()) + + assert result1 == {"Authorization": "Bearer tok"} + assert result2 == {"Authorization": "Bearer tok"} # served from cache on loop 2 + assert len(cred.calls) == 1 # get_token only called from loop 1 + + +# --------------------------------------------------------------------------- +# Test 4: EXCEPTION NOT CACHED +# --------------------------------------------------------------------------- + + +class TestExceptionNotCached: + def test_get_token_exception_propagates(self) -> None: + """When get_token raises, the exception propagates to the caller.""" + from context_intelligence.auth import EntraTokenAuth, _TokenCache + + cred = FakeCredential() + cred.fail_once(RuntimeError("az: ERROR: Please run 'az login' to authenticate")) + + cache = _TokenCache() + auth = EntraTokenAuth(cred, "api://app", _cache=cache) + + with pytest.raises(RuntimeError, match="az login"): + auth.headers() + + def test_failed_get_token_leaves_cache_empty(self) -> None: + """After get_token raises, nothing is stored in the cache.""" + from context_intelligence.auth import EntraTokenAuth, _TokenCache + + cred = FakeCredential() + cred.fail_once(RuntimeError("transient")) + + cache = _TokenCache() + auth = EntraTokenAuth(cred, "api://app", _cache=cache) + + with pytest.raises(RuntimeError): + auth.headers() + + assert cache.get("api://app/.default") is None + + def test_next_call_after_failure_retries_and_succeeds(self) -> None: + """After a failure, the next headers() call retries (no stale cached exception).""" + from context_intelligence.auth import EntraTokenAuth, _TokenCache + + cred = FakeCredential("good-tok") + cred.fail_once(RuntimeError("transient failure")) + + cache = _TokenCache() + auth = EntraTokenAuth(cred, "api://app", _cache=cache) + + with pytest.raises(RuntimeError): + auth.headers() + + result = auth.headers() # second call: cred now succeeds + assert result == {"Authorization": "Bearer good-tok"} + assert len(cred.calls) == 2 # tried twice + + +# --------------------------------------------------------------------------- +# Test 5: reset() +# --------------------------------------------------------------------------- + + +class TestReset: + def test_reset_clears_module_singleton_cache(self) -> None: + """reset() empties _MODULE_CACHE; next call using it re-fetches the token.""" + from context_intelligence.auth import EntraTokenAuth, _MODULE_CACHE, reset + + future = time.time() + 7200 + _MODULE_CACHE.store("api://app/.default", "cached-tok", future) + + cred = FakeCredential("fresh-tok") + auth = EntraTokenAuth(cred, "api://app", _cache=_MODULE_CACHE) + + # First call: served from pre-populated cache + result1 = auth.headers() + assert result1 == {"Authorization": "Bearer cached-tok"} + assert len(cred.calls) == 0 + + reset() # clears _MODULE_CACHE + + # Second call: cache miss → re-fetches + result2 = auth.headers() + assert result2 == {"Authorization": "Bearer fresh-tok"} + assert len(cred.calls) == 1 + + def test_reset_does_not_affect_injected_cache(self) -> None: + """reset() only clears the module singleton; injected-cache instances keep their state.""" + from context_intelligence.auth import EntraTokenAuth, _TokenCache, reset + + cred = FakeCredential("tok") + own_cache = _TokenCache() + auth = EntraTokenAuth(cred, "api://app", _cache=own_cache) + + auth.headers() # populates own_cache + assert len(cred.calls) == 1 + + reset() # clears _MODULE_CACHE only + + auth.headers() # hits own_cache, NOT re-fetched + assert len(cred.calls) == 1 # still 1 — own_cache intact + + def test_reset_sets_singleton_credential_to_none(self) -> None: + """reset() sets _singleton_credential back to None.""" + import context_intelligence.auth as _auth_mod + + _auth_mod._singleton_credential = object() # pretend it's set + _auth_mod.reset() + assert _auth_mod._singleton_credential is None + + +# --------------------------------------------------------------------------- +# Additional: cache-hit count, scope keying, wiring +# --------------------------------------------------------------------------- + + +class TestCacheHit: + def test_repeated_headers_calls_invoke_get_token_once(self) -> None: + """After the first cache miss, many headers() calls keep get_token call-count at 1.""" + from context_intelligence.auth import EntraTokenAuth, _TokenCache + + cred = FakeCredential("tok") + auth = EntraTokenAuth(cred, "api://app", _cache=_TokenCache()) + + for _ in range(50): + result = auth.headers() + assert result == {"Authorization": "Bearer tok"} + + assert len(cred.calls) == 1 # get_token called exactly once + + def test_different_resources_use_independent_cache_entries(self) -> None: + """Two EntraTokenAuth instances with different resources do NOT share cached tokens.""" + from context_intelligence.auth import EntraTokenAuth, _TokenCache + + shared_cache = _TokenCache() + cred_a = FakeCredential("tok-a") + cred_b = FakeCredential("tok-b") + auth_a = EntraTokenAuth(cred_a, "api://resource-a", _cache=shared_cache) + auth_b = EntraTokenAuth(cred_b, "api://resource-b", _cache=shared_cache) + + # First calls: both are cache misses + assert auth_a.headers() == {"Authorization": "Bearer tok-a"} + assert auth_b.headers() == {"Authorization": "Bearer tok-b"} + assert len(cred_a.calls) == 1 + assert len(cred_b.calls) == 1 + + # Second calls: both served from cache + assert auth_a.headers() == {"Authorization": "Bearer tok-a"} + assert auth_b.headers() == {"Authorization": "Bearer tok-b"} + assert len(cred_a.calls) == 1 + assert len(cred_b.calls) == 1 + + +class TestBuildAuthStrategyWiring: + """build_auth_strategy wires injected credential → fresh cache; production → module cache.""" + + def test_injected_credential_gets_fresh_cache_not_module_singleton(self) -> None: + """build_auth_strategy(credential=fake) must NOT share _MODULE_CACHE.""" + from context_intelligence.auth import EntraTokenAuth, _MODULE_CACHE, build_auth_strategy + + cred = FakeCredential("tok") + strategy = build_auth_strategy( + auth_mode="entra", auth_resource="api://app", credential=cred + ) + assert isinstance(strategy, EntraTokenAuth) + assert strategy._cache is not _MODULE_CACHE # type: ignore[union-attr] + + def test_production_path_credential_none_uses_module_cache(self) -> None: + """build_auth_strategy(credential=None) wires to _MODULE_CACHE.""" + from context_intelligence.auth import EntraTokenAuth, _MODULE_CACHE, build_auth_strategy + + fake_cred = FakeCredential("tok") + with patch("context_intelligence.auth._get_singleton_credential", return_value=fake_cred): + strategy = build_auth_strategy(auth_mode="entra", auth_resource="api://app") + + assert isinstance(strategy, EntraTokenAuth) + assert strategy._cache is _MODULE_CACHE # type: ignore[union-attr] + + +class TestApiKeyAuthUnchanged: + """ApiKeyAuth is a pure dict — no cache, no threading.""" + + def test_api_key_auth_unaffected_by_cache_changes(self) -> None: + from context_intelligence.auth import ApiKeyAuth + + auth = ApiKeyAuth("sk-test") + for _ in range(20): + assert auth.headers() == {"Authorization": "Bearer sk-test"} diff --git a/tests/test_auth_resource_expansion.py b/tests/test_auth_resource_expansion.py new file mode 100644 index 0000000..3ca7775 --- /dev/null +++ b/tests/test_auth_resource_expansion.py @@ -0,0 +1,77 @@ +"""Tests for ${VAR} expansion of auth_resource in query-tool sources (slice 2-B, root-bundle scope). + +Root-bundle scope: only context_intelligence.* is importable here. + + - Hook destination auth_resource: tested in modules/hook-context-intelligence/tests/test_hook_auth.py + - Upload CLI auth_resource: tested in modules/tool-context-intelligence-upload/tests/test_auth_wiring.py +""" + +from __future__ import annotations + +import os +from unittest.mock import MagicMock, patch + + +# --------------------------------------------------------------------------- +# Query-tool sources — _expand() applied to auth_resource +# --------------------------------------------------------------------------- + +class TestToolSourceAuthResourceExpansion: + """ToolConfigResolver.sources applies _expand() to auth_resource.""" + + def _resolver(self, config: dict) -> object: + from context_intelligence.tool_resolver import ToolConfigResolver + coord = MagicMock() + coord.config = {} + return ToolConfigResolver(config, coord) + + def test_auth_resource_placeholder_expanded(self) -> None: + """${MY_RESOURCE} in source auth_resource is expanded to env value.""" + r = self._resolver({ + "sources": { + "team": { + "url": "http://ci:8000", + "auth_mode": "entra", + "auth_resource": "${MY_CI_RESOURCE}", + } + } + }) + with patch.dict(os.environ, {"MY_CI_RESOURCE": "api://server-id"}, clear=False): + # Force re-parse + r._sources = None # type: ignore[attr-defined] + sources = r.sources # type: ignore[attr-defined] + assert sources["team"].auth_resource == "api://server-id" + + def test_auth_resource_with_default_placeholder_unset(self) -> None: + """${MY_CI_RESOURCE:api://fallback} uses default when env var unset.""" + r = self._resolver({ + "sources": { + "team": { + "url": "http://ci:8000", + "auth_mode": "entra", + "auth_resource": "${_UNSET_CI_RES_XYZZY:api://fallback-id}", + } + } + }) + env = {k: v for k, v in os.environ.items() if k != "_UNSET_CI_RES_XYZZY"} + with patch.dict(os.environ, env, clear=True): + r._sources = None # type: ignore[attr-defined] + sources = r.sources # type: ignore[attr-defined] + assert sources["team"].auth_resource == "api://fallback-id" + + def test_static_source_api_key_placeholder_expanded(self) -> None: + """${MY_API_KEY} in source api_key is expanded to env value.""" + r = self._resolver({ + "sources": { + "local": { + "url": "http://local:8000", + "api_key": "${LOCAL_CI_KEY}", + } + } + }) + with patch.dict(os.environ, {"LOCAL_CI_KEY": "sk-expanded"}, clear=False): + r._sources = None # type: ignore[attr-defined] + sources = r.sources # type: ignore[attr-defined] + assert sources["local"].api_key == "sk-expanded" + + diff --git a/tests/test_placeholder_expander.py b/tests/test_placeholder_expander.py new file mode 100644 index 0000000..5f8b53d --- /dev/null +++ b/tests/test_placeholder_expander.py @@ -0,0 +1,88 @@ +"""Tests for _expand_env_placeholders — RED phase for the regex bug fix (slice 2). + +Proves that ${VAR}, ${VAR:}, ${VAR:default}, and embedded forms like +api://${X}/y all expand correctly after the backslash is removed from +_PLACEHOLDER_RE. +""" + +from __future__ import annotations + +import os +from unittest.mock import patch + + +class TestExpandEnvPlaceholders: + """_expand_env_placeholders must handle all documented forms.""" + + def test_simple_var_set(self) -> None: + """`${FOO}` expands to the env var value when FOO is set.""" + from context_intelligence.config import _expand_env_placeholders + + with patch.dict(os.environ, {"FOO": "bar"}, clear=False): + assert _expand_env_placeholders("${FOO}") == "bar" + + def test_simple_var_unset_gives_empty(self) -> None: + """`${FOO}` with FOO unset expands to empty string.""" + from context_intelligence.config import _expand_env_placeholders + + env = {k: v for k, v in os.environ.items() if k != "FOO"} + with patch.dict(os.environ, env, clear=True): + assert _expand_env_placeholders("${FOO}") == "" + + def test_var_with_empty_default_set(self) -> None: + """`${VAR:}` with VAR set → env value.""" + from context_intelligence.config import _expand_env_placeholders + + with patch.dict(os.environ, {"MYVAR": "hello"}, clear=False): + assert _expand_env_placeholders("${MYVAR:}") == "hello" + + def test_var_with_empty_default_unset(self) -> None: + """`${VAR:}` with VAR unset → empty string.""" + from context_intelligence.config import _expand_env_placeholders + + env = {k: v for k, v in os.environ.items() if k != "MISSING_VAR_XYZZY"} + with patch.dict(os.environ, env, clear=True): + assert _expand_env_placeholders("${MISSING_VAR_XYZZY:}") == "" + + def test_var_with_default_set(self) -> None: + """`${VAR:default}` with VAR set → env value (not default).""" + from context_intelligence.config import _expand_env_placeholders + + with patch.dict(os.environ, {"MYVAR": "actual"}, clear=False): + assert _expand_env_placeholders("${MYVAR:fallback}") == "actual" + + def test_var_with_default_unset(self) -> None: + """`${MISSING:default}` with MISSING unset → default.""" + from context_intelligence.config import _expand_env_placeholders + + env = {k: v for k, v in os.environ.items() if k != "MISSING_VAR_XYZZY"} + with patch.dict(os.environ, env, clear=True): + assert _expand_env_placeholders("${MISSING_VAR_XYZZY:mydefault}") == "mydefault" + + def test_no_placeholder_unchanged(self) -> None: + """A plain string without ${} passes through unchanged.""" + from context_intelligence.config import _expand_env_placeholders + + assert _expand_env_placeholders("api://some-fixed-id") == "api://some-fixed-id" + + def test_embedded_placeholder_expanded(self) -> None: + """`api://${X}/y` expands the embedded ${X}.""" + from context_intelligence.config import _expand_env_placeholders + + with patch.dict(os.environ, {"X": "abc123"}, clear=False): + result = _expand_env_placeholders("api://${X}/y") + assert result == "api://abc123/y" + + def test_multiple_placeholders_in_one_string(self) -> None: + """Multiple ${} in one string all expand.""" + from context_intelligence.config import _expand_env_placeholders + + with patch.dict(os.environ, {"HOST": "myhost", "PORT": "9000"}, clear=False): + result = _expand_env_placeholders("http://${HOST}:${PORT}/events") + assert result == "http://myhost:9000/events" + + def test_empty_string_unchanged(self) -> None: + """Empty string passes through unchanged.""" + from context_intelligence.config import _expand_env_placeholders + + assert _expand_env_placeholders("") == ""