diff --git a/.sampo/changesets/metrics-autocapture.md b/.sampo/changesets/metrics-autocapture.md new file mode 100644 index 000000000..aeff097b8 --- /dev/null +++ b/.sampo/changesets/metrics-autocapture.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +Add `metrics={"autocapture": True}`: HTTP, database and runtime metrics from the official OpenTelemetry instrumentations, with no instrumentation code. Install `posthog[metrics]` and run `opentelemetry-bootstrap -a install` to add the instrumentors for your libraries. diff --git a/posthog/client.py b/posthog/client.py index 2bb16c492..c0eefac90 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -1108,6 +1108,13 @@ def __init__( ) self._lanes = [self._analytics_lane, self._ai_lane] + self._metrics_autocapture = None + autocapture = metrics.get("autocapture") if isinstance(metrics, dict) else None + if autocapture and not self.disabled: + self._metrics_autocapture = self._start_metrics_autocapture( + metrics or {}, autocapture if isinstance(autocapture, dict) else {} + ) + if hasattr(os, "register_at_fork"): weak_self = weakref.ref(self) os.register_at_fork( @@ -1116,6 +1123,37 @@ def __init__( self._warn_if_duplicate_async_client() + def _start_metrics_autocapture(self, metrics_config: dict, areas: dict): + # Imported here, so a client without autocapture never loads OpenTelemetry. + from .metrics_autocapture import start_metrics_autocapture + + resource_attributes = metrics_config.get("resource_attributes") + resource_attributes = ( + dict(resource_attributes) if isinstance(resource_attributes, dict) else {} + ) + for key, attribute in ( + ("service_version", "service.version"), + ("environment", "deployment.environment"), + ): + if metrics_config.get(key) and attribute not in resource_attributes: + resource_attributes[attribute] = metrics_config[key] + weak_self = weakref.ref(self) + return start_metrics_autocapture( + host=self.host, + api_key=self.api_key, + areas={ + "http": areas.get("http", True), + "db": areas.get("db", True), + "runtime": areas.get("runtime", True), + }, + service_name=resource_attributes.get("service.name") + or metrics_config.get("service_name"), + resource_attributes=resource_attributes, + is_enabled=lambda: (client := weak_self()) is not None + and not client.disabled, + export_interval_seconds=metrics_config.get("flush_interval"), + ) + def _set_library_identity(self, library_id: str, library_version: str) -> None: """Override the SDK identity stamped on events and outbound requests.""" self._library_id = library_id @@ -2923,6 +2961,14 @@ def _shutdown_once(self, errors: list[Exception]) -> None: self._run_lifecycle_cleanup( "Failed to reset metrics on shutdown", self._metrics.reset, errors ) + if self._metrics_autocapture is not None: + # Exports the last window, then removes the instrumentation. + self._run_lifecycle_cleanup( + "Failed to shut down metrics autocapture", + self._metrics_autocapture.shutdown, + errors, + ) + self._metrics_autocapture = None with self._traces_lock: traces = self._traces if traces is not None: diff --git a/posthog/metrics_autocapture.py b/posthog/metrics_autocapture.py new file mode 100644 index 000000000..3d07cdc09 --- /dev/null +++ b/posthog/metrics_autocapture.py @@ -0,0 +1,433 @@ +"""Metrics autocapture (``metrics={"autocapture": True}``) — alpha. + +HTTP, database and runtime metrics from the official OpenTelemetry +instrumentations, exported to PostHog's ``/i/v1/metrics`` with no +instrumentation code in the app. + +The OpenTelemetry packages come from the ``posthog[metrics]`` extra and are +imported only when autocapture starts. Library instrumentors (requests, Django, +psycopg, ...) are found through the ``opentelemetry_instrumentor`` entry points, +the same way ``opentelemetry-instrument`` finds them. + +The meter and tracer providers are private: nothing is set on the OpenTelemetry +globals, so an app that adds its own OpenTelemetry setup later is not changed. +Spans are only used to derive database metrics and are never exported. +""" + +import importlib +import logging +import os +import re +import threading +from importlib.metadata import entry_points +from typing import Any, Callable, Dict, List, Optional + +from .version import VERSION + +try: + from opentelemetry.sdk.trace import SpanProcessor as _SpanProcessorBase +except ImportError: # pragma: no cover - the processor is only built once OTel imports + _SpanProcessorBase = object + +log = logging.getLogger("posthog") + +_import_module = importlib.import_module + +_INSTALL_HINT = 'pip install "posthog[metrics]" && opentelemetry-bootstrap -a install' + +_HTTP_CLIENT_INSTRUMENTORS = ( + "requests", + "urllib", + "urllib3", + "httpx", + "aiohttp-client", +) +_HTTP_SERVER_INSTRUMENTORS = ( + "django", + "flask", + "fastapi", + "starlette", + "falcon", + "pyramid", + "tornado", + "aiohttp-server", +) +_DB_DRIVER_INSTRUMENTORS = ( + "psycopg2", + "psycopg", + "asyncpg", + "pymysql", + "mysql", + "mysqlclient", + "sqlite3", + "pymongo", + "redis", + "pymssql", + "cassandra", +) +# SQLAlchemy adds connection pool metrics. Its spans wrap a driver span, so they +# are not turned into operation metrics: that would count one query twice. +_DB_INSTRUMENTORS = _DB_DRIVER_INSTRUMENTORS + ("sqlalchemy",) +_DERIVED_DB_METRIC_SCOPES = frozenset( + f"opentelemetry.instrumentation.{name}" for name in _DB_DRIVER_INSTRUMENTORS +) + +# Process-level metrics only; the host-wide system.* metrics are left out. +_RUNTIME_CONFIG: Dict[str, Optional[List[str]]] = { + "process.cpu.time": ["user", "system"], + "process.cpu.utilization": ["user", "system"], + "process.memory.usage": None, + "process.memory.virtual": None, + "process.open_file_descriptor.count": None, + "process.thread.count": None, + "process.context_switches": ["involuntary", "voluntary"], + "cpython.gc.collections": None, + "cpython.gc.collected_objects": None, + "cpython.gc.uncollectable_objects": None, +} + +_DB_OPERATION_DURATION = "db.client.operation.duration" +# OpenTelemetry semantic convention advice for `db.client.operation.duration`. +_DB_DURATION_BUCKETS = [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5, 10] +# The leading keyword (`SELECT`, `GET`) is bounded; the rest of a statement is not. +_STATEMENT_KEYWORD = re.compile(r"^\s*([A-Za-z]{2,20})\b") + +_lock = threading.Lock() +_active: Optional["MetricsAutocapture"] = None +_warned_about_missing_packages = False + + +def _first_string(attributes: Any, keys: tuple) -> Optional[str]: + for key in keys: + value = attributes.get(key) + if isinstance(value, str) and value: + return value + return None + + +class DbSpanMetricsProcessor(_SpanProcessorBase): + """Turns driver-level database client spans into a + ``db.client.operation.duration`` histogram. + + Only bounded attributes are kept: never the statement, which holds values, + and never anything per-user. + """ + + def __init__(self, meter): + try: + self._histogram = meter.create_histogram( + _DB_OPERATION_DURATION, + unit="s", + description="Duration of database client operations.", + explicit_bucket_boundaries_advisory=_DB_DURATION_BUCKETS, + ) + except TypeError: + # opentelemetry-api before 1.23 has no bucket advice. + self._histogram = meter.create_histogram( + _DB_OPERATION_DURATION, + unit="s", + description="Duration of database client operations.", + ) + + def on_start(self, span, parent_context=None): + pass + + def on_end(self, span): + try: + self._record(span) + except Exception: # never raise into the instrumented call + log.debug( + "Metrics autocapture failed to record a database span", exc_info=True + ) + + def _record(self, span): + from opentelemetry.trace import SpanKind, StatusCode + + scope = getattr(span, "instrumentation_scope", None) + if ( + span.kind != SpanKind.CLIENT + or getattr(scope, "name", None) not in _DERIVED_DB_METRIC_SCOPES + ): + return + attributes = span.attributes or {} + system = _first_string(attributes, ("db.system.name", "db.system")) + if not system or span.start_time is None or span.end_time is None: + return + metric_attributes: Dict[str, Any] = {"db.system.name": system} + operation = _first_string(attributes, ("db.operation.name", "db.operation")) + if operation is None: + statement = _first_string(attributes, ("db.query.text", "db.statement")) + match = _STATEMENT_KEYWORD.match(statement or "") + operation = match.group(1).upper() if match else None + collection = _first_string( + attributes, + ( + "db.collection.name", + "db.mongodb.collection", + "db.sql.table", + "db.cassandra.table", + ), + ) + namespace = _first_string(attributes, ("db.namespace", "db.name")) + address = _first_string(attributes, ("server.address", "net.peer.name")) + port = attributes.get("server.port", attributes.get("net.peer.port")) + if operation: + metric_attributes["db.operation.name"] = operation + if collection: + metric_attributes["db.collection.name"] = collection + if namespace: + metric_attributes["db.namespace"] = namespace + if address: + metric_attributes["server.address"] = address + if isinstance(port, int) and not isinstance(port, bool): + metric_attributes["server.port"] = port + if span.status.status_code == StatusCode.ERROR: + metric_attributes["error.type"] = ( + _first_string(attributes, ("error.type",)) or "_OTHER" + ) + self._histogram.record( + (span.end_time - span.start_time) / 1e9, metric_attributes + ) + + def _on_ending(self, span): + pass + + def shutdown(self): + pass + + def force_flush(self, timeout_millis=30000): + return True + + +class MetricsAutocapture: + """The running autocapture. ``shutdown()`` exports the last window and + removes the instrumentation.""" + + def __init__(self, meter_provider, tracer_provider, instrumentors): + self._meter_provider = meter_provider + self._tracer_provider = tracer_provider + self._instrumentors = instrumentors + + def force_flush(self, timeout_millis: int = 10_000) -> None: + self._meter_provider.force_flush(timeout_millis) + + def shutdown(self) -> None: + global _active + with _lock: + if _active is not self: + return + _active = None + for instrumentor in self._instrumentors: + try: + instrumentor.uninstrument() + except Exception: + log.debug( + "Metrics autocapture failed to uninstrument %s", + instrumentor, + exc_info=True, + ) + for provider in (self._tracer_provider, self._meter_provider): + try: + provider.shutdown() + except Exception: + log.debug( + "Metrics autocapture failed to shut down %s", + provider, + exc_info=True, + ) + + +def _is_default_provider(provider: Any) -> bool: + name = type(provider).__name__ + return name.startswith(("_Proxy", "Proxy", "NoOp", "_NoOp", "Default")) + + +def _instrumentor_names(areas: Dict[str, bool]) -> List[str]: + names: List[str] = [] + if areas.get("http"): + names.extend(_HTTP_CLIENT_INSTRUMENTORS + _HTTP_SERVER_INSTRUMENTORS) + if areas.get("db"): + names.extend(_DB_INSTRUMENTORS) + if areas.get("runtime"): + names.append("system_metrics") + return names + + +def start_metrics_autocapture( + *, + host: str, + api_key: str, + areas: Dict[str, bool], + service_name: Optional[str], + resource_attributes: Dict[str, Any], + is_enabled: Callable[[], bool], + export_interval_seconds: Optional[float] = None, + _metric_reader: Any = None, +) -> Optional[MetricsAutocapture]: + """Starts metrics autocapture. Returns ``None``, after one warning, when the + OpenTelemetry packages are missing, when OpenTelemetry is already set up in + the process, or when autocapture is already running. Never raises.""" + global _active, _warned_about_missing_packages + with _lock: + if _active is not None: + log.warning( + "Metrics autocapture is already running in this process, so this client does not start it again." + ) + return None + try: + metrics_api = _import_module("opentelemetry.metrics") + trace_api = _import_module("opentelemetry.trace") + sdk_metrics = _import_module("opentelemetry.sdk.metrics") + sdk_metrics_export = _import_module("opentelemetry.sdk.metrics.export") + sdk_trace = _import_module("opentelemetry.sdk.trace") + sampling = _import_module("opentelemetry.sdk.trace.sampling") + sdk_resources = _import_module("opentelemetry.sdk.resources") + otlp = _import_module( + "opentelemetry.exporter.otlp.proto.http.metric_exporter" + ) + _import_module("opentelemetry.instrumentation") + except ImportError as e: + if not _warned_about_missing_packages: + _warned_about_missing_packages = True + log.warning( + "Metrics autocapture needs the OpenTelemetry packages. Install them with: %s (%s)", + _INSTALL_HINT, + e, + ) + return None + + try: + if not _is_default_provider( + metrics_api.get_meter_provider() + ) or not _is_default_provider(trace_api.get_tracer_provider()): + log.warning( + "OpenTelemetry is already set up in this process, so metrics autocapture does not start. " + "Point your OTLP metrics exporter at PostHog instead: https://posthog.com/docs/metrics" + ) + return None + + # Stable HTTP names (`http.server.request.duration`, seconds), the same + # names posthog-node sends. Read once, when the first instrumentor loads. + os.environ.setdefault("OTEL_SEMCONV_STABILITY_OPT_IN", "http") + + resource = sdk_resources.Resource.create( + { + "service.name": service_name + or os.environ.get("OTEL_SERVICE_NAME") + or "unknown_service", + **resource_attributes, + "telemetry.distro.name": "posthog-python", + "telemetry.distro.version": VERSION, + } + ) + + reader = _metric_reader + if reader is None: + exporter_base: Any = otlp.OTLPMetricExporter + delta = sdk_metrics_export.AggregationTemporality.DELTA + cumulative = sdk_metrics_export.AggregationTemporality.CUMULATIVE + + class _GatedExporter(exporter_base): + # The exporter still sends while the client is disabled unless it is gated here. + def export(self, metrics_data, timeout_millis=10_000, **kwargs): + if not is_enabled(): + return sdk_metrics_export.MetricExportResult.SUCCESS + return super().export( + metrics_data, timeout_millis=timeout_millis, **kwargs + ) + + reader = sdk_metrics_export.PeriodicExportingMetricReader( + _GatedExporter( + endpoint=f"{host}/i/v1/metrics", + headers={"Authorization": f"Bearer {api_key}"}, + # Delta, like the `posthog.metrics` wire shape; values that go + # up and down stay cumulative. + preferred_temporality={ + sdk_metrics.Counter: delta, + sdk_metrics.Histogram: delta, + sdk_metrics.ObservableCounter: delta, + sdk_metrics.UpDownCounter: cumulative, + sdk_metrics.ObservableUpDownCounter: cumulative, + sdk_metrics.ObservableGauge: cumulative, + }, + ), + export_interval_millis=(export_interval_seconds or 10.0) * 1000, + ) + meter_provider = sdk_metrics.MeterProvider( + resource=resource, metric_readers=[reader] + ) + + sampler_base: Any = sampling.Sampler + + class _ClientSpansOnly(sampler_base): + # Records only client spans, the ones a database call makes. Database + # instrumentors set `db.system` after start, so the kind is all a + # sampler can check. Server spans stay no-ops. + def should_sample( + self, + parent_context, + trace_id, + name, + kind=None, + attributes=None, + links=None, + trace_state=None, + ): + decision = ( + sampling.Decision.RECORD_ONLY + if kind == trace_api.SpanKind.CLIENT + else sampling.Decision.DROP + ) + return sampling.SamplingResult(decision) + + def get_description(self): + return "PostHogClientSpansOnly" + + tracer_provider = sdk_trace.TracerProvider( + resource=resource, sampler=_ClientSpansOnly() + ) + if areas.get("db"): + tracer_provider.add_span_processor( + DbSpanMetricsProcessor( + meter_provider.get_meter("posthog.metrics_autocapture", VERSION) + ) + ) + + excluded_urls = "^" + re.escape(host.rstrip("/")) + "(/|$)" + wanted = set(_instrumentor_names(areas)) + instrumentors = [] + for entry_point in entry_points(group="opentelemetry_instrumentor"): + if entry_point.name not in wanted: + continue + try: + instrumentor_class = entry_point.load() + instrumentor = ( + instrumentor_class(config=_RUNTIME_CONFIG) + if entry_point.name == "system_metrics" + else instrumentor_class() + ) + if instrumentor.is_instrumented_by_opentelemetry: + # The app instrumented this library itself. + continue + kwargs: Dict[str, Any] = { + "tracer_provider": tracer_provider, + "meter_provider": meter_provider, + } + if entry_point.name in _HTTP_CLIENT_INSTRUMENTORS: + # PostHog's own uploads, including this exporter's, are not app traffic. + kwargs["excluded_urls"] = excluded_urls + instrumentor.instrument(**kwargs) + if instrumentor.is_instrumented_by_opentelemetry: + instrumentors.append(instrumentor) + except Exception: + log.debug( + "Metrics autocapture skipped %s", + entry_point.name, + exc_info=True, + ) + + _active = MetricsAutocapture(meter_provider, tracer_provider, instrumentors) + return _active + except Exception: + log.exception("Metrics autocapture failed to start") + return None diff --git a/posthog/test/test_metrics_autocapture.py b/posthog/test/test_metrics_autocapture.py new file mode 100644 index 000000000..3d9bd35c7 --- /dev/null +++ b/posthog/test/test_metrics_autocapture.py @@ -0,0 +1,339 @@ +import sqlite3 +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from unittest import mock + +import pytest +import requests +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import InMemoryMetricReader +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.trace import SpanKind, Status, StatusCode + +from posthog.client import Client +from posthog.metrics_autocapture import ( + DbSpanMetricsProcessor, + start_metrics_autocapture, +) + +FAKE_API_KEY = "phc_test_key" + + +class _Server: + def __init__(self): + received = self.received = [] + + class Handler(BaseHTTPRequestHandler): + def _respond(self): + length = int(self.headers.get("content-length") or 0) + received.append( + { + "path": self.path, + "headers": dict(self.headers), + "body": self.rfile.read(length), + } + ) + self.send_response(200) + self.send_header("content-type", "application/json") + self.send_header("content-length", "2") + self.end_headers() + self.wfile.write(b"{}") + + do_GET = _respond + do_POST = _respond + + def log_message(self, *args): + pass + + self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.port = self.httpd.server_address[1] + self.url = f"http://127.0.0.1:{self.port}" + threading.Thread(target=self.httpd.serve_forever, daemon=True).start() + + def close(self): + self.httpd.shutdown() + self.httpd.server_close() + + +@pytest.fixture +def posthog_host(): + server = _Server() + yield server + server.close() + + +@pytest.fixture +def app_server(): + server = _Server() + yield server + server.close() + + +@pytest.fixture +def started(): + handles = [] + yield handles + for handle in handles: + handle.shutdown() + + +def _start(posthog_host, started, reader=None, **overrides): + options = dict( + host=posthog_host.url, + api_key=FAKE_API_KEY, + areas={"http": True, "db": True, "runtime": True}, + service_name="checkout-api", + resource_attributes={"deployment.environment": "test"}, + is_enabled=lambda: True, + ) + options.update(overrides) + handle = start_metrics_autocapture(**options, _metric_reader=reader) + if handle is not None: + started.append(handle) + return handle + + +def _points(reader, name): + data = reader.get_metrics_data() + points = [] + for resource_metrics in data.resource_metrics if data else []: + for scope_metrics in resource_metrics.scope_metrics: + for metric in scope_metrics.metrics: + if metric.name == name: + points.extend(metric.data.data_points) + return points + + +def _names(reader): + data = reader.get_metrics_data() + return { + metric.name + for resource_metrics in (data.resource_metrics if data else []) + for scope_metrics in resource_metrics.scope_metrics + for metric in scope_metrics.metrics + } + + +class TestStartMetricsAutocapture: + def test_records_http_client_durations_but_not_requests_to_posthog( + self, posthog_host, app_server, started + ): + reader = InMemoryMetricReader() + assert _start(posthog_host, started, reader) is not None + + requests.get(f"{app_server.url}/health") + requests.post(f"{posthog_host.url}/batch/", data="{}") + + ports = [ + point.attributes.get("server.port") + for point in _points(reader, "http.client.request.duration") + ] + assert app_server.port in ports + assert posthog_host.port not in ports + + def test_records_database_operations_without_the_statement( + self, posthog_host, started + ): + reader = InMemoryMetricReader() + _start(posthog_host, started, reader) + + # The instrumentor wraps cursors, which drivers and SQLAlchemy use. + connection = sqlite3.connect(":memory:") + cursor = connection.cursor() + cursor.execute("CREATE TABLE orders (email TEXT)") + cursor.execute("SELECT * FROM orders WHERE email = 'a@b.c'") + connection.close() + + points = _points(reader, "db.client.operation.duration") + operations = {point.attributes.get("db.operation.name") for point in points} + assert {"CREATE", "SELECT"} <= operations + for point in points: + assert point.attributes["db.system.name"] == "sqlite" + assert all("a@b.c" not in str(value) for value in point.attributes.values()) + + def test_records_runtime_metrics(self, posthog_host, started): + reader = InMemoryMetricReader() + _start( + posthog_host, + started, + reader, + areas={"http": False, "db": False, "runtime": True}, + ) + + names = _names(reader) + assert "process.cpu.time" in names + assert any(name.startswith("cpython.gc.") for name in names) + assert "http.client.request.duration" not in names + + def test_exports_otlp_to_posthog_with_the_project_token( + self, posthog_host, app_server, started + ): + handle = _start(posthog_host, started) + requests.get(f"{app_server.url}/health") + + handle.force_flush() + + exports = [r for r in posthog_host.received if r["path"] == "/i/v1/metrics"] + assert exports + headers = {k.lower(): v for k, v in exports[0]["headers"].items()} + assert headers["authorization"] == f"Bearer {FAKE_API_KEY}" + assert headers["content-type"] == "application/x-protobuf" + body = b"".join(r["body"] for r in exports) + for expected in ( + b"http.client.request.duration", + b"checkout-api", + b"telemetry.distro.name", + b"posthog-python", + b"deployment.environment", + ): + assert expected in body + + def test_sends_nothing_while_opted_out(self, posthog_host, app_server, started): + handle = _start(posthog_host, started, is_enabled=lambda: False) + requests.get(f"{app_server.url}/health") + + handle.force_flush() + + assert [r for r in posthog_host.received if r["path"] == "/i/v1/metrics"] == [] + + def test_warns_once_and_returns_none_when_packages_are_missing( + self, posthog_host, started + ): + def missing(name): + raise ImportError(f"No module named '{name}'") + + with mock.patch("posthog.metrics_autocapture._import_module", missing): + with mock.patch("posthog.metrics_autocapture.log") as log: + assert _start(posthog_host, started) is None + assert _start(posthog_host, started) is None + + assert log.warning.call_count == 1 + assert "posthog[metrics]" in str(log.warning.call_args) + + def test_does_not_start_when_opentelemetry_is_already_set_up( + self, posthog_host, started + ): + with mock.patch( + "opentelemetry.metrics.get_meter_provider", return_value=MeterProvider() + ): + with mock.patch("posthog.metrics_autocapture.log") as log: + assert _start(posthog_host, started) is None + + assert "already set up" in str(log.warning.call_args) + + def test_runs_once_per_process_and_can_start_again_after_shutdown( + self, posthog_host, started + ): + first = _start(posthog_host, started, InMemoryMetricReader()) + with mock.patch("posthog.metrics_autocapture.log") as log: + assert _start(posthog_host, started) is None + assert log.warning.call_count == 1 + + first.shutdown() + reader = InMemoryMetricReader() + assert _start(posthog_host, started, reader) is not None + + connection = sqlite3.connect(":memory:") + connection.cursor().execute("SELECT 1") + connection.close() + assert _points(reader, "db.client.operation.duration") + + +class TestDbSpanMetricsProcessor: + def _record(self, scope, kind=SpanKind.CLIENT, attributes=None, error=False): + reader = InMemoryMetricReader() + meter = MeterProvider(metric_readers=[reader]).get_meter("test") + provider = TracerProvider() + provider.add_span_processor(DbSpanMetricsProcessor(meter)) + span = provider.get_tracer(scope).start_span("query", kind=kind) + span.set_attributes(attributes or {}) + if error: + span.set_status(Status(StatusCode.ERROR)) + span.end() + return _points(reader, "db.client.operation.duration") + + def test_turns_a_driver_span_into_a_duration_histogram(self): + points = self._record( + "opentelemetry.instrumentation.psycopg2", + attributes={ + "db.system": "postgresql", + "db.statement": "select * from orders where id = 7", + "db.name": "shop", + "net.peer.name": "db.internal", + "net.peer.port": 5432, + }, + ) + + assert len(points) == 1 + assert dict(points[0].attributes) == { + "db.system.name": "postgresql", + "db.operation.name": "SELECT", + "db.namespace": "shop", + "server.address": "db.internal", + "server.port": 5432, + } + + def test_marks_failures(self): + points = self._record( + "opentelemetry.instrumentation.pymongo", + attributes={"db.system": "mongodb", "db.operation": "find"}, + error=True, + ) + + assert dict(points[0].attributes) == { + "db.system.name": "mongodb", + "db.operation.name": "find", + "error.type": "_OTHER", + } + + def test_skips_orm_layers_and_other_spans(self): + # SQLAlchemy wraps a driver span, so counting it would count the query twice. + assert ( + self._record( + "opentelemetry.instrumentation.sqlalchemy", + attributes={"db.system": "postgresql"}, + ) + == [] + ) + assert ( + self._record( + "opentelemetry.instrumentation.psycopg2", + kind=SpanKind.SERVER, + attributes={"db.system": "postgresql"}, + ) + == [] + ) + assert ( + self._record( + "opentelemetry.instrumentation.requests", + attributes={"http.request.method": "GET"}, + ) + == [] + ) + + +class TestClientOption: + def test_starts_with_metrics_autocapture_and_stops_on_shutdown( + self, posthog_host, started + ): + client = Client( + FAKE_API_KEY, host=posthog_host.url, metrics={"autocapture": True} + ) + with mock.patch("posthog.metrics_autocapture.log"): + assert _start(posthog_host, started) is None + + client.shutdown() + assert _start(posthog_host, started) is not None + + def test_does_not_start_by_default_or_when_disabled(self, posthog_host, started): + plain = Client(FAKE_API_KEY, host=posthog_host.url) + disabled = Client( + FAKE_API_KEY, + host=posthog_host.url, + disabled=True, + metrics={"autocapture": True}, + ) + + assert _start(posthog_host, started) is not None + plain.shutdown() + disabled.shutdown() diff --git a/pyproject.toml b/pyproject.toml index eeac89b75..0f65fd337 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,15 @@ otel = [ "opentelemetry-sdk>=1.20.0", "opentelemetry-exporter-otlp-proto-http>=1.20.0", ] +# Metrics autocapture (`metrics={"autocapture": True}`). Library instrumentors +# (requests, Django, psycopg, ...) are separate packages: `opentelemetry-bootstrap +# -a install` adds the ones that match the installed libraries. +metrics = [ + "opentelemetry-sdk>=1.20.0", + "opentelemetry-exporter-otlp-proto-http>=1.20.0", + "opentelemetry-instrumentation>=0.41b0", + "opentelemetry-instrumentation-system-metrics>=0.41b0", +] dev = [ "django-stubs", "griffe", @@ -95,6 +104,11 @@ test = [ "fastmcp>=2.0", "opentelemetry-sdk>=1.20.0", "opentelemetry-exporter-otlp-proto-http>=1.20.0", + "opentelemetry-instrumentation>=0.41b0", + "opentelemetry-instrumentation-system-metrics>=0.41b0", + "opentelemetry-instrumentation-requests>=0.41b0", + "opentelemetry-instrumentation-sqlite3>=0.41b0", + "opentelemetry-instrumentation-wsgi>=0.41b0", "pytest-bdd>=8.1.0", "zstandard>=0.23.0", # gevent 25.4.1+ replaces queue.Queue, exercising the compatibility path. diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 95783ef4c..4c7fbbcbe 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -371,6 +371,7 @@ alias posthog.mcp.get_more_tools_result -> posthog.mcp.tools.get_more_tools_resu alias posthog.mcp.get_request_headers -> posthog.mcp.request_headers.get_request_headers alias posthog.mcp.send_feedback_result -> posthog.mcp.feedback.send_feedback_result alias posthog.mcp.set_logger -> posthog.mcp.logger.set_logger +alias posthog.metrics_autocapture.VERSION -> posthog.version.VERSION alias posthog.metrics_capture.VERSION -> posthog.version.VERSION alias posthog.metrics_capture.remove_trailing_slash -> posthog.utils.remove_trailing_slash alias posthog.request.VERSION -> posthog.version.VERSION @@ -857,6 +858,7 @@ attribute posthog.mcp.types.UserIdentity.groups: Optional[Dict[str, str]] = None attribute posthog.mcp.types.UserIdentity.properties: Optional[JsonRecord] = None attribute posthog.mcp.version.__version__ = '0.3.0' attribute posthog.metrics = None +attribute posthog.metrics_autocapture.log = logging.getLogger('posthog') attribute posthog.metrics_capture.DEFAULT_HISTOGRAM_BOUNDS = [0, 5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10000] attribute posthog.metrics_capture.MetricAttributeValue = Union[str, int, float, bool] attribute posthog.metrics_capture.log = logging.getLogger('posthog') @@ -1054,6 +1056,8 @@ class posthog.mcp.types.MCPAnalyticsModelOptions(description: Optional[str] = No class posthog.mcp.types.MCPAnalyticsOptions(logger: Optional[LoggerFn] = None, report_missing: bool = False, missing_capability_tool_name: Optional[str] = None, enable_conversation_id: bool = True, enable_exception_autocapture: bool = True, context: Union[bool, MCPAnalyticsContextOptions] = True, identify: Optional[Union[IdentifyFn, UserIdentity]] = None, intent_fallback: Optional[IntentFallbackFn] = None, before_send: Optional[BeforeSendFn] = None, event_properties: Optional[EventPropertiesFn] = None, capture_model: Union[bool, MCPAnalyticsModelOptions] = True, collect_feedback: Union[bool, CollectFeedbackOptions] = False) class posthog.mcp.types.PreparedToolCall(args: Optional[JsonRecord] = None, intent: Optional[str] = None, intent_source: Optional[str] = None, is_missing_capability: bool = False, llm_model: Optional[str] = None, llm_model_source: Optional[MCPAnalyticsModelSource] = None, is_feedback: bool = False, feedback_report: Optional[FeedbackReport] = None) class posthog.mcp.types.UserIdentity(distinct_id: str, properties: Optional[JsonRecord] = None, groups: Optional[Dict[str, str]] = None) +class posthog.metrics_autocapture.DbSpanMetricsProcessor(meter) +class posthog.metrics_autocapture.MetricsAutocapture(meter_provider, tracer_provider, instrumentors) class posthog.metrics_capture.PostHogMetrics(client, config: Optional[dict] = None) class posthog.poller.Poller(interval, execute, *args, **kwargs) class posthog.request.APIError(status: Union[int, str], message: str, retry_after: Optional[float] = None) @@ -1257,6 +1261,7 @@ function posthog.mcp.session_token.decode_session_id(value: Any) -> Optional[Ses function posthog.mcp.session_token.encode_session_id(payload: SessionTokenPayload) -> str function posthog.mcp.session_token.read_mcp_session_header(headers: Any) -> Optional[str] function posthog.mcp.tools.get_more_tools_result() -> Dict[str, Any] +function posthog.metrics_autocapture.start_metrics_autocapture(*, host: str, api_key: str, areas: Dict[str, bool], service_name: Optional[str], resource_attributes: Dict[str, Any], is_enabled: Callable[[], bool], export_interval_seconds: Optional[float] = None, _metric_reader: Any = None) -> Optional[MetricsAutocapture] function posthog.new_context(fresh: bool = False, capture_exceptions: Optional[bool] = None, client: Optional[Client] = None) function posthog.request.batch_post(api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, path: str = EVENTS_ENDPOINT, **kwargs) -> requests.Response function posthog.request.determine_server_host(host: Optional[str]) -> str @@ -1485,6 +1490,12 @@ method posthog.mcp.posthog_mcp.PostHogMCP.flush(timeout_seconds: Optional[float] method posthog.mcp.posthog_mcp.PostHogMCP.prepare_tool_call(name: str, args: Optional[JsonRecord] = None, *, request_meta: Optional[JsonRecord] = None, original_tool: Any = None) -> PreparedToolCall method posthog.mcp.posthog_mcp.PostHogMCP.prepare_tool_list(tools: List[Any], context: Union[bool, MCPAnalyticsContextOptions] = True, report_missing: bool = False, collect_feedback: bool = False) -> List[Any] method posthog.mcp.posthog_mcp.PostHogMCP.shutdown() -> None +method posthog.metrics_autocapture.DbSpanMetricsProcessor.force_flush(timeout_millis=30000) +method posthog.metrics_autocapture.DbSpanMetricsProcessor.on_end(span) +method posthog.metrics_autocapture.DbSpanMetricsProcessor.on_start(span, parent_context=None) +method posthog.metrics_autocapture.DbSpanMetricsProcessor.shutdown() +method posthog.metrics_autocapture.MetricsAutocapture.force_flush(timeout_millis: int = 10000) -> None +method posthog.metrics_autocapture.MetricsAutocapture.shutdown() -> None method posthog.metrics_capture.PostHogMetrics.count(name: str, value: float = 1, unit: Optional[str] = None, attributes: Optional[dict] = None) -> None method posthog.metrics_capture.PostHogMetrics.flush() -> None method posthog.metrics_capture.PostHogMetrics.gauge(name: str, value: float, unit: Optional[str] = None, attributes: Optional[dict] = None) -> None @@ -1592,6 +1603,7 @@ module posthog.mcp.session_token module posthog.mcp.tools module posthog.mcp.types module posthog.mcp.version +module posthog.metrics_autocapture module posthog.metrics_capture module posthog.poller module posthog.release_id diff --git a/uv.lock b/uv.lock index d5f177949..c9c56497a 100644 --- a/uv.lock +++ b/uv.lock @@ -2516,6 +2516,94 @@ 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-dbapi" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/ed/ba91c9e4a3ec65781e9c59982109f0a36de9fa574f622596b33d1985dab5/opentelemetry_instrumentation_dbapi-0.61b0.tar.gz", hash = "sha256:02fa800682c1de87dcad0e59f2092b3b6fb8b8ea0636518f989e1166b418dcb9", size = 16761, upload-time = "2026-03-04T14:20:29.782Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/a5/d26c68f3fd33eb7410985cef7700bb426e2c4a26de9207902cbbffb19a3f/opentelemetry_instrumentation_dbapi-0.61b0-py3-none-any.whl", hash = "sha256:8f762c39c8edd20c6aef3282550a2cfbfec76c3f431bf5c36327dcf9ece2e5a0", size = 14134, upload-time = "2026-03-04T14:19:24.718Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-requests" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/c7/7a47cb85c7aa93a9c820552e414889185bcf91245271d12e5d443e5f834d/opentelemetry_instrumentation_requests-0.61b0.tar.gz", hash = "sha256:15f879ce8fb206bd7e6fdc61663ea63481040a845218c0cf42902ce70bd7e9d9", size = 18379, upload-time = "2026-03-04T14:20:46.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/a1/a7a133b273d1f53950f16a370fc94367eff472c9c2576e8e9e28c62dcc9f/opentelemetry_instrumentation_requests-0.61b0-py3-none-any.whl", hash = "sha256:cce19b379949fe637eb73ba39b02c57d2d0805447ca6d86534aa33fcb141f683", size = 14207, upload-time = "2026-03-04T14:19:51.765Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-sqlite3" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-dbapi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/92/0d561326bf4026817abef097ec6be3ae7a86dd02d64e1a80c2218057a999/opentelemetry_instrumentation_sqlite3-0.61b0.tar.gz", hash = "sha256:96d4f0fa35ba7ee9aa683aa17726cb358c8029cc7b3cf55668ccc77254c29ca5", size = 7933, upload-time = "2026-03-04T14:20:48.301Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/a8/4454bd16b3cd7edebc71327c3c4946e7bf969e8fcd8fe5581d9f5d92ec2d/opentelemetry_instrumentation_sqlite3-0.61b0-py3-none-any.whl", hash = "sha256:202a18e7f9d231bfa44771fdb068bff16f24a6fa5e424a0df4d9232b1a818693", size = 9341, upload-time = "2026-03-04T14:19:54.426Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-system-metrics" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "psutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/68/a403ade03a7ccba3d113a02c041942ab8feb4471101eb3a02da6403e9258/opentelemetry_instrumentation_system_metrics-0.61b0.tar.gz", hash = "sha256:3eb55f9a058797cf915946cbb7445e00b31316ac3e55050475792edf3367c321", size = 17637, upload-time = "2026-03-04T14:20:49.591Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/2b/3142c6e0f3c9a5be3e5187933bc28b0c8b7e77c04937aec317eee96e8fdb/opentelemetry_instrumentation_system_metrics-0.61b0-py3-none-any.whl", hash = "sha256:7d4fe3e0ce14e0e6eb18f5826100d6cc1af662e5a8ebc74e9b91fe23f192f3e8", size = 14909, upload-time = "2026-03-04T14:19:56.306Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-wsgi" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/e5/189f2845362cfe78e356ba127eab21456309def411c6874aa4800c3de816/opentelemetry_instrumentation_wsgi-0.61b0.tar.gz", hash = "sha256:380f2ae61714e5303275a80b2e14c58571573cd1fddf496d8c39fb9551c5e532", size = 19898, upload-time = "2026-03-04T14:20:54.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/75/d6b42ba26f3c921be6d01b16561b7bb863f843bad7ac3a5011f62617bcab/opentelemetry_instrumentation_wsgi-0.61b0-py3-none-any.whl", hash = "sha256:bd33b0824166f24134a3400648805e8d2e6a7951f070241294e8b8866611d7fa", size = 14628, upload-time = "2026-03-04T14:20:03.934Z" }, +] + [[package]] name = "opentelemetry-proto" version = "1.40.0" @@ -2555,6 +2643,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2", size = 231621, upload-time = "2026-03-04T14:17:19.33Z" }, ] +[[package]] +name = "opentelemetry-util-http" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/3c/f0196223efc5c4ca19f8fad3d5462b171ac6333013335ce540c01af419e9/opentelemetry_util_http-0.61b0.tar.gz", hash = "sha256:1039cb891334ad2731affdf034d8fb8b48c239af9b6dd295e5fabd07f1c95572", size = 11361, upload-time = "2026-03-04T14:20:57.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/e5/c08aaaf2f64288d2b6ef65741d2de5454e64af3e050f34285fb1907492fe/opentelemetry_util_http-0.61b0-py3-none-any.whl", hash = "sha256:8e715e848233e9527ea47e275659ea60a57a75edf5206a3b937e236a6da5fc33", size = 9281, upload-time = "2026-03-04T14:20:08.364Z" }, +] + [[package]] name = "orjson" version = "3.11.7" @@ -2804,6 +2901,12 @@ dev = [ langchain = [ { name = "langchain" }, ] +metrics = [ + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-system-metrics" }, + { name = "opentelemetry-sdk" }, +] otel = [ { name = "opentelemetry-exporter-otlp-proto-http" }, { name = "opentelemetry-sdk" }, @@ -2828,6 +2931,11 @@ test = [ { name = "mcp" }, { name = "openai-agents" }, { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-requests" }, + { name = "opentelemetry-instrumentation-sqlite3" }, + { name = "opentelemetry-instrumentation-system-metrics" }, + { name = "opentelemetry-instrumentation-wsgi" }, { name = "opentelemetry-sdk" }, { name = "parameterized" }, { name = "pydantic" }, @@ -2878,8 +2986,17 @@ requires-dist = [ { name = "mypy", marker = "extra == 'dev'" }, { name = "mypy-baseline", marker = "extra == 'dev'" }, { name = "openai-agents", marker = "extra == 'test'", specifier = ">=0.18" }, + { name = "opentelemetry-exporter-otlp-proto-http", marker = "extra == 'metrics'", specifier = ">=1.20.0" }, { 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", marker = "extra == 'metrics'", specifier = ">=0.41b0" }, + { name = "opentelemetry-instrumentation", marker = "extra == 'test'", specifier = ">=0.41b0" }, + { name = "opentelemetry-instrumentation-requests", marker = "extra == 'test'", specifier = ">=0.41b0" }, + { name = "opentelemetry-instrumentation-sqlite3", marker = "extra == 'test'", specifier = ">=0.41b0" }, + { name = "opentelemetry-instrumentation-system-metrics", marker = "extra == 'metrics'", specifier = ">=0.41b0" }, + { name = "opentelemetry-instrumentation-system-metrics", marker = "extra == 'test'", specifier = ">=0.41b0" }, + { name = "opentelemetry-instrumentation-wsgi", marker = "extra == 'test'", specifier = ">=0.41b0" }, + { name = "opentelemetry-sdk", marker = "extra == 'metrics'", specifier = ">=1.20.0" }, { 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'" }, @@ -2907,7 +3024,7 @@ requires-dist = [ { name = "zstandard", marker = "extra == 'test'", specifier = ">=0.23.0" }, { name = "zstandard", marker = "extra == 'zstd'", specifier = ">=0.23.0" }, ] -provides-extras = ["langchain", "async", "zstd", "otel", "dev", "test"] +provides-extras = ["langchain", "async", "zstd", "otel", "metrics", "dev", "test"] [package.metadata.requires-dev] dev = [{ name = "claude-agent-sdk", specifier = ">=0.1.50" }] @@ -3032,6 +3149,34 @@ wheels = [ { 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 = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + [[package]] name = "py-key-value-aio" version = "0.4.5" @@ -4349,6 +4494,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"