diff --git a/.sampo/changesets/asgi-otel-span-context.md b/.sampo/changesets/asgi-otel-span-context.md new file mode 100644 index 00000000..a3d821ea --- /dev/null +++ b/.sampo/changesets/asgi-otel-span-context.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: patch +--- + +Add active PostHog request IDs to existing OpenTelemetry spans in ASGI middleware. diff --git a/.sampo/changesets/celery-otel-span-context.md b/.sampo/changesets/celery-otel-span-context.md new file mode 100644 index 00000000..0cb6c928 --- /dev/null +++ b/.sampo/changesets/celery-otel-span-context.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: patch +--- + +Add propagated task context to active Celery OpenTelemetry spans. diff --git a/.sampo/changesets/flask-otel-span-context.md b/.sampo/changesets/flask-otel-span-context.md new file mode 100644 index 00000000..fc475163 --- /dev/null +++ b/.sampo/changesets/flask-otel-span-context.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: patch +--- + +Add available PostHog request IDs to active Flask OpenTelemetry request spans. diff --git a/posthog/integrations/asgi.py b/posthog/integrations/asgi.py index 5d381c4c..d58f818d 100644 --- a/posthog/integrations/asgi.py +++ b/posthog/integrations/asgi.py @@ -34,6 +34,7 @@ from .. import contexts from ..client import Client +from ..tracing._context import _enrich_current_span from ..exception_utils import ( _ExceptionCaptureMetadata, _capture_exception_with_metadata, @@ -249,6 +250,8 @@ async def __call__(self, scope, receive, send) -> None: self.capture_tracing_headers, ) + _enrich_current_span() + properties = _extract_properties(scope, headers) if self.extra_properties: extra_properties = await _resolve_callback_result( diff --git a/posthog/integrations/celery.py b/posthog/integrations/celery.py index 59a513be..c1111792 100644 --- a/posthog/integrations/celery.py +++ b/posthog/integrations/celery.py @@ -67,6 +67,7 @@ import json import logging import time +from collections.abc import Mapping from typing import Any, Callable, Optional, cast from .. import contexts @@ -75,6 +76,7 @@ _ExceptionCaptureMetadata, _capture_exception_with_metadata, ) +from ..tracing._context import _enrich_span CONTEXT_DISTINCT_ID_HEADER = "X-POSTHOG-DISTINCT-ID" @@ -309,6 +311,7 @@ def _on_task_prerun(self, *args, **kwargs): request._posthog_start = time.monotonic() self._apply_propagated_identity(request) + self._enrich_task_span(task_id) merged_tags = {**task_properties, **context_tags} for key, value in merged_tags.items(): @@ -326,6 +329,23 @@ def _on_task_prerun(self, *args, **kwargs): except Exception: pass + @staticmethod + def _enrich_task_span(task_id: str) -> None: + try: + from opentelemetry.trace import get_current_span + + span = get_current_span() + attributes = getattr(span, "attributes", None) + if ( + isinstance(attributes, Mapping) + and attributes.get("celery.task_id") == task_id + ): + _enrich_span(span) + except ImportError: + pass + except Exception: + logger.debug("Failed to read current Celery task span", exc_info=True) + def _on_task_success(self, *args, **kwargs): self._handle_task_end("success", **kwargs) diff --git a/posthog/integrations/flask.py b/posthog/integrations/flask.py index 8c416e5d..345c3741 100644 --- a/posthog/integrations/flask.py +++ b/posthog/integrations/flask.py @@ -25,6 +25,7 @@ from .. import contexts from ..client import Client +from ..tracing._context import _enrich_current_span from ..exception_utils import ( _ExceptionCaptureMetadata, _capture_exception_with_metadata, @@ -201,6 +202,8 @@ def _before_request(self) -> None: for key, value in extra_properties.items(): contexts.tag(key, value) + _enrich_current_span() + @staticmethod def _request_properties(request: Request) -> dict[str, Any]: properties: dict[str, Any] = { diff --git a/posthog/test/integrations/test_asgi_otel_context.py b/posthog/test/integrations/test_asgi_otel_context.py new file mode 100644 index 00000000..2739eb83 --- /dev/null +++ b/posthog/test/integrations/test_asgi_otel_context.py @@ -0,0 +1,247 @@ +import asyncio +from contextlib import nullcontext +from unittest.mock import patch + +import pytest +from fastapi import FastAPI +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + +from posthog import contexts +from posthog.integrations.asgi import PosthogASGIMiddleware +from posthog.integrations.otel import PostHogContextSpanProcessor +from posthog.test.integrations.test_asgi_integration import ( + http_scope, + noop_receive, + noop_send, +) +from posthog.test.tracing.test_client_traces import ( + flush_and_capture, + make_client, + mock_session, + spans_from, +) + + +@pytest.fixture +def tracing(monkeypatch): + monkeypatch.setattr("posthog.project_api_key", "phc_key") + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(PostHogContextSpanProcessor()) + provider.add_span_processor(SimpleSpanProcessor(exporter)) + yield provider.get_tracer(__name__), exporter + provider.shutdown() + + +@pytest.mark.parametrize("scope_type", ["http", "websocket"]) +@pytest.mark.parametrize( + "trust,headers,attributes,fail,expected", + [ + ( + True, + None, + {}, + False, + {"sessionId": "session-123", "posthogDistinctId": "user-456"}, + ), + ( + True, + None, + {}, + True, + {"sessionId": "session-123", "posthogDistinctId": "user-456"}, + ), + (False, None, {}, False, {}), + (True, [], {}, False, {}), + ( + True, + [(b"x-posthog-session-id", b"\x00\n"), (b"x-posthog-distinct-id", b"\t ")], + {}, + False, + {}, + ), + ( + True, + None, + {"user.id": "authenticated"}, + False, + {"user.id": "authenticated", "sessionId": "session-123"}, + ), + ( + True, + None, + {"sessionId": "explicit-session", "posthogDistinctId": "explicit-person"}, + False, + {"sessionId": "explicit-session", "posthogDistinctId": "explicit-person"}, + ), + ], +) +async def test_asgi_enriches_existing_request_and_child_spans( + tracing, scope_type, trust, headers, attributes, fail, expected +): + tracer, exporter = tracing + + async def app(scope, receive, send): + tracer.start_span("child", attributes=attributes).end() + if fail: + raise ValueError("handler failed") + await send( + {"type": "websocket.close"} + if scope_type == "websocket" + else {"type": "http.response.body", "body": b""} + ) + + scope = http_scope(type=scope_type) + if headers is not None: + scope["headers"] = headers + middleware = PosthogASGIMiddleware( + app, capture_exceptions=False, capture_tracing_headers=trust + ) + with contexts.new_context(fresh=True, capture_exceptions=False): + with ( + pytest.raises(ValueError, match="handler failed") if fail else nullcontext() + ): + with tracer.start_as_current_span("request", attributes=attributes): + await middleware(scope, noop_receive, noop_send) + assert contexts.get_context_session_id() is None + assert contexts.get_context_distinct_id() is None + tracer.start_span("after-request").end() + + spans = {span.name: span for span in exporter.get_finished_spans()} + for name in ("request", "child"): + assert dict(spans[name].attributes) == expected + assert dict(spans["after-request"].attributes) == {} + + +async def test_asgi_concurrent_requests_and_ended_task_context(tracing): + tracer, exporter = tracing + both_started = asyncio.Event() + finished = asyncio.Event() + held_spans = [] + background_tasks = [] + + async def background(name): + await finished.wait() + tracer.start_span("late-" + name).end() + + async def app(scope, receive, send): + name = scope["path"] + held_spans.append(tracer.start_span("held-" + name)) + background_tasks.append(asyncio.create_task(background(name))) + if len(held_spans) == 2: + both_started.set() + await both_started.wait() + tracer.start_span("child-" + name).end() + + middleware = PosthogASGIMiddleware(app, capture_tracing_headers=True) + + async def request(name): + with tracer.start_as_current_span("request-" + name): + await middleware( + http_scope( + path=name, + headers=[ + (b"x-posthog-session-id", ("session-" + name).encode()), + (b"x-posthog-distinct-id", ("person-" + name).encode()), + ], + ), + noop_receive, + noop_send, + ) + + with contexts.new_context(fresh=True, capture_exceptions=False): + await asyncio.gather(request("first"), request("second")) + finished.set() + await asyncio.gather(*background_tasks) + for span in held_spans: + span.end() + + spans = {span.name: span for span in exporter.get_finished_spans()} + for name in ("first", "second"): + for prefix in ("request-", "child-", "held-"): + assert dict(spans[prefix + name].attributes) == { + "sessionId": "session-" + name, + "posthogDistinctId": "person-" + name, + } + assert dict(spans["late-" + name].attributes) == {} + + +@pytest.mark.parametrize("scope_type,filtered", [("http", True), ("lifespan", False)]) +async def test_asgi_bypassed_scopes_do_not_enrich_spans(tracing, scope_type, filtered): + tracer, exporter = tracing + + async def app(scope, receive, send): + tracer.start_span("child").end() + + middleware = PosthogASGIMiddleware( + app, + capture_tracing_headers=True, + request_filter=(lambda scope: False) if filtered else None, + ) + with contexts.new_context(fresh=True, capture_exceptions=False): + with tracer.start_as_current_span("request"): + await middleware(http_scope(type=scope_type), noop_receive, noop_send) + assert all(dict(span.attributes) == {} for span in exporter.get_finished_spans()) + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("fail", [False, True]) +async def test_fastapi_sync_and_async_handlers_enrich_native_and_otel_spans( + tracing, asynchronous, fail +): + tracer, exporter = tracing + native = make_client(traces={}) + + def handler(): + native.start_span("native").end() + tracer.start_span("child").end() + if fail: + raise ValueError("handler failed") + return {"ok": True} + + async def async_handler(): + return handler() + + app = FastAPI() + app.add_api_route("/api/items", async_handler if asynchronous else handler) + app.add_middleware( + PosthogASGIMiddleware, capture_tracing_headers=True, capture_exceptions=False + ) + responses = [] + + async def send(message): + responses.append(message) + + try: + with ( + patch( + "posthog.tracing._transport._get_session", return_value=mock_session() + ), + contexts.new_context(fresh=True, capture_exceptions=False), + ): + with ( + pytest.raises(ValueError, match="handler failed") + if fail + else nullcontext() + ): + with tracer.start_as_current_span("request"): + await app(http_scope(), noop_receive, send) + assert contexts.get_context_session_id() is None + assert contexts.get_context_distinct_id() is None + payload, _, _ = flush_and_capture(native) + finally: + native.shutdown() + + assert responses[0]["status"] == (500 if fail else 200) + spans = {span.name: span for span in exporter.get_finished_spans()} + for name in ("request", "child"): + assert dict(spans[name].attributes) == { + "sessionId": "session-123", + "posthogDistinctId": "user-456", + } + [span] = spans_from(payload) + attributes = {item["key"]: item["value"] for item in span["attributes"]} + assert attributes["sessionId"] == {"stringValue": "session-123"} + assert attributes["posthogDistinctId"] == {"stringValue": "user-456"} diff --git a/posthog/test/integrations/test_celery_otel_context.py b/posthog/test/integrations/test_celery_otel_context.py new file mode 100644 index 00000000..678722cd --- /dev/null +++ b/posthog/test/integrations/test_celery_otel_context.py @@ -0,0 +1,238 @@ +from unittest.mock import Mock + +import pytest +from celery import Celery +from opentelemetry import trace +from opentelemetry.instrumentation.celery import CeleryInstrumentor +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + +from posthog import contexts +from posthog.integrations.celery import ( + CONTEXT_DISTINCT_ID_HEADER, + CONTEXT_SESSION_ID_HEADER, + PosthogCeleryIntegration, +) +from posthog.integrations.otel import PostHogContextSpanProcessor + + +@pytest.fixture +def celery_tracing(): + app = Celery("span-context", broker="memory://") + integration = PosthogCeleryIntegration( + client=Mock(disabled=False), + capture_exceptions=False, + capture_task_lifecycle_events=False, + ) + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(PostHogContextSpanProcessor()) + provider.add_span_processor(SimpleSpanProcessor(exporter)) + integration.instrument() + try: + yield app, provider.get_tracer(__name__), exporter, provider + finally: + integration.shutdown() + provider.shutdown() + app.close() + + +@pytest.mark.parametrize("state", ["success", "failure", "retry"]) +@pytest.mark.parametrize("propagated", [True, False]) +def test_eager_task_enriches_existing_span_and_clears_context( + celery_tracing, state, propagated +): + app, tracer, exporter, _ = celery_tracing + headers = ( + { + CONTEXT_DISTINCT_ID_HEADER: "job-person", + CONTEXT_SESSION_ID_HEADER: "job-session", + } + if propagated + else {} + ) + expected = ( + {"posthogDistinctId": "job-person", "sessionId": "job-session"} + if propagated + else {} + ) + attempts = [] + + @app.task(bind=True, name="span-context.task") + def task(self): + attempts.append(self.request.retries) + tracer.start_span("child").end() + assert contexts.get_context_distinct_id() == ( + "job-person" if propagated else None + ) + assert contexts.get_context_session_id() == ( + "job-session" if propagated else None + ) + if state == "failure": + raise ValueError("task failed") + if state == "retry" and self.request.retries == 0: + raise self.retry(exc=ValueError("try again"), countdown=0) + return "done" + + task_id = "owned-task" + task_span = tracer.start_span("task", attributes={"celery.task_id": task_id}) + with ( + trace.use_span(task_span, end_on_exit=True), + contexts.new_context(fresh=True, capture_exceptions=False), + ): + contexts.identify_context("outer-person") + contexts.set_context_session("outer-session") + result = task.apply(task_id=task_id, headers=headers, throw=False) + assert result.state == ("FAILURE" if state == "failure" else "SUCCESS") + assert contexts.get_context_distinct_id() == "outer-person" + assert contexts.get_context_session_id() == "outer-session" + + assert attempts == ([0, 1] if state == "retry" else [0]) + tracer.start_span("after-task").end() + for span in exporter.get_finished_spans(): + span_expected = {} if span.name == "after-task" else expected + if span.name == "task": + span_expected = {**span_expected, "celery.task_id": task_id} + assert dict(span.attributes) == span_expected + + +def test_published_context_reaches_task_span_without_worker_context_bleed( + celery_tracing, +): + app, tracer, exporter, _ = celery_tracing + + @app.task(name="span-context.published") + def task(): + tracer.start_span("child").end() + return contexts.get_context_distinct_id(), contexts.get_context_session_id() + + with contexts.new_context(fresh=True, capture_exceptions=False): + contexts.identify_context("producer-person") + contexts.set_context_session("producer-session") + task.apply_async() + + with app.connection_for_read() as connection: + with connection.SimpleQueue("celery") as queue: + message = queue.get(block=False) + worker_headers = message.headers["headers"] + message.ack() + + with tracer.start_as_current_span( + "task", attributes={"celery.task_id": "published"} + ): + result = task.apply(task_id="published", headers=worker_headers) + assert result.get() == ("producer-person", "producer-session") + + with tracer.start_as_current_span( + "unlinked-task", attributes={"celery.task_id": "unlinked"} + ): + assert task.apply(task_id="unlinked").get() == (None, None) + expected = { + "posthogDistinctId": "producer-person", + "sessionId": "producer-session", + } + spans = exporter.get_finished_spans() + assert [dict(span.attributes) for span in spans] == [ + expected, + {**expected, "celery.task_id": "published"}, + {}, + {"celery.task_id": "unlinked"}, + ] + + +@pytest.mark.parametrize("state", ["success", "failure", "retry"]) +def test_official_eager_task_spans_do_not_stamp_the_caller(celery_tracing, state): + app, tracer, exporter, provider = celery_tracing + instrumentor = CeleryInstrumentor() + instrumentor.instrument(tracer_provider=provider) + + @app.task(bind=True, name="span-context.official") + def task(self): + tracer.start_span(f"child-{self.request.id}").end() + if state == "failure": + raise ValueError("task failed") + if state == "retry" and self.request.retries == 0: + raise self.retry(exc=ValueError("try again"), countdown=0) + return "done" + + try: + with tracer.start_as_current_span("caller") as caller: + for name in ("first", "second"): + result = task.apply( + task_id=name, + headers={ + CONTEXT_DISTINCT_ID_HEADER: f"{name}-person", + CONTEXT_SESSION_ID_HEADER: f"{name}-session", + }, + throw=False, + ) + assert result.state == ("FAILURE" if state == "failure" else "SUCCESS") + assert dict(caller.attributes) == {} + assert contexts.get_context_distinct_id() is None + assert contexts.get_context_session_id() is None + tracer.start_span("after-tasks").end() + finally: + instrumentor.uninstrument() + + task_spans = [ + span + for span in exporter.get_finished_spans() + if span.kind == trace.SpanKind.CONSUMER + ] + assert len(task_spans) == (4 if state == "retry" else 2) + for span in exporter.get_finished_spans(): + if span.name in ("caller", "after-tasks"): + assert dict(span.attributes) == {} + else: + name = ( + span.name.removeprefix("child-") + if span.name.startswith("child-") + else span.attributes["messaging.message.id"] + ) + assert span.attributes["posthogDistinctId"] == f"{name}-person" + assert span.attributes["sessionId"] == f"{name}-session" + + +@pytest.mark.parametrize( + "attributes", [{}, {"celery.task_id": "other-task"}, {"celery.task_id": 42}] +) +def test_eager_task_does_not_enrich_an_unverified_span(celery_tracing, attributes): + app, tracer, exporter, _ = celery_tracing + + @app.task(name="span-context.unverified") + def task(): + tracer.start_span("child").end() + + with tracer.start_as_current_span("task", attributes=attributes): + task.apply( + task_id="actual-task", headers={CONTEXT_SESSION_ID_HEADER: "job-session"} + ) + child, parent = exporter.get_finished_spans() + assert dict(child.attributes) == {"sessionId": "job-session"} + assert dict(parent.attributes) == attributes + + +@pytest.mark.parametrize("api_state", ["missing", "failing", "unreadable"]) +def test_task_context_survives_unavailable_current_span_api( + celery_tracing, monkeypatch, api_state +): + app, _, _, _ = celery_tracing + if api_state == "missing": + monkeypatch.delattr(trace, "get_current_span") + elif api_state == "failing": + monkeypatch.setattr( + trace, "get_current_span", Mock(side_effect=RuntimeError("lookup failed")) + ) + else: + monkeypatch.setattr(trace, "get_current_span", lambda: object()) + + @app.task(name="span-context.unavailable") + def task(): + return contexts.get_context_session_id() + + assert ( + task.apply(headers={CONTEXT_SESSION_ID_HEADER: "job-session"}).get() + == "job-session" + ) + assert contexts.get_context_session_id() is None diff --git a/posthog/test/integrations/test_flask_spans.py b/posthog/test/integrations/test_flask_spans.py new file mode 100644 index 00000000..59de68d3 --- /dev/null +++ b/posthog/test/integrations/test_flask_spans.py @@ -0,0 +1,177 @@ +from concurrent.futures import ThreadPoolExecutor +from contextlib import nullcontext +from threading import Barrier +from unittest.mock import patch + +import pytest +from flask import Flask +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + +from posthog import contexts +from posthog.integrations.flask import PosthogFlaskIntegration +from posthog.integrations.otel import PostHogContextSpanProcessor +from posthog.test.tracing.test_client_traces import ( + flush_and_capture, + make_client, + mock_session, + spans_from, +) + + +@pytest.fixture +def tracing(monkeypatch): + monkeypatch.setattr("posthog.project_api_key", "phc_key") + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(PostHogContextSpanProcessor()) + provider.add_span_processor(SimpleSpanProcessor(exporter)) + yield provider.get_tracer(__name__), exporter + provider.shutdown() + + +@pytest.fixture +def native_client(): + client = make_client(traces={}) + with patch("posthog.tracing._transport._get_session", return_value=mock_session()): + yield client + client.shutdown() + + +@pytest.mark.parametrize("fail", [False, True]) +def test_flask_enriches_existing_request_and_child_spans_and_cleans_up( + tracing, native_client, fail +): + tracer, exporter = tracing + app = Flask(__name__) + app.config["TESTING"] = True + PosthogFlaskIntegration(app, capture_tracing_headers=True, capture_exceptions=False) + + @app.get("/") + def view(): + native_client.start_span("native").end() + tracer.start_span("child").end() + if fail: + raise ValueError("view failed") + return "response" + + with contexts.new_context(fresh=True, capture_exceptions=False): + with pytest.raises(ValueError, match="view failed") if fail else nullcontext(): + with tracer.start_as_current_span("request"): + response = app.test_client().get( + "/", + headers={ + "X-PostHog-Distinct-ID": "browser-person", + "X-PostHog-Session-ID": "browser-session", + }, + ) + assert response.status_code == 200 + tracer.start_span("after-request").end() + assert contexts.get_context_distinct_id() is None + assert contexts.get_context_session_id() is None + + spans = {span.name: dict(span.attributes) for span in exporter.get_finished_spans()} + expected = { + "posthogDistinctId": "browser-person", + "sessionId": "browser-session", + } + assert spans == {"request": expected, "child": expected, "after-request": {}} + payload, _, _ = flush_and_capture(native_client) + (native,) = spans_from(payload) + assert { + attribute["key"]: attribute["value"]["stringValue"] + for attribute in native["attributes"] + } == expected + + +@pytest.mark.parametrize( + "trust,headers,attributes,expected", + [ + ( + False, + {"X-PostHog-Distinct-ID": "person", "X-PostHog-Session-ID": "session"}, + {}, + {}, + ), + (True, {}, {}, {}), + (True, {"X-PostHog-Distinct-ID": " ", "X-PostHog-Session-ID": " "}, {}, {}), + (True, {"X-PostHog-Session-ID": "session"}, {}, {"sessionId": "session"}), + ( + True, + {"X-PostHog-Distinct-ID": "person"}, + {}, + {"posthogDistinctId": "person"}, + ), + ( + True, + {"X-PostHog-Distinct-ID": "person", "X-PostHog-Session-ID": "session"}, + {"posthogDistinctId": "own-person", "sessionId": "own-session"}, + {"posthogDistinctId": "own-person", "sessionId": "own-session"}, + ), + ( + True, + {"X-PostHog-Distinct-ID": "person", "X-PostHog-Session-ID": "session"}, + {"user.id": "authenticated"}, + {"user.id": "authenticated", "sessionId": "session"}, + ), + ], +) +def test_flask_request_span_respects_header_policy_and_explicit_identity( + tracing, trust, headers, attributes, expected +): + tracer, exporter = tracing + app = Flask(__name__) + PosthogFlaskIntegration( + app, capture_tracing_headers=trust, capture_exceptions=False + ) + app.add_url_rule("/", view_func=lambda: "response") + + with contexts.new_context(fresh=True, capture_exceptions=False): + with tracer.start_as_current_span("request", attributes=attributes): + assert app.test_client().get("/", headers=headers).status_code == 200 + + assert dict(exporter.get_finished_spans()[0].attributes) == expected + + +def test_flask_concurrent_requests_keep_separate_span_context(tracing): + tracer, exporter = tracing + app = Flask(__name__) + both_started = Barrier(2) + PosthogFlaskIntegration(app, capture_tracing_headers=True, capture_exceptions=False) + + @app.get("/") + def view(identity): + both_started.wait(timeout=5) + tracer.start_span(identity + "-child").end() + return "response" + + def run_request(identity): + with contexts.new_context(fresh=True, capture_exceptions=False): + with tracer.start_as_current_span(identity): + assert ( + app.test_client() + .get( + "/" + identity, + headers={ + "X-PostHog-Distinct-ID": identity, + "X-PostHog-Session-ID": identity + "-session", + }, + ) + .status_code + == 200 + ) + tracer.start_span(identity + "-after").end() + + with ThreadPoolExecutor(max_workers=2) as executor: + list(executor.map(run_request, ("first", "second"))) + + spans = {span.name: dict(span.attributes) for span in exporter.get_finished_spans()} + for identity in ("first", "second"): + expected = { + "posthogDistinctId": identity, + "sessionId": identity + "-session", + } + assert spans[identity] == expected + assert spans[identity + "-child"] == expected + assert spans[identity + "-after"] == {} diff --git a/pyproject.toml b/pyproject.toml index 44e84c33..f6969b17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,6 +70,9 @@ dev = [ test = [ "freezegun==1.5.1", "flask>=2.2", + "celery>=5.3,<6", + "opentelemetry-instrumentation-celery>=0.41b0", + "fastapi>=0.115,<1", "python-dateutil>=2.9.0.post0", "tzdata", "coverage", diff --git a/references/middleware_configuration.md b/references/middleware_configuration.md index 70cc236e..472d2dc9 100644 --- a/references/middleware_configuration.md +++ b/references/middleware_configuration.md @@ -122,6 +122,35 @@ request handlers use the same bridge. Tracing headers are captured by default. Set `POSTHOG_MW_CAPTURE_TRACING_HEADERS = False` to disable extraction. These headers remain untrusted analytics context. +Flask's `PosthogFlaskIntegration` adds IDs to the active OpenTelemetry SDK +request span after its request hook sets the context. Keep OpenTelemetry's WSGI +middleware outside the Flask app so that its request span is active before +Flask runs `before_request` hooks. Browser session and person headers are captured +by default. Set `capture_tracing_headers=False` to disable extraction. Register the shared processor above for +child spans; the request hook alone does not enrich later OpenTelemetry spans. +The application must set authenticated identity on the request span before the +PostHog hook if that identity must take precedence over a browser header. +Requests excluded by `request_filter` receive no enrichment. Flask request +context closes in `teardown_request`, including when the view raises an error. + +ASGI's `PosthogASGIMiddleware` also adds IDs after it reads the headers. This +covers an active recording request span started by outer tracing middleware. +FastAPI applications use the same ASGI middleware: + +```python +from posthog.integrations.asgi import PosthogASGIMiddleware + +app.add_middleware(PosthogASGIMiddleware) +``` + +Keep OpenTelemetry request tracing outside this middleware and register +`PostHogContextSpanProcessor` for child spans. Both async handlers and FastAPI's +sync handlers run within the request context. HTTP and WebSocket scopes are +supported; WebSocket tracing requires the application to supply a recording span. +Lifespan scopes and requests rejected by `request_filter` pass through without +span enrichment. The middleware does not create request spans. Spans started by +outer error-response handlers after the PostHog scope exits omit request IDs. + For a custom request integration, call `enrich_current_span()` from `posthog.integrations.otel` once after setting the request IDs. This call handles an already active recording SDK span. The processor handles spans started later @@ -135,3 +164,34 @@ A background task can set its own IDs in a new `new_context()` scope. Only IDs from active scopes are used. Set `fresh=True` to prevent inheritance from any parent scope. The AI OpenTelemetry processor is a separate integration with different attributes. + +## Celery task spans + +`PosthogCeleryIntegration` carries context through its existing task message +headers when `propagate_context=True` (the default). Instrument the producer and +each worker process. A worker starts a fresh PostHog context before each task and +restores the prior context after success, failure, or retry. A task without +PostHog headers does not inherit identity or session IDs from an enclosing scope. + +Add `PostHogContextSpanProcessor` to the application's existing provider for +child spans. The Celery integration also enriches an active task span after it +reads the message headers, but only when its public `celery.task_id` attribute +exactly matches the current task ID. Other active spans remain unchanged. +It does not create task spans. To create task spans, +install and configure `opentelemetry-instrumentation-celery` separately. + +Call `PosthogCeleryIntegration.instrument()` before +`CeleryInstrumentor().instrument(tracer_provider=provider)`. This order ensures +that PostHog restores the job context before OpenTelemetry starts the task span. +This also matters for eager and nested tasks: with the reverse order, the span +processor can read the enclosing context before the task context is available. +Existing span attributes are preserved, so the later task hook cannot replace +those values. Official Celery instrumentation does not expose a matching +`celery.task_id` at this hook. The later task hook therefore cannot repair task +spans created in the reverse order. + +Celery's eager execution does not emit task publish signals. To carry context +into an eager task, pass the task's PostHog headers explicitly with +`task.apply(headers=...)` or `task.apply_async(headers=...)`. Do not use these +analytics headers for authentication or authorization. Task context propagation +does not grant a background task access to an ended request scope. diff --git a/uv.lock b/uv.lock index 96913163..ae1aff66 100644 --- a/uv.lock +++ b/uv.lock @@ -215,6 +215,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] +[[package]] +name = "amqp" +version = "5.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/41/63526ffa542b7dbeb671ab2252fb38e26cd2dbc68c0775cdc5ba11af78a7/amqp-5.4.1.tar.gz", hash = "sha256:79a9c0ab70e71745667f127ff80666894a734c26236b6f33149c964b096f0b20", size = 132240, upload-time = "2026-10-05T14:03:23.415Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/8e/25f762f8cf0da76c7b1a66a9cadc291168537598c533954b0e2c9de3a0a3/amqp-5.4.1-py3-none-any.whl", hash = "sha256:ac2b816a14a380ed10c5ebbf85a334fd68111fa476496867a5ccd2fd09926d5e", size = 51858, upload-time = "2026-10-05T14:03:18.61Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -355,6 +376,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, ] +[[package]] +name = "billiard" +version = "4.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/0d/8921e960be19fa226358bf933509f57ec679d9b35a1e7ea43460af4b7fef/billiard-4.3.1.tar.gz", hash = "sha256:c88559b306ee5dc93f8d5f843d07da15d795d67af26720d14ee9d09f09eb0b22", size = 166478, upload-time = "2026-10-05T06:38:30.496Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/b1/360936699597063a2d9863aa94ccc3a6951e906ced032a9a1d8e562fc56b/billiard-4.3.1-py3-none-any.whl", hash = "sha256:2c7075283191d9c0add66cf8fca8e06ba599e75fe7319b67186759f8877dfdaf", size = 90178, upload-time = "2026-10-05T06:38:28.373Z" }, +] + [[package]] name = "blinker" version = "1.9.0" @@ -402,6 +432,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, ] +[[package]] +name = "celery" +version = "5.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "billiard" }, + { name = "click" }, + { name = "click-didyoumean" }, + { name = "click-plugins" }, + { name = "click-repl" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "kombu" }, + { name = "python-dateutil" }, + { name = "tzlocal" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/b4/a1233943ab5c8ea05fb877a88a0a0622bf47444b99e4991a8045ac37ea1d/celery-5.6.3.tar.gz", hash = "sha256:177006bd2054b882e9f01be59abd8529e88879ef50d7918a7050c5a9f4e12912", size = 1742243, upload-time = "2026-03-26T12:14:51.76Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/c9/6eccdda96e098f7ae843162db2d3c149c6931a24fda69fe4ab84d0027eb5/celery-5.6.3-py3-none-any.whl", hash = "sha256:0808f42f80909c4d5833202360ffafb2a4f83f4d8e23e1285d926610e9a7afa6", size = 451235, upload-time = "2026-03-26T12:14:49.491Z" }, +] + [[package]] name = "certifi" version = "2025.6.15" @@ -622,6 +673,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] +[[package]] +name = "click-didyoumean" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" }, +] + +[[package]] +name = "click-plugins" +version = "1.1.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" }, +] + +[[package]] +name = "click-repl" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "prompt-toolkit" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/50/bea78619ff1fc0fbd61882f64a1302a8abb2ea0b3db92907042d0e362df2/click_repl-0.4.1.tar.gz", hash = "sha256:c32a1cf6f95e5bd6e92076f81ce24eafd33f2f0ffb0135887e335b8e446d1c0b", size = 16403, upload-time = "2026-10-05T06:01:57.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f6/12dc0f2e0159c2b416818b7fedcda15b520043773364a81d7389809a5af5/click_repl-0.4.1-py3-none-any.whl", hash = "sha256:5cb10881d4c5ebaa8695eceb69911af3062ee78342812b713564b17aad333eb5", size = 14988, upload-time = "2026-10-05T06:01:55.611Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -895,6 +984,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, ] +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + [[package]] name = "fastmcp" version = "3.2.0" @@ -1639,6 +1744,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd", size = 39085, upload-time = "2024-12-25T15:26:44.377Z" }, ] +[[package]] +name = "kombu" +version = "5.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "amqp" }, + { name = "packaging" }, + { name = "tzdata" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/a5/607e533ed6c83ae1a696969b8e1c137dfebd5759a2e9682e26ff1b97740b/kombu-5.6.2.tar.gz", hash = "sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55", size = 472594, upload-time = "2025-12-29T20:30:07.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93", size = 214219, upload-time = "2025-12-29T20:30:05.74Z" }, +] + [[package]] name = "langchain" version = "1.4.3" @@ -2576,6 +2696,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/3a/8865d6754e61c9fb170cdd530a124a53769ee5f740236064816eb0ca7301/opentelemetry_exporter_otlp_proto_http-1.40.0-py3-none-any.whl", hash = "sha256:a8d1dab28f504c5d96577d6509f80a8150e44e8f45f82cdbe0e34c99ab040069", size = 19960, upload-time = "2026-03-04T14:17:07.153Z" }, ] +[[package]] +name = "opentelemetry-instrumentation" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/37/6bf8e66bfcee5d3c6515b79cb2ee9ad05fe573c20f7ceb288d0e7eeec28c/opentelemetry_instrumentation-0.61b0.tar.gz", hash = "sha256:cb21b48db738c9de196eba6b805b4ff9de3b7f187e4bbf9a466fa170514f1fc7", size = 32606, upload-time = "2026-03-04T14:20:16.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/3e/f6f10f178b6316de67f0dfdbbb699a24fbe8917cf1743c1595fb9dcdd461/opentelemetry_instrumentation-0.61b0-py3-none-any.whl", hash = "sha256:92a93a280e69788e8f88391247cc530fd81f16f2b011979d4d6398f805cfbc63", size = 33448, upload-time = "2026-03-04T14:19:02.447Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-celery" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/43/e79108a804d16b1dc8ff28edd0e94ac393cf6359a5adcd7cdd2ec4be85f4/opentelemetry_instrumentation_celery-0.61b0.tar.gz", hash = "sha256:0e352a567dc89ed8bc083fc635035ce3c5b96bbbd92831ffd676e93b87f8e94f", size = 14780, upload-time = "2026-03-04T14:20:27.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/ed/c05f3c84b455654eb6c047474ffde61ed92efc24030f64213c98bca9d44b/opentelemetry_instrumentation_celery-0.61b0-py3-none-any.whl", hash = "sha256:01235733ff0cdf571cb03b270645abb14b9c8d830313dc5842097ec90146320b", size = 13856, upload-time = "2026-03-04T14:19:20.98Z" }, +] + [[package]] name = "opentelemetry-proto" version = "1.40.0" @@ -2870,10 +3019,12 @@ otel = [ ] test = [ { name = "anthropic" }, + { name = "celery" }, { name = "claude-agent-sdk" }, { name = "coverage" }, { name = "django" }, { name = "djangorestframework" }, + { name = "fastapi" }, { name = "fastmcp" }, { name = "flask" }, { name = "freezegun" }, @@ -2890,6 +3041,7 @@ test = [ { name = "mcp" }, { name = "openai-agents" }, { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation-celery" }, { name = "opentelemetry-sdk" }, { name = "parameterized" }, { name = "pydantic" }, @@ -2915,12 +3067,14 @@ dev = [ requires-dist = [ { name = "anthropic", marker = "extra == 'test'", specifier = ">=0.72" }, { name = "backoff", specifier = ">=1.10.0" }, + { name = "celery", marker = "extra == 'test'", specifier = ">=5.3,<6" }, { name = "claude-agent-sdk", marker = "extra == 'test'" }, { name = "coverage", marker = "extra == 'test'" }, { name = "distro", specifier = ">=1.5.0" }, { name = "django", marker = "extra == 'test'", specifier = ">=5.2.15,<6.0" }, { name = "django-stubs", marker = "extra == 'dev'" }, { name = "djangorestframework", marker = "extra == 'test'", specifier = ">=3.15,<4" }, + { name = "fastapi", marker = "extra == 'test'", specifier = ">=0.115,<1" }, { name = "fastmcp", marker = "extra == 'test'", specifier = ">=2.0" }, { name = "flask", marker = "extra == 'test'", specifier = ">=2.2" }, { name = "freezegun", marker = "extra == 'test'", specifier = "==1.5.1" }, @@ -2944,6 +3098,7 @@ requires-dist = [ { name = "openai-agents", marker = "extra == 'test'", specifier = ">=0.18" }, { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'otel'", specifier = ">=1.20.0" }, { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'test'", specifier = ">=1.20.0" }, + { name = "opentelemetry-instrumentation-celery", marker = "extra == 'test'", specifier = ">=0.41b0" }, { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.20.0" }, { name = "opentelemetry-sdk", marker = "extra == 'test'", specifier = ">=1.20.0" }, { name = "packaging", marker = "extra == 'dev'" }, @@ -2992,6 +3147,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" }, ] +[[package]] +name = "prompt-toolkit" +version = "3.0.53" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" }, +] + [[package]] name = "propcache" version = "0.3.2" @@ -4164,6 +4331,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, ] +[[package]] +name = "tzlocal" +version = "5.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/5b/879b2f932adfa7a053c360d50bc896c977fa6426109185f7c12ebdd0cb9d/tzlocal-5.4.4.tar.gz", hash = "sha256:8dbb8660838688a7b6ba4fed31d18dedf842afb4d47ca050d6d891c2c15f3be4", size = 31170, upload-time = "2026-06-29T08:03:40.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/a4/017a7a6cbe387d961a688ec31364ae60a5c4e22c96ae9921b79a947c855d/tzlocal-5.4.4-py3-none-any.whl", hash = "sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15", size = 18115, upload-time = "2026-06-29T08:03:38.666Z" }, +] + [[package]] name = "uncalled-for" version = "0.3.2" @@ -4225,6 +4404,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, ] +[[package]] +name = "vine" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" }, +] + [[package]] name = "virtualenv" version = "21.7.13" @@ -4358,6 +4546,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, ] +[[package]] +name = "wcwidth" +version = "0.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/b4/7830542634bb2d3e62aa3b586a72d5b3b6c91c3168929e7000ef3fed041d/wcwidth-0.9.2.tar.gz", hash = "sha256:ae0ef90b90f6af38b54f1fe6d58662ec33b3cb4b8391958a62416d654231727b", size = 955039, upload-time = "2026-10-05T00:24:05.521Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/1e/4532a81fb9dfbf4114a816775e0a36c3a64ee1d1f4bba2094e2da50be5dc/wcwidth-0.9.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:7ef5a940bd5e30bac6e721f1a48fce0cd7bb3ece19e9c5d139e72c76c35cfd07", size = 605343, upload-time = "2026-10-05T00:23:22.649Z" }, + { url = "https://files.pythonhosted.org/packages/a0/07/cb6940e81134b7ed25fa312ee9ab536a63db0793b149f88a90e603ceace9/wcwidth-0.9.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:ae0800c5339423cc53d33a266ad264b42ba8aaa16d4464f6e6b1bee607f50b17", size = 609960, upload-time = "2026-10-05T00:23:27.049Z" }, + { url = "https://files.pythonhosted.org/packages/a4/80/15ad05d40bfa99155639fb9e13b3d77083aa0fab893c816db2543d29005c/wcwidth-0.9.2-cp310-abi3-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:9e542f1f8475b78452a295495d7a5bc3ead565112e9446a64dc93462a41c2a79", size = 762566, upload-time = "2026-10-05T00:23:38.322Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f0/b8ef7758003d66b60f093695831a86dcc726aac01ee6446ffcbda27b61e3/wcwidth-0.9.2-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:674b518af28d38ee645ff97b74f5760abee5fad4bac74413bfc4b881ef2ce724", size = 771632, upload-time = "2026-10-05T00:23:32.448Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/f940133c71427c208575910e981942bd78c98b1f7cd0d1425ca4b7457c04/wcwidth-0.9.2-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:751bef0ab404b6a1dc028b56b4b85d46486be1c55833f80da533e42dc691f389", size = 769763, upload-time = "2026-10-05T00:23:40.175Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/285f862826f721964ec7c42f81dc53d23afbd723a0f4cd989651f8218e25/wcwidth-0.9.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c3d80f39ba4653a595edae9aa46a509d14883790a8fc23c5db221ceb207f64b7", size = 782134, upload-time = "2026-10-05T00:23:33.926Z" }, + { url = "https://files.pythonhosted.org/packages/c2/2d/64aa54882a5d556d3654c1f926d9118b797461033e23a158409941a37c8f/wcwidth-0.9.2-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:0a47e03d8293590ecce66c45dc20ff7b4b885e3c78093722239585eca0d77ab2", size = 782851, upload-time = "2026-10-05T00:23:41.974Z" }, + { url = "https://files.pythonhosted.org/packages/59/39/52389f6de7fe2e9c14ceb8253dd99034bd86e1c87847ea3c100a97dded9a/wcwidth-0.9.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:67d901a4ad99249eb775b4ee4769ca97fa405d35a75f46e83166910a47003f04", size = 784621, upload-time = "2026-10-05T00:23:43.449Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/20225500a076ace27bbcc8a6fd7c55125133c57a618816c7b7b8b73070b1/wcwidth-0.9.2-cp310-abi3-win32.whl", hash = "sha256:ee1fd0db9d9fd711a70f3e7765e0e04c05d26982fa05361456163062549d7da4", size = 593123, upload-time = "2026-10-05T00:23:55.953Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/b0690f55ea0483530a18bac917fbadbf54f35122510446fc370f5f1c2453/wcwidth-0.9.2-cp310-abi3-win_amd64.whl", hash = "sha256:2a9746de704242bd4fdaabb31dd46b82f694a56a8d21081ad89b679a89da9fec", size = 597827, upload-time = "2026-10-05T00:23:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/e5/11/6ecf4e9e268ab1a4ec617ffcccc2ee4a71301625f5490912dbaba462fa9c/wcwidth-0.9.2-cp310-abi3-win_arm64.whl", hash = "sha256:b9c6ab615e03723b7f8760ea2f27758d656e7e13b51515c9dca5c3e8b04612fa", size = 598415, upload-time = "2026-10-05T00:23:51.517Z" }, + { url = "https://files.pythonhosted.org/packages/4e/41/549eef1ab767032bdbdc1f0ab655d404b082b1e9a1dab1361dbba90f64ed/wcwidth-0.9.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eda88ffdc97c0fbf193d407114f2c7a54b379f67f6e52a7531ee3b9fe749eca7", size = 606555, upload-time = "2026-10-05T00:23:24.188Z" }, + { url = "https://files.pythonhosted.org/packages/9b/64/a875ed7ea71cacadc0ae11b5fd3fac3486efd58bb25e67a7344248dceadd/wcwidth-0.9.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bf361c8705576760623b4724ae564666d73b016f9a778bcfd1c7345378ef4ec", size = 610829, upload-time = "2026-10-05T00:23:28.563Z" }, + { url = "https://files.pythonhosted.org/packages/c6/98/513095e484fe79b6f2613d6a72f855f5d56b65e15c215c2a6746fbc638f5/wcwidth-0.9.2-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:97b878d1e158da5ed9ac5aac53fa3a55e282103af6a09ec353865613d1a31a76", size = 775819, upload-time = "2026-10-05T00:23:45.116Z" }, + { url = "https://files.pythonhosted.org/packages/22/fc/c02f3eec57224731e78f84b68e272250f784b6205acc7e0dcef6a7c23a0e/wcwidth-0.9.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:59dab4049cbd982b478bca098528df2c79a9160636a3a163ffebffcbd7d1b892", size = 786915, upload-time = "2026-10-05T00:23:35.323Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/b0529a79bac3fe8d94f32b4237a13dbc3f955508753f6a6f06c73d679dc2/wcwidth-0.9.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb08ceb501d6aaf94066c3ee122dd825b152df40ff0bd0df4dc27126233b948e", size = 784186, upload-time = "2026-10-05T00:23:46.366Z" }, + { url = "https://files.pythonhosted.org/packages/d5/bd/6357c84ca9a734bfc735b7c48dbe21336b3777fab8a4101d14976dfe49a7/wcwidth-0.9.2-cp314-cp314t-win32.whl", hash = "sha256:8b4e381590b9b7390e07e22b2c0c1bb96ce50e1d2243c866d9387600362d51ed", size = 600791, upload-time = "2026-10-05T00:23:59.398Z" }, + { url = "https://files.pythonhosted.org/packages/98/de/037591ca18d897cc2179559dde72e6efc6ce0c90e9cd1e6bca4e87c38b4b/wcwidth-0.9.2-cp314-cp314t-win_amd64.whl", hash = "sha256:f2f7b3bba5a5d5f31fc350fd36ce5b84b693c83b7eb95ee630b720da5a5ce06f", size = 605463, upload-time = "2026-10-05T00:24:01.049Z" }, + { url = "https://files.pythonhosted.org/packages/d0/07/c9d96e106d938d26f7ab639bc80b8199359a1645ba6e3498413313ab6f38/wcwidth-0.9.2-cp314-cp314t-win_arm64.whl", hash = "sha256:734aa9405b321d1042301aa19c943c4731ee9e3460e4f8feea3299c064c97a14", size = 605936, upload-time = "2026-10-05T00:23:52.765Z" }, + { url = "https://files.pythonhosted.org/packages/82/8a/a28d61d910005ac93dfe48be3a0ebaa49352d88cebd25323e69e6ff2f4a8/wcwidth-0.9.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:42dbcb76ce8af39e2c9db410ac3f9bdf4e47eb41d6f44525952f172d3d98f724", size = 606575, upload-time = "2026-10-05T00:23:25.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/c2/a3c66bd32766c8f4d6dc47d572532ba014fe5be30489f2576aff7cada363/wcwidth-0.9.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:138e1f8898e431b2f2d7881f8ca8d75591c1d3c21aa53f54e989bd6b39811da2", size = 611045, upload-time = "2026-10-05T00:23:30.421Z" }, + { url = "https://files.pythonhosted.org/packages/ec/8a/d39964f8f8c019d7d439b9b501d3e7bb42fee69f00354040ba0b27b5824c/wcwidth-0.9.2-cp315-cp315t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5175609bf8cc7398a5f48aa35207bd64ebf9f45e4c70df65f7fdc7a988041a3c", size = 778143, upload-time = "2026-10-05T00:23:47.7Z" }, + { url = "https://files.pythonhosted.org/packages/2f/53/525da13e8f9ff7b5b4e74ec6f8d68bdee63905796972e086c6b1b96670d2/wcwidth-0.9.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5f669ae8c3d969c72032f9cdee019674b666e522d45e1e2099a2e9dda4a341d", size = 786630, upload-time = "2026-10-05T00:23:36.967Z" }, + { url = "https://files.pythonhosted.org/packages/ef/9f/d6a0c6df354b9d93466548a65cbf4ffcb48c719bbd307504cf3e76740837/wcwidth-0.9.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:196b47cf32f9df27ccda6dc513237f3c2429c4c659db428d60a5bc443d10f270", size = 784839, upload-time = "2026-10-05T00:23:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d7/3021feed1ed7926021ec134943ad3b24a2f7ea742cc9976461171482ed77/wcwidth-0.9.2-cp315-cp315t-win32.whl", hash = "sha256:0cd4f7f2e53905dcb110d213a4c8529b6733fa3d232d8c717f946cc69a10349b", size = 600845, upload-time = "2026-10-05T00:24:02.497Z" }, + { url = "https://files.pythonhosted.org/packages/63/80/6a03356d8ee38261e3a78cf89ee03d8e7f12c572d969237be00869e2dc73/wcwidth-0.9.2-cp315-cp315t-win_amd64.whl", hash = "sha256:33df042f96c61ed3cd5fb3742fba427553a635bc578799857a48aa79f774a0b9", size = 605441, upload-time = "2026-10-05T00:24:04.052Z" }, + { url = "https://files.pythonhosted.org/packages/0c/48/1a308a86a833fd12ff7a08d0d2491ff4a72c8a92d12f5ead8317630f771e/wcwidth-0.9.2-cp315-cp315t-win_arm64.whl", hash = "sha256:48719a9bc76c2f84238693fe5013571fa5beffa3621cf228f1f3a9e30dae84b8", size = 605885, upload-time = "2026-10-05T00:23:54.274Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b4/0bfa065af506540d9d558e3e5548cff00bc1f9b24e6e2a8512498e8628de/wcwidth-0.9.2-py3-none-any.whl", hash = "sha256:89ca642c5bf0101157a09366be69fad0379db1f700ae39a920e103234573670e", size = 301667, upload-time = "2026-10-05T00:23:21.097Z" }, +] + [[package]] name = "websockets" version = "15.0.1" @@ -4438,6 +4662,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/2c/87f3254fd8ffd29e4c02732eee68a83a1d3c346ae39bc6822dcbcb697f2b/wheel-0.45.1-py3-none-any.whl", hash = "sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248", size = 72494, upload-time = "2024-11-23T00:18:21.207Z" }, ] +[[package]] +name = "wrapt" +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/3f/23/bb82321b86411eb51e5a5db3fb8f8032fd30bd7c2d74bfe936136b2fa1d6/wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04", size = 53482, upload-time = "2025-08-12T05:51:44.467Z" }, + { url = "https://files.pythonhosted.org/packages/45/69/f3c47642b79485a30a59c63f6d739ed779fb4cc8323205d047d741d55220/wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2", size = 38676, upload-time = "2025-08-12T05:51:32.636Z" }, + { url = "https://files.pythonhosted.org/packages/d1/71/e7e7f5670c1eafd9e990438e69d8fb46fa91a50785332e06b560c869454f/wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c", size = 38957, upload-time = "2025-08-12T05:51:54.655Z" }, + { url = "https://files.pythonhosted.org/packages/de/17/9f8f86755c191d6779d7ddead1a53c7a8aa18bccb7cea8e7e72dfa6a8a09/wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775", size = 81975, upload-time = "2025-08-12T05:52:30.109Z" }, + { url = "https://files.pythonhosted.org/packages/f2/15/dd576273491f9f43dd09fce517f6c2ce6eb4fe21681726068db0d0467096/wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd", size = 83149, upload-time = "2025-08-12T05:52:09.316Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c4/5eb4ce0d4814521fee7aa806264bf7a114e748ad05110441cd5b8a5c744b/wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05", size = 82209, upload-time = "2025-08-12T05:52:10.331Z" }, + { url = "https://files.pythonhosted.org/packages/31/4b/819e9e0eb5c8dc86f60dfc42aa4e2c0d6c3db8732bce93cc752e604bb5f5/wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418", size = 81551, upload-time = "2025-08-12T05:52:31.137Z" }, + { url = "https://files.pythonhosted.org/packages/f8/83/ed6baf89ba3a56694700139698cf703aac9f0f9eb03dab92f57551bd5385/wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390", size = 36464, upload-time = "2025-08-12T05:53:01.204Z" }, + { url = "https://files.pythonhosted.org/packages/2f/90/ee61d36862340ad7e9d15a02529df6b948676b9a5829fd5e16640156627d/wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6", size = 38748, upload-time = "2025-08-12T05:53:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c3/cefe0bd330d389c9983ced15d326f45373f4073c9f4a8c2f99b50bfea329/wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18", size = 36810, upload-time = "2025-08-12T05:52:51.906Z" }, + { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, + { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, + { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, + { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, + { 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]] name = "xxhash" version = "3.5.0"