diff --git a/docs/concepts/observability.md b/docs/concepts/observability.md index 38b3d0b..c02cbc3 100644 --- a/docs/concepts/observability.md +++ b/docs/concepts/observability.md @@ -610,26 +610,55 @@ graph.attach_observer(observer) The `client` is anything matching the `LangfuseClient` Protocol — the bundled `InMemoryLangfuseClient` (used by the conformance harness, useful for unit tests), or a real `langfuse.Langfuse()` -instance from the [Langfuse Python SDK](https://github.com/langfuse/langfuse-python). -The Protocol declares only the methods the observer calls, so SDK -versions whose shape matches drop in directly. SDK versions whose -shape diverges (renamed kwargs, return-type quirks) plug in via a -small adapter; see +instance wrapped in `LangfuseSDKAdapter` for production. Install +the optional extras to bring in the Langfuse SDK: + +```bash +pip install 'openarmature[langfuse]' +``` + +Production wire-up: + +```python +from langfuse import Langfuse +from openarmature.observability.langfuse import ( + LangfuseObserver, + LangfuseSDKAdapter, +) + +langfuse_client = Langfuse( + public_key="pk-lf-...", + secret_key="sk-lf-...", + host="https://cloud.langfuse.com", +) +observer = LangfuseObserver( + client=LangfuseSDKAdapter(langfuse_client), + disable_llm_payload=False, +) +``` + +The adapter bridges `langfuse>=4.6`'s unified `start_observation` +API onto our `LangfuseClient` Protocol; the observer code is the +same in tests and production. See [`examples/10-langfuse-observability`](../examples/10-langfuse-observability.md) -for the runnable demo plus the adapter shape. +for a runnable demo. !!! note "Langfuse SDK version compatibility" - No specific `langfuse` SDK version is validated in CI as of this - release. The Protocol mirrors the SDK's documented low-level - `trace` / `span` / `generation` shape, but the SDK has shifted - between major versions (v2 → v3 introduced API changes). A - follow-on release pins a tested `[langfuse]` extras range and - ships a runtime `isinstance` check confirming the SDK satisfies - the Protocol. Until then, treat production wire-up as a "verify - in your own environment" path: bring the langfuse version your - stack already uses, run a smoke trace, and write a thin adapter - if any kwargs don't line up. + Validated against `langfuse>=4.6,<5`. The v4 SDK introduced an + OTel-based architecture with `start_observation` / + `propagate_attributes` replacing the v2/v3 `trace` / `span` / + `generation` low-level API; the bundled `LangfuseSDKAdapter` + handles the bridge so the observer surface is stable across + future v4 patches. + + Earlier SDK versions (v2.x, v3.x) are NOT supported. Projects on + those versions either upgrade to v4 or supply their own adapter + matching the `LangfuseClient` Protocol's four methods. + + A runtime `isinstance(adapter, LangfuseClient)` check ships in + the unit suite — if a future v4 patch breaks the Protocol's + surface, the test fails loudly. ### What Langfuse sees diff --git a/docs/examples/10-langfuse-observability.md b/docs/examples/10-langfuse-observability.md index 6bd00c9..c47e5ed 100644 --- a/docs/examples/10-langfuse-observability.md +++ b/docs/examples/10-langfuse-observability.md @@ -114,35 +114,45 @@ Trace id=01234567-89ab-... ## Swapping to a real Langfuse SDK -The observer's `client` parameter is `LangfuseClient`-Protocol-typed, -so any structurally-compatible value works: +Install the optional extras: + +```bash +pip install 'openarmature[langfuse]' +``` + +Wrap the SDK client with `LangfuseSDKAdapter` and pass it to the +observer: ```python from langfuse import Langfuse +from openarmature.observability.langfuse import ( + LangfuseObserver, + LangfuseSDKAdapter, +) -client = Langfuse( +langfuse_client = Langfuse( public_key="pk-lf-...", secret_key="sk-lf-...", host="https://cloud.langfuse.com", ) -observer = LangfuseObserver(client=client, disable_llm_payload=False) +observer = LangfuseObserver( + client=LangfuseSDKAdapter(langfuse_client), + disable_llm_payload=False, +) ``` -If the installed SDK version's `trace` / `span` / `generation` method -signatures match the Protocol exactly, this is the whole change. If -they diverge (renamed kwargs, return-type quirks), wrap the SDK in a -small adapter class that implements `LangfuseClient` and delegates to -the SDK call-by-call. The Protocol surface is narrow — four methods — -so the adapter is on the order of 40 lines. - -**No specific `langfuse` SDK version is validated in CI as of this -release.** The Protocol matches the SDK's documented low-level shape, -but `langfuse` has shifted between major versions (v2 → v3 introduced -API changes). A follow-on release pins a tested `[langfuse]` extras -range and a runtime `isinstance(client, LangfuseClient)` check; until -then, smoke-trace in your own environment with whichever `langfuse` -version your stack already uses and write a thin adapter if any -kwargs don't line up. +The adapter bridges `langfuse>=4.6,<5`'s unified `start_observation` +API onto OA's four-method `LangfuseClient` Protocol. v4 has no +explicit trace creation (traces are auto-created from observations); +the adapter caches trace info from `.trace()` and applies it via +`propagate_attributes` around EVERY observation under that trace_id. +Propagating on every observation keeps v4's last-attribute-wins +display logic from clobbering the trace's display name when later +observations land without the attribute set. + +Validated against `langfuse>=4.6,<5`. v2.x and v3.x are NOT +supported — supply your own adapter against the same four-method +Protocol if you need to stay on an older version. For prompt linkage: in production, the `Prompt.observability_entities['langfuse_prompt']` value is the SDK's diff --git a/examples/10-langfuse-observability/main.py b/examples/10-langfuse-observability/main.py index 0fa09db..614d72b 100644 --- a/examples/10-langfuse-observability/main.py +++ b/examples/10-langfuse-observability/main.py @@ -21,8 +21,12 @@ The example uses the bundled ``InMemoryLangfuseClient`` recorder so the demo runs without a Langfuse account — at the end we print the captured Trace + Observation tree. Swapping to a real ``langfuse.Langfuse()`` -client is a one-line constructor change (see the comment near the -observer build below). +client is a one-line constructor change via ``LangfuseSDKAdapter`` (see +the comment near the observer build below). The adapter bridges the +``langfuse>=4.6`` Python SDK shape onto OA's ``LangfuseClient`` +Protocol. Install with:: + + pip install 'openarmature[langfuse]' LLM calls go through ``openarmature.llm.OpenAIProvider``. @@ -244,11 +248,18 @@ async def main() -> None: # fields — without needing a Langfuse account. For production: # # from langfuse import Langfuse - # client = Langfuse(public_key=..., secret_key=..., host=...) + # from openarmature.observability.langfuse import LangfuseSDKAdapter + # + # langfuse_client = Langfuse( + # public_key="pk-lf-...", + # secret_key="sk-lf-...", + # host="https://cloud.langfuse.com", + # ) + # client = LangfuseSDKAdapter(langfuse_client) # - # Replace the InMemoryLangfuseClient construction below with that - # client. The observer code doesn't change — the client is - # Protocol-typed, so any structurally-compatible value works. + # Validated against ``langfuse>=4.6,<5``. The adapter bridges + # langfuse v4's unified ``start_observation`` API onto OA's + # ``LangfuseClient`` Protocol; the observer code doesn't change. client = InMemoryLangfuseClient() # disable_llm_payload=False opts in to capturing the input messages diff --git a/pyproject.toml b/pyproject.toml index 796bc96..ed0c53b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,13 @@ otel = [ # below 1.0; revisit when 1.0 lands. "opentelemetry-instrumentation-logging>=0.62.0b1", ] +# Spec observability §8 Langfuse mapping. Optional per charter §3.1 +# principle 5; matches the [otel] extras' shape. Validated against +# Langfuse Python SDK 4.6.x; bridge from the SDK's v4 API onto the +# LangfuseClient Protocol lives in ``observability.langfuse.adapter``. +langfuse = [ + "langfuse>=4.6,<5", +] [project.urls] Repository = "https://github.com/LunarCommand/openarmature-python" @@ -105,3 +112,11 @@ select = ["E", "F", "I", "B", "UP"] [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" +# Opt-in markers for tests that hit real external services. CI skips +# these by default — run with `-m integration` to include them after +# setting the relevant env vars (LANGFUSE_PUBLIC_KEY / SECRET_KEY for +# Langfuse Cloud, etc). +markers = [ + "integration: tests that exercise real external services (Langfuse Cloud, HyperDX). Skipped by default.", +] +addopts = ["-m", "not integration"] diff --git a/src/openarmature/observability/langfuse/__init__.py b/src/openarmature/observability/langfuse/__init__.py index e857fbb..8efb699 100644 --- a/src/openarmature/observability/langfuse/__init__.py +++ b/src/openarmature/observability/langfuse/__init__.py @@ -42,6 +42,18 @@ ) from .observer import LangfuseObserver +# LangfuseSDKAdapter requires the [langfuse] optional dependency. +# Surface it when available, but don't force the import on consumers +# who only use the InMemoryLangfuseClient — the adapter module's own +# guard raises an informative ImportError if anyone tries to use it +# without the extras installed. +try: + from .adapter import LangfuseSDKAdapter as LangfuseSDKAdapter + + _adapter_available = True +except ImportError: # pragma: no cover - exercised by extras-not-installed path + _adapter_available = False + __all__ = [ "InMemoryLangfuseClient", "LangfuseClient", @@ -54,3 +66,5 @@ "ObservationLevel", "ObservationType", ] +if _adapter_available: + __all__.append("LangfuseSDKAdapter") diff --git a/src/openarmature/observability/langfuse/adapter.py b/src/openarmature/observability/langfuse/adapter.py new file mode 100644 index 0000000..f136b8f --- /dev/null +++ b/src/openarmature/observability/langfuse/adapter.py @@ -0,0 +1,382 @@ +# Bridges the langfuse Python SDK (v4.6+) onto the LangfuseClient +# Protocol. Validated against langfuse==4.7.0; the [langfuse] extras +# pin to `>=4.6,<5`. SDK churn before v4 (v2/v3 API removed in v4) is +# not supported — projects on v2/v3 should write their own adapter or +# upgrade. +# +# Shape mismatch the adapter handles: +# - v4 has no explicit `client.trace(...)` — traces are auto-created +# when the first observation starts. We cache the trace name + +# metadata on `.trace()` and apply them via `propagate_attributes` +# around EVERY observation under that trace_id. Propagating on +# every observation (not just the first) keeps v4's +# last-attribute-wins display logic from clobbering the trace's +# display name when later observations land without the attribute +# set. +# - v4 unifies span and generation under `start_observation(as_type=)`. +# The adapter routes `.span()` to `as_type="span"` and +# `.generation()` to `as_type="generation"`. +# - v4's `propagate_attributes(metadata=...)` requires Dict[str, str] +# (not Dict[str, Any]). Non-string values are JSON-serialized at +# the boundary. +# +# `update_trace` merges into the persistent trace_info cache so +# subsequent observations under the trace_id pick up the new values +# via `propagate_attributes`. Existing observations are NOT +# retroactively updated. The current OA LangfuseObserver doesn't +# actually invoke `update_trace` today — caller-supplied +# invocation-label lands in PR 4 via the trace_info cache before the +# first observation creates the trace — but the merge-then-propagate +# path is wired for forward compat. + +"""LangfuseSDKAdapter: bridge langfuse>=4.6 onto the LangfuseClient Protocol.""" + +from __future__ import annotations + +import json +import uuid as _uuid +from contextlib import ExitStack +from typing import TYPE_CHECKING, Any, cast + +from .client import LangfuseGenerationHandle, LangfuseSpanHandle, LangfuseUsage, ObservationLevel + +if TYPE_CHECKING: + from langfuse import Langfuse + +try: + from langfuse import propagate_attributes + from langfuse.types import TraceContext +except ImportError as exc: # pragma: no cover - exercised by extras-not-installed path + raise ImportError( + "openarmature.observability.langfuse.adapter requires the optional `langfuse` extras. " + "Install with: pip install 'openarmature[langfuse]'" + ) from exc + + +def _to_otel_trace_id(trace_id: str) -> str: + """Convert OA's UUID4-formatted invocation_id to OTel's 32-char + hex trace_id form (no dashes). + + Langfuse v4 is OTel-based: trace IDs are 128-bit integers + serialized as 32 lowercase hex characters. OA's invocation_id is + a standard UUID4 (8-4-4-4-12 dashed hex); same 128 bits, different + representation. Passing the dashed form to Langfuse v4 fails with + ``int(..., 16)`` parsing in the SDK's internals. + + Non-UUID inputs pass through unchanged so adapter consumers can + pass an already-OTel-formatted trace_id if they have one. + + Trade-off: the spec §8.4.1 "trace.id MUST equal invocation_id + verbatim" contract is met content-wise (same 128 bits) but not + representation-wise. Users querying Langfuse for an OA + invocation_id need to strip dashes before searching. Documented + in the adapter's class docstring. + """ + try: + return _uuid.UUID(trace_id).hex + except (ValueError, AttributeError): + return trace_id + + +def _stringify_metadata(metadata: dict[str, Any] | None) -> dict[str, str]: + """Coerce metadata values to strings for v4's propagate_attributes, + which only accepts ``Dict[str, str]``. Non-string scalars stringify + via ``str()``; dicts and lists serialize via JSON with sorted keys + so the round-trip is deterministic.""" + if metadata is None: + return {} + out: dict[str, str] = {} + for key, value in metadata.items(): + if isinstance(value, str): + out[key] = value + elif isinstance(value, dict | list): + out[key] = json.dumps(value, sort_keys=True, separators=(",", ":")) + else: + out[key] = str(value) + return out + + +class _SpanHandle: + """Wraps a langfuse LangfuseSpan / LangfuseGeneration to satisfy + :class:`LangfuseSpanHandle` / :class:`LangfuseGenerationHandle`. + + The SDK's ``update(**fields)`` and ``end()`` shapes match our + Protocol; the only translation is the ``status_message`` / + ``level`` kwarg pass-through and the ``usage_details`` rename for + Generation usage fields. + """ + + def __init__(self, langfuse_obs: Any) -> None: + self._obs = langfuse_obs + + @property + def id(self) -> str: + # v4's LangfuseObservationWrapper exposes ``id`` as a property + # backed by the underlying OTel span context. Cast to str so + # static analysis sees the right shape. + return cast("str", self._obs.id) + + def update(self, **fields: Any) -> None: + kwargs: dict[str, Any] = {} + for key, value in fields.items(): + if key == "metadata": + kwargs["metadata"] = value + elif key == "status_message": + kwargs["status_message"] = value + elif key == "level": + kwargs["level"] = value + elif key == "usage": + # Translate our LangfuseUsage record to v4's + # usage_details dict shape. v4 expects integers. + if isinstance(value, LangfuseUsage): + usage_details: dict[str, int] = {} + if value.input is not None: + usage_details["input"] = value.input + if value.output is not None: + usage_details["output"] = value.output + if value.total is not None: + usage_details["total"] = value.total + kwargs["usage_details"] = usage_details + elif key == "output": + kwargs["output"] = value + elif key == "input": + kwargs["input"] = value + elif key == "model": + kwargs["model"] = value + elif key == "model_parameters": + kwargs["model_parameters"] = value + elif key == "prompt": + kwargs["prompt"] = value + else: + # Unknown kwargs fall through to v4's update kwargs — + # the SDK accepts arbitrary kwargs via its **kwargs + # parameter. + kwargs[key] = value + self._obs.update(**kwargs) + + def end(self, **fields: Any) -> None: + # Apply any field updates first (so they're set BEFORE the + # observation closes), then call end(). v4's end() takes only + # an optional ``end_time``; field mutation happens via update(). + if fields: + self.update(**fields) + self._obs.end() + + +class LangfuseSDKAdapter: + """Adapts a ``langfuse.Langfuse`` client (v4.6+) to the + :class:`~openarmature.observability.langfuse.LangfuseClient` + Protocol the :class:`LangfuseObserver` consumes. + + Usage:: + + from langfuse import Langfuse + from openarmature.observability.langfuse import ( + LangfuseObserver, + LangfuseSDKAdapter, + ) + + client = Langfuse( + public_key="pk-lf-...", + secret_key="sk-lf-...", + host="https://cloud.langfuse.com", + ) + observer = LangfuseObserver(client=LangfuseSDKAdapter(client)) + compiled.attach_observer(observer) + + The adapter is stateful per-instance: it caches trace info keyed + by trace_id and applies it to every observation under that trace + via ``propagate_attributes``. The cache persists across the + observation lifecycle so the trace name + metadata stay consistent + instead of being clobbered by later observations under "last- + attribute-wins" Langfuse-side processing. Cache cleanup is + future-PR work (a `close_trace(trace_id)` hook on the Protocol); + until then the cache grows linearly with unique trace_ids, which + is bounded in practice by how many invocations a process runs. + + Safe to share across concurrent invocations on one ``Langfuse`` + client; the cache is keyed by trace_id. + + **Trace ID format.** OA uses standard UUID4 invocation_ids + (8-4-4-4-12 dashed hex); Langfuse v4 is OTel-based and expects + 32-char lowercase hex (no dashes). The adapter converts on the + way out via :func:`_to_otel_trace_id`. Same 128 bits, different + representation — so a trace shows in Langfuse under + ``b24eda93d06d4eaa9891ca5e56f35722`` while OA's + ``correlation_id`` / ``invocation_id`` log line emits + ``b24eda93-d06d-4eaa-9891-ca5e56f35722``. Strip the dashes when + querying Langfuse for a specific invocation. + """ + + def __init__(self, client: Langfuse) -> None: + self._client = client + # Trace info cache, applied via propagate_attributes around + # EVERY observation (not just the first). Langfuse v4's trace + # name/metadata processing uses last-attribute-wins semantics, + # so propagating only on the first observation lets later + # observations clobber the trace's display name (the LAST + # observation's name becomes the trace name). Propagating on + # every observation under the same trace_id keeps the value + # consistent. Cache cleanup is deferred to a future PR. + self._trace_info: dict[str, dict[str, Any]] = {} + + def trace( + self, + *, + id: str, + name: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> None: + # v4 has no explicit trace creation; cache the info and apply + # it via propagate_attributes on every observation under this + # trace_id so the trace's display name + metadata stay + # consistent under v4's last-wins semantics. + self._trace_info[id] = { + "name": name, + "metadata": dict(metadata) if metadata is not None else {}, + } + + def update_trace( + self, + *, + id: str, + name: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> None: + # Merge into the trace_info cache so subsequent observations + # (and the first one if not yet created) pick up the updated + # values. Since propagate_attributes runs on every observation + # using cached info, update_trace takes effect on the NEXT + # observation under this trace_id, not retroactively on prior + # observations. + entry = self._trace_info.get(id) + if entry is None: + self._trace_info[id] = { + "name": name, + "metadata": dict(metadata) if metadata is not None else {}, + } + return + if name is not None: + entry["name"] = name + if metadata is not None: + entry["metadata"].update(metadata) + + def span( + self, + *, + trace_id: str, + name: str | None = None, + metadata: dict[str, Any] | None = None, + parent_observation_id: str | None = None, + level: ObservationLevel = "DEFAULT", + status_message: str | None = None, + ) -> LangfuseSpanHandle: + obs = self._start_observation( + as_type="span", + trace_id=trace_id, + name=name, + metadata=metadata, + parent_observation_id=parent_observation_id, + level=level, + status_message=status_message, + ) + return _SpanHandle(obs) + + def generation( + self, + *, + trace_id: str, + name: str | None = None, + metadata: dict[str, Any] | None = None, + parent_observation_id: str | None = None, + level: ObservationLevel = "DEFAULT", + status_message: str | None = None, + model: str | None = None, + model_parameters: dict[str, Any] | None = None, + input: Any = None, + output: Any = None, + usage: LangfuseUsage | None = None, + prompt: Any = None, + ) -> LangfuseGenerationHandle: + extra_kwargs: dict[str, Any] = { + "model": model, + "model_parameters": model_parameters, + "input": input, + "output": output, + "prompt": prompt, + } + # v4 expects usage_details (Dict[str, int]); translate from + # our LangfuseUsage record. + if usage is not None: + usage_details: dict[str, int] = {} + if usage.input is not None: + usage_details["input"] = usage.input + if usage.output is not None: + usage_details["output"] = usage.output + if usage.total is not None: + usage_details["total"] = usage.total + extra_kwargs["usage_details"] = usage_details + obs = self._start_observation( + as_type="generation", + trace_id=trace_id, + name=name, + metadata=metadata, + parent_observation_id=parent_observation_id, + level=level, + status_message=status_message, + **{k: v for k, v in extra_kwargs.items() if v is not None}, + ) + return _SpanHandle(obs) + + def _start_observation( + self, + *, + as_type: str, + trace_id: str, + name: str | None, + metadata: dict[str, Any] | None, + parent_observation_id: str | None, + level: ObservationLevel, + status_message: str | None, + **extra: Any, + ) -> Any: + # Read the cached trace info (no pop — propagate on every + # observation so v4's last-wins display logic keeps the + # trace name + metadata stable across all observations under + # this trace_id). + trace_entry = self._trace_info.get(trace_id) + + # Build the start_observation kwargs. parent_observation_id is + # threaded via trace_context (v4's TraceContext TypedDict + # supports trace_id + parent_span_id). Convert OA's UUID4 + # invocation_id to OTel's hex form for the trace_id; the + # parent_observation_id was minted by Langfuse on a prior + # call and is already OTel-formatted. + trace_context: TraceContext = {"trace_id": _to_otel_trace_id(trace_id)} + if parent_observation_id is not None: + trace_context["parent_span_id"] = parent_observation_id + + kwargs: dict[str, Any] = { + "name": name or "observation", + "as_type": as_type, + "trace_context": trace_context, + "metadata": metadata, + } + if level != "DEFAULT": + kwargs["level"] = level + if status_message is not None: + kwargs["status_message"] = status_message + kwargs.update(extra) + + with ExitStack() as stack: + if trace_entry is not None: + stack.enter_context( + propagate_attributes( + trace_name=trace_entry["name"], + metadata=_stringify_metadata(trace_entry["metadata"]), + ) + ) + return cast("Any", self._client.start_observation(**kwargs)) + + +__all__ = ["LangfuseSDKAdapter"] diff --git a/tests/unit/test_observability_langfuse_adapter.py b/tests/unit/test_observability_langfuse_adapter.py new file mode 100644 index 0000000..08f081e --- /dev/null +++ b/tests/unit/test_observability_langfuse_adapter.py @@ -0,0 +1,186 @@ +"""Unit + integration tests for LangfuseSDKAdapter against langfuse>=4.6. + +The unit test instantiates a real ``langfuse.Langfuse`` client with +dummy credentials and verifies the adapter satisfies the +:class:`LangfuseClient` Protocol via runtime ``isinstance`` — no +network calls. Skipped when the ``[langfuse]`` extra isn't installed. + +The integration test, gated by ``@pytest.mark.integration`` plus +``LANGFUSE_PUBLIC_KEY`` / ``LANGFUSE_SECRET_KEY`` env vars, runs a +small graph end-to-end against real Langfuse Cloud. Use:: + + LANGFUSE_PUBLIC_KEY=pk-lf-... \\ + LANGFUSE_SECRET_KEY=sk-lf-... \\ + LANGFUSE_HOST=https://cloud.langfuse.com \\ + uv run pytest tests/unit/test_observability_langfuse_adapter.py \\ + -m integration -v + +CI does NOT run integration tests; they're opt-in for local +verification. +""" + +from __future__ import annotations + +import os +from typing import Annotated, Any + +import pytest + +# Skip the whole module if langfuse isn't installed (extras not present). +pytest.importorskip("langfuse") + +from langfuse import Langfuse # noqa: E402 + +from openarmature.graph import END, GraphBuilder, State, append # noqa: E402 +from openarmature.observability.langfuse import ( # noqa: E402 + LangfuseClient, + LangfuseObserver, + LangfuseSDKAdapter, +) + + +def _dummy_client() -> Langfuse: + # langfuse 4.x's Langfuse() constructor accepts credentials via env + # vars or kwargs. Dummy keys bypass auth_check (which is called + # opportunistically) — the adapter only needs the methods present + # on the constructed instance, not a working API connection. + return Langfuse( + public_key="pk-lf-test", + secret_key="sk-lf-test", + host="http://localhost:0", # unreachable; we don't make calls in unit tests + ) + + +def test_adapter_satisfies_langfuse_client_protocol() -> None: + # Structural typing: the adapter MUST satisfy LangfuseClient at + # runtime so LangfuseObserver accepts it. This is the load-bearing + # test for the [langfuse] extras pin — if a future SDK release + # breaks the Protocol's surface, this fails loudly. + adapter = LangfuseSDKAdapter(_dummy_client()) + assert isinstance(adapter, LangfuseClient) + + +def test_adapter_observer_construction() -> None: + # End-to-end: the observer accepts the adapter as its client + # (Protocol satisfaction proves out at instantiation time under + # the LangfuseClient annotation). + adapter = LangfuseSDKAdapter(_dummy_client()) + observer = LangfuseObserver(client=adapter) + assert observer.client is adapter + + +def test_adapter_caches_trace_info() -> None: + # The trace() call doesn't hit the SDK; it caches info that + # propagate_attributes applies on every observation under that + # trace_id (not just the first — v4's last-wins display logic + # would otherwise let later observations clobber the trace name). + adapter = LangfuseSDKAdapter(_dummy_client()) + adapter.trace(id="trace-1", name="my-trace", metadata={"correlation_id": "c-1"}) + + assert "trace-1" in adapter._trace_info # noqa: SLF001 + cached = adapter._trace_info["trace-1"] # noqa: SLF001 + assert cached["name"] == "my-trace" + assert cached["metadata"] == {"correlation_id": "c-1"} + + +def test_adapter_converts_uuid_trace_id_to_otel_hex() -> None: + # Langfuse v4 expects OTel-format trace IDs (32-char lowercase + # hex, no dashes). OA's invocation_id is a UUID4 with dashes. + # The adapter MUST convert before passing to TraceContext, or + # the SDK fails with ValueError("invalid literal for int() with + # base 16: 'uuid-with-dashes'") at the OTel-attribute layer + # — which OA's observer-error-isolation pattern swallows as a + # warnings.warn, leaving the trace invisibly broken. + from openarmature.observability.langfuse.adapter import _to_otel_trace_id + + assert _to_otel_trace_id("b24eda93-d06d-4eaa-9891-ca5e56f35722") == "b24eda93d06d4eaa9891ca5e56f35722" + # Idempotent on already-hex input. + assert _to_otel_trace_id("b24eda93d06d4eaa9891ca5e56f35722") == "b24eda93d06d4eaa9891ca5e56f35722" + # Non-UUID inputs pass through unchanged (consumers passing an + # already-OTel-formatted trace_id from elsewhere don't get + # mangled). + assert _to_otel_trace_id("custom-trace-id") == "custom-trace-id" + + +def test_adapter_update_trace_merges_into_cache() -> None: + # update_trace merges into the cache so subsequent observations + # under this trace_id pick up the new values via propagate_attributes. + adapter = LangfuseSDKAdapter(_dummy_client()) + adapter.trace(id="trace-1", name="initial", metadata={"key1": "v1"}) + adapter.update_trace(id="trace-1", name="renamed", metadata={"key2": "v2"}) + + cached = adapter._trace_info["trace-1"] # noqa: SLF001 + assert cached["name"] == "renamed" + assert cached["metadata"] == {"key1": "v1", "key2": "v2"} + + +# --------------------------------------------------------------------------- +# Integration test against real Langfuse Cloud (opt-in) +# --------------------------------------------------------------------------- + + +class _S(State): + trail: Annotated[list[str], append] = [] + + +async def _node(name: str) -> Any: + return {"trail": [name]} + + +@pytest.mark.integration +async def test_adapter_against_real_langfuse_cloud() -> None: + # Validates that the adapter actually exchanges data with Langfuse + # Cloud — instantiates the real SDK, runs a tiny graph through + # LangfuseObserver, calls flush(). No assertions on the + # remote-side ingest (which is async). Manually verify via the + # Langfuse dashboard that the trace appears with the expected + # observation tree. + public_key = os.environ.get("LANGFUSE_PUBLIC_KEY") + secret_key = os.environ.get("LANGFUSE_SECRET_KEY") + if not public_key or not secret_key: + pytest.skip("LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY not set") + + # LANGFUSE_HOST is the canonical name (matches the SDK's ``host=`` + # kwarg); LANGFUSE_BASE_URL is the common alias some downstream + # configs use. Accept either; LANGFUSE_HOST wins when both set. + host = ( + os.environ.get("LANGFUSE_HOST") or os.environ.get("LANGFUSE_BASE_URL") or "https://cloud.langfuse.com" + ) + client = Langfuse( + public_key=public_key, + secret_key=secret_key, + host=host, + ) + # Fail loudly on bad credentials. Without this, a 401 from the + # background export thread is just a logged warning and the test + # passes while traces vanish. + assert client.auth_check(), ( + "Langfuse auth_check failed — verify LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY / LANGFUSE_HOST" + ) + + observer = LangfuseObserver(client=LangfuseSDKAdapter(client)) + + graph = ( + GraphBuilder(_S) + .add_node("step_a", lambda _s: _node("step_a")) + .add_node("step_b", lambda _s: _node("step_b")) + .add_edge("step_a", "step_b") + .add_edge("step_b", END) + .set_entry("step_a") + .compile() + ) + graph.attach_observer(observer) + await graph.invoke(_S()) + await graph.drain() + observer.shutdown() + # ``client.shutdown()`` is the synchronous drain — flush() returns + # immediately while the OTel BatchSpanProcessor exports in + # background, and the test process exits before that finishes. + # shutdown() blocks until all spans are exported (or the + # exporter's shutdown timeout elapses). + client.shutdown() + # Manual check: open the trace in the dashboard and confirm + # "step_a" + "step_b" appear as Span observations under one Trace. + # The trace_id in the dashboard is the 32-char hex form (no dashes) + # of OA's UUID4 invocation_id; strip dashes from any logged + # correlation_id / invocation_id to find it. diff --git a/uv.lock b/uv.lock index e404a94..7118ebc 100644 --- a/uv.lock +++ b/uv.lock @@ -42,6 +42,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, +] + [[package]] name = "backrefs" version = "7.0" @@ -358,6 +367,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.75.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, +] + [[package]] name = "griffelib" version = "2.0.2" @@ -554,6 +575,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "langfuse" +version = "4.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backoff" }, + { name = "httpx" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/80/0bd2ed8f781905d8e4c8f69e72fd06d914c907f31a3074449d20f8964c78/langfuse-4.7.0.tar.gz", hash = "sha256:7b592a251777dae44f76db7971871ff16c4c200d490cb5cd4e2f3822a1d593ec", size = 282543, upload-time = "2026-05-27T18:51:01.521Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/13/9a2304f546c74bc2bfc4765adb1e6f6a0984ead671fa65c3edcf9d473b58/langfuse-4.7.0-py3-none-any.whl", hash = "sha256:72ca668ce0999d05c787ccbfa313b679b99b3492e268d351549488822dd40c34", size = 482420, upload-time = "2026-05-27T18:50:59.773Z" }, +] + [[package]] name = "markdown" version = "3.10.2" @@ -895,6 +935,9 @@ dependencies = [ ] [package.optional-dependencies] +langfuse = [ + { name = "langfuse" }, +] otel = [ { name = "opentelemetry-api" }, { name = "opentelemetry-instrumentation-logging" }, @@ -929,12 +972,13 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27" }, { name = "jinja2", specifier = ">=3.1" }, { name = "jsonschema", specifier = ">=4.0" }, + { name = "langfuse", marker = "extra == 'langfuse'", specifier = ">=4.6,<5" }, { name = "opentelemetry-api", marker = "extra == 'otel'", specifier = ">=1.27,<3" }, { name = "opentelemetry-instrumentation-logging", marker = "extra == 'otel'", specifier = ">=0.62.0b1" }, { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.27,<3" }, { name = "pydantic", specifier = ">=2.7" }, ] -provides-extras = ["otel"] +provides-extras = ["otel", "langfuse"] [package.metadata.requires-dev] dev = [ @@ -970,6 +1014,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/59/3e7118ed140f76b0982ba4321bdaed1997a0473f9720de2d10788a577033/opentelemetry_api-1.41.1-py3-none-any.whl", hash = "sha256:a22df900e75c76dc08440710e51f52f1aa6b451b429298896023e60db5b3139f", size = 69007, upload-time = "2026-04-24T13:15:15.662Z" }, ] +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/fa/f9e3bd3c4d692b3ce9a2880a167d1f79681a1bea11f00d5bf76adc03e6ea/opentelemetry_exporter_otlp_proto_common-1.41.1.tar.gz", hash = "sha256:0e253156ea9c36b0bd3d2440c5c9ba7dd1f3fb64ba7a08fc85fbac536b56e1fb", size = 20409, upload-time = "2026-04-24T13:15:40.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/48/bce76d3ea772b609757e9bc844e02ab408a6446609bf74fb562062ba6b71/opentelemetry_exporter_otlp_proto_common-1.41.1-py3-none-any.whl", hash = "sha256:10da74dad6a49344b9b7b21b6182e3060373a235fde1528616d5f01f92e66aa9", size = 18366, upload-time = "2026-04-24T13:15:18.917Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/5b/9d3c7f70cca10136ba82a81e738dee626c8e7fc61c6887ea9a58bf34c606/opentelemetry_exporter_otlp_proto_http-1.41.1.tar.gz", hash = "sha256:4747a9604c8550ab38c6fd6180e2fcb80de3267060bef2c306bad3cb443302bc", size = 24139, upload-time = "2026-04-24T13:15:42.977Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/4d/ef07ff2fc630849f2080ae0ae73a61f67257905b7ac79066640bfa0c5739/opentelemetry_exporter_otlp_proto_http-1.41.1-py3-none-any.whl", hash = "sha256:1a21e8f49c7a946d935551e90947d6c3eb39236723c6624401da0f33d68edcb4", size = 22673, upload-time = "2026-04-24T13:15:21.313Z" }, +] + [[package]] name = "opentelemetry-instrumentation" version = "0.62b1" @@ -998,6 +1072,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/e4/216d1c7ff9c10815a8587ecbca0b570596921f001d1e2c2903c6f19e2e90/opentelemetry_instrumentation_logging-0.62b1-py3-none-any.whl", hash = "sha256:969330216d1ae02f4e10f1a030566ae758114caead020817192e6a02c6d1a0e1", size = 17488, upload-time = "2026-04-24T13:22:00.726Z" }, ] +[[package]] +name = "opentelemetry-proto" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/e8/633c6d8a9c8840338b105907e55c32d3da1983abab5e52f899f72a82c3d1/opentelemetry_proto-1.41.1.tar.gz", hash = "sha256:4b9d2eb631237ea43b80e16c073af438554e32bc7e9e3f8ca4a9582f900020e5", size = 45670, upload-time = "2026-04-24T13:15:49.768Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/1e/5cd77035e3e82070e2265a63a760f715aacd3cb16dddc7efee913f297fcc/opentelemetry_proto-1.41.1-py3-none-any.whl", hash = "sha256:0496713b804d127a4147e32849fbaf5683fac8ee98550e8e7679cd706c289720", size = 72076, upload-time = "2026-04-24T13:15:32.542Z" }, +] + [[package]] name = "opentelemetry-sdk" version = "1.41.1" @@ -1155,6 +1241,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, ] +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -1772,66 +1873,51 @@ wheels = [ [[package]] name = "wrapt" -version = "2.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/b6/1db817582c49c7fcbb7df6809d0f515af29d7c2fbf57eb44c36e98fb1492/wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9", size = 61255, upload-time = "2026-03-06T02:52:45.663Z" }, - { url = "https://files.pythonhosted.org/packages/a2/16/9b02a6b99c09227c93cd4b73acc3678114154ec38da53043c0ddc1fba0dc/wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748", size = 61848, upload-time = "2026-03-06T02:53:48.728Z" }, - { url = "https://files.pythonhosted.org/packages/af/aa/ead46a88f9ec3a432a4832dfedb84092fc35af2d0ba40cd04aea3889f247/wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e", size = 121433, upload-time = "2026-03-06T02:54:40.328Z" }, - { url = "https://files.pythonhosted.org/packages/3a/9f/742c7c7cdf58b59085a1ee4b6c37b013f66ac33673a7ef4aaed5e992bc33/wrapt-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8", size = 123013, upload-time = "2026-03-06T02:53:26.58Z" }, - { url = "https://files.pythonhosted.org/packages/e8/44/2c3dd45d53236b7ed7c646fcf212251dc19e48e599debd3926b52310fafb/wrapt-2.1.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c", size = 117326, upload-time = "2026-03-06T02:53:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/74/e2/b17d66abc26bd96f89dec0ecd0ef03da4a1286e6ff793839ec431b9fae57/wrapt-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c", size = 121444, upload-time = "2026-03-06T02:54:09.5Z" }, - { url = "https://files.pythonhosted.org/packages/3c/62/e2977843fdf9f03daf1586a0ff49060b1b2fc7ff85a7ea82b6217c1ae36e/wrapt-2.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1", size = 116237, upload-time = "2026-03-06T02:54:03.884Z" }, - { url = "https://files.pythonhosted.org/packages/88/dd/27fc67914e68d740bce512f11734aec08696e6b17641fef8867c00c949fc/wrapt-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2", size = 120563, upload-time = "2026-03-06T02:53:20.412Z" }, - { url = "https://files.pythonhosted.org/packages/ec/9f/b750b3692ed2ef4705cb305bd68858e73010492b80e43d2a4faa5573cbe7/wrapt-2.1.2-cp312-cp312-win32.whl", hash = "sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0", size = 58198, upload-time = "2026-03-06T02:53:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/8e/b2/feecfe29f28483d888d76a48f03c4c4d8afea944dbee2b0cd3380f9df032/wrapt-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63", size = 60441, upload-time = "2026-03-06T02:52:47.138Z" }, - { url = "https://files.pythonhosted.org/packages/44/e1/e328f605d6e208547ea9fd120804fcdec68536ac748987a68c47c606eea8/wrapt-2.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf", size = 58836, upload-time = "2026-03-06T02:53:22.053Z" }, - { url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" }, - { url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" }, - { url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" }, - { url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" }, - { url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" }, - { url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" }, - { url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" }, - { url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" }, - { url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" }, - { url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" }, - { url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" }, - { url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" }, - { url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" }, - { url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" }, - { url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" }, - { url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" }, - { url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" }, - { url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" }, - { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" }, - { url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" }, - { url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" }, - { url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" }, - { url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" }, - { url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" }, - { url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" }, - { url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" }, - { url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" }, - { url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" }, - { url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" }, - { url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" }, - { url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" }, - { url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" }, - { url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" }, - { url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" }, - { url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" }, - { url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" }, - { url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" }, - { url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" }, - { url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" }, - { url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" }, - { url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" }, - { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, ] [[package]]