diff --git a/pyproject.toml b/pyproject.toml index f55e58d..04f4639 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,13 +22,13 @@ dependencies = [ # dependencies. Plan to lift into a sibling ``openarmature-otel`` # package at v1.0 launch alongside ``openarmature-eval``. otel = [ - # Upper bound guards against the SDK 2.x release that removes - # ``opentelemetry.sdk._logs.LoggingHandler`` (currently emits a - # DeprecationWarning). Migration to - # ``opentelemetry-instrumentation-logging`` lands in Phase 6.1 - # before bumping the upper bound. - "opentelemetry-api>=1.27,<2", - "opentelemetry-sdk>=1.27,<2", + "opentelemetry-api>=1.27,<3", + "opentelemetry-sdk>=1.27,<3", + # Provides ``LoggingHandler`` after the SDK's deprecation + # of ``opentelemetry.sdk._logs.LoggingHandler``. No upper + # bound — the contrib repo cycles fast on minor releases + # below 1.0; revisit when 1.0 lands. + "opentelemetry-instrumentation-logging>=0.62.0b1", ] [project.urls] diff --git a/src/openarmature/observability/otel/logs.py b/src/openarmature/observability/otel/logs.py index 2422edc..aa70661 100644 --- a/src/openarmature/observability/otel/logs.py +++ b/src/openarmature/observability/otel/logs.py @@ -8,36 +8,67 @@ Opt-in by design: users may have their own logging configuration we shouldn't override silently. Calling ``install_log_bridge(provider)`` explicitly attaches an OTel ``LoggingHandler`` to the root logger -and registers a filter that injects the correlation_id from the -ContextVar. +and installs a process-global ``LogRecord`` factory that injects +the correlation_id from the ContextVar. """ from __future__ import annotations import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from opentelemetry.sdk._logs import LoggerProvider -class _CorrelationIdFilter(logging.Filter): - """Logging filter that reads the openarmature correlation_id - ContextVar and attaches it to every log record as the - ``openarmature.correlation_id`` attribute. Per spec §7 the - attribute MUST appear on every log record emitted during an - invocation.""" +# Marker attribute used to detect "this is the OA-installed +# LogRecord factory" so re-calling ``install_log_bridge`` doesn't +# stack a second wrapper on top of the already-installed one. +_FACTORY_MARKER = "_openarmature_correlation_factory" + + +def _install_correlation_id_factory() -> None: + """Install a process-global :class:`logging.LogRecord` factory + that reads the openarmature correlation_id ContextVar and + attaches it to every constructed record as the + ``openarmature.correlation_id`` attribute. + + Why a factory instead of a logger filter: filters added to the + ROOT logger only fire for records originating directly on the + root logger — Python's logging propagation walks ancestors' + HANDLERS but not their filters. A filter on root therefore + misses every record from a child logger (the normal case; + every reasonable user does ``logger = logging.getLogger("module")``). + Spec §7 mandates the attribute appear on records emitted from + "anywhere within an invocation" — the factory hooks at record + construction, fires uniformly for every emit regardless of + which logger originated the record, and chains over any + user-installed factory rather than replacing it. + + Idempotent: re-calling skips installation if the current + factory is already the OA-installed one. + """ + from openarmature.observability.correlation import current_correlation_id + + current_factory = logging.getLogRecordFactory() + if getattr(current_factory, _FACTORY_MARKER, False): + # Already installed — re-calling is a no-op. + return - def filter(self, record: logging.LogRecord) -> bool: - from openarmature.observability.correlation import current_correlation_id + prior_factory = current_factory + def _correlation_id_factory(*args: Any, **kwargs: Any) -> logging.LogRecord: + record = prior_factory(*args, **kwargs) cid = current_correlation_id() if cid is not None: # Stored on the log record so any formatter/handler that # reads ``record.__dict__`` (including the OTel # LoggingHandler) sees it. setattr(record, "openarmature.correlation_id", cid) - return True + return record + + setattr(_correlation_id_factory, _FACTORY_MARKER, True) + logging.setLogRecordFactory(_correlation_id_factory) def install_log_bridge( @@ -47,32 +78,31 @@ def install_log_bridge( ) -> None: """Wire the stdlib root logger to the supplied OTel :class:`LoggerProvider`. Adds a - :class:`opentelemetry.sdk._logs.LoggingHandler` for OTel-native - ``trace_id`` / ``span_id`` bridging, AND attaches an - :class:`_CorrelationIdFilter` directly to the ROOT LOGGER (not - the handler) so the ``openarmature.correlation_id`` attribute - lands on every log record emitted during an invocation — - including records consumed by pre-existing stdout / file / - third-party handlers the user already had configured. - - Filter-on-the-root-logger placement matters per spec §7: - "log records emitted from anywhere within an invocation MUST - carry ``openarmature.correlation_id``." A handler-level filter - would only modify records flowing through THAT handler, so a - user's existing stdout handler would see records without the - attribute. The root-logger filter applies to every record, - regardless of which handler eventually processes it. + :class:`opentelemetry.instrumentation.logging.handler.LoggingHandler` + for OTel-native ``trace_id`` / ``span_id`` bridging, AND + installs a process-global :class:`logging.LogRecord` factory + that injects ``openarmature.correlation_id`` on every record. + + The factory placement matters per spec §7: "log records + emitted from anywhere within an invocation MUST carry + ``openarmature.correlation_id``." Filters added to the root + logger fire only for records originating on root — Python's + propagation walks ancestor handlers but not ancestor filters + — so a root-logger filter misses every child-logger record. + The factory hook fires at record construction time, before any + logger or handler dispatch, so every record gets the + attribute regardless of which logger originated it. Idempotent: re-calling is a no-op (we check for the existing - OA-tagged handler AND for an existing filter instance on the - root logger). + OA-tagged handler on the root logger AND for the OA-installed + factory marker on the current global factory). The user retains responsibility for providing the :class:`LoggerProvider` (typically built with their preferred - exporter — :class:`InMemoryLogExporter` for tests, + exporter — :class:`InMemoryLogRecordExporter` for tests, :class:`OTLPLogExporter` for production). """ - from opentelemetry.sdk._logs import LoggingHandler # type: ignore[attr-defined] + from opentelemetry.instrumentation.logging.handler import LoggingHandler root = logging.getLogger() # Idempotency #1: don't double-add the OTel LoggingHandler. @@ -87,11 +117,8 @@ def install_log_bridge( # marker behavior. object.__setattr__(handler, "_openarmature_installed", True) root.addHandler(handler) - # Idempotency #2: don't double-add the correlation_id filter to - # the root logger. - filter_already_installed = any(isinstance(f, _CorrelationIdFilter) for f in root.filters) - if not filter_already_installed: - root.addFilter(_CorrelationIdFilter()) + # Idempotency #2: don't stack the LogRecord factory. + _install_correlation_id_factory() __all__ = [ diff --git a/tests/unit/test_observability_otel.py b/tests/unit/test_observability_otel.py index 19b53a5..94ad62e 100644 --- a/tests/unit/test_observability_otel.py +++ b/tests/unit/test_observability_otel.py @@ -303,70 +303,174 @@ async def test_disable_llm_spans_skips_llm_provider_span() -> None: # --------------------------------------------------------------------------- -def test_log_bridge_filter_injects_correlation_id() -> None: +def test_log_record_factory_injects_correlation_id() -> None: """Spec §7: every log record emitted during an invocation MUST - carry ``openarmature.correlation_id``. The bridge's filter reads - the ContextVar and attaches it to the LogRecord.""" + carry ``openarmature.correlation_id``. The bridge installs a + process-global :class:`logging.LogRecord` factory (rather than + a logger-level filter) so the attribute lands on every record + regardless of which logger originated it — Python's logging + propagates records up the logger tree's HANDLERS but skips + ancestor FILTERS, so a filter on root would miss any + child-logger emit. + + Tests both null-cid (outside invocation) and live-cid paths.""" from openarmature.observability.correlation import ( _reset_correlation_id, _set_correlation_id, ) - from openarmature.observability.otel.logs import _CorrelationIdFilter - - flt = _CorrelationIdFilter() - record = logging.LogRecord( - name="test", - level=logging.INFO, - pathname="", - lineno=0, - msg="hello", - args=None, - exc_info=None, + from openarmature.observability.otel.logs import ( + _install_correlation_id_factory, ) - # Outside an invocation: no correlation_id attribute set. - assert flt.filter(record) is True - assert not hasattr(record, "openarmature.correlation_id") - - # Inside an invocation: filter attaches the ContextVar value. - record2 = logging.LogRecord( - name="test", - level=logging.INFO, - pathname="", - lineno=0, - msg="hello", - args=None, - exc_info=None, - ) - token = _set_correlation_id("my-cid-42") + + prior_factory = logging.getLogRecordFactory() try: - flt.filter(record2) + _install_correlation_id_factory() + factory = logging.getLogRecordFactory() + + # Outside an invocation: no correlation_id attribute set. + record = factory( + "any.child.logger", + logging.INFO, + "", + 0, + "hello", + None, + None, + ) + assert not hasattr(record, "openarmature.correlation_id") + + # Inside an invocation: factory attaches the ContextVar + # value to every newly constructed record. + token = _set_correlation_id("my-cid-42") + try: + record2 = factory( + "any.child.logger", + logging.INFO, + "", + 0, + "hello", + None, + None, + ) + finally: + _reset_correlation_id(token) + assert getattr(record2, "openarmature.correlation_id") == "my-cid-42" finally: - _reset_correlation_id(token) - assert getattr(record2, "openarmature.correlation_id") == "my-cid-42" + # Restore the prior factory — process-global state. + logging.setLogRecordFactory(prior_factory) def test_install_log_bridge_is_idempotent() -> None: """Re-calling :func:`install_log_bridge` MUST NOT register a - duplicate handler — the bridge owns the only OA-flagged - LoggingHandler on the root logger.""" + duplicate handler on the root logger AND MUST NOT stack a + second LogRecord factory wrapper on top of the + already-installed one. + + Wrapped in ``warnings.catch_warnings("error")`` to lock in the + Phase 6.1 PR-B migration: this is the canonical surface where + the deprecated ``opentelemetry.sdk._logs.LoggingHandler`` used + to emit a ``DeprecationWarning``. Any future regression that + re-introduces the deprecated path fires here immediately.""" + import warnings + + from opentelemetry.sdk._logs import LoggerProvider + + root = logging.getLogger() + prior_handlers = list(root.handlers) + prior_filters = list(root.filters) + prior_factory = logging.getLogRecordFactory() + try: + with warnings.catch_warnings(): + warnings.simplefilter("error") + provider = LoggerProvider() + install_log_bridge(provider) + handler_count_before = len(root.handlers) + factory_after_first = logging.getLogRecordFactory() + install_log_bridge(provider) + handler_count_after = len(root.handlers) + factory_after_second = logging.getLogRecordFactory() + assert handler_count_before == handler_count_after + # Factory identity is preserved across re-calls — no + # second wrapper stacked on top of the first. + assert factory_after_first is factory_after_second + finally: + # install_log_bridge mutates process-wide state; restore so + # this test does not leak into others. + root.handlers[:] = prior_handlers + root.filters[:] = prior_filters + logging.setLogRecordFactory(prior_factory) + + +def test_log_bridge_exports_records_with_correlation_id() -> None: + """Spec §7 end-to-end: a log record emitted on a CHILD logger + under ``current_correlation_id`` flows through the bridge to + the OTel ``LoggerProvider``'s exporter with + ``openarmature.correlation_id`` populated. Child-logger emit + is the load-bearing case — Python's logging propagates child + records up to root's handlers but skips root's filters, so a + filter-on-root placement (the prior implementation) misses + every reasonable user's logger. + + Wrapped in ``warnings.catch_warnings("error")`` so the PR-B + migration's "no more deprecation warning" guarantee is + asserted on the affirmative export path too.""" + import warnings + from opentelemetry.sdk._logs import LoggerProvider + from opentelemetry.sdk._logs.export import ( + InMemoryLogRecordExporter, + SimpleLogRecordProcessor, + ) + + from openarmature.observability.correlation import ( + _reset_correlation_id, + _set_correlation_id, + ) root = logging.getLogger() prior_handlers = list(root.handlers) prior_filters = list(root.filters) + prior_factory = logging.getLogRecordFactory() try: - provider = LoggerProvider() - install_log_bridge(provider) - handler_count_before = len(root.handlers) - install_log_bridge(provider) - handler_count_after = len(root.handlers) - assert handler_count_before == handler_count_after + with warnings.catch_warnings(): + warnings.simplefilter("error") + exporter = InMemoryLogRecordExporter() + provider = LoggerProvider() + provider.add_log_record_processor(SimpleLogRecordProcessor(exporter)) + install_log_bridge(provider) + + # Emit on a CHILD logger to verify the factory + # placement (which fires uniformly at record + # construction) actually delivers — a filter-on-root + # placement would not. + child_logger = logging.getLogger("openarmature.test_log_bridge.child") + token = _set_correlation_id("test-cid-export-1") + try: + child_logger.warning("hello from %s", "test") + finally: + _reset_correlation_id(token) + + # SimpleLogRecordProcessor flushes synchronously, but + # force-flush as a belt-and-suspenders guard so any + # buffered emit lands in the exporter before assertions. + provider.force_flush() + records = exporter.get_finished_logs() + # Filter to the record(s) emitted on our test logger — the + # root may receive other records from concurrent test setup. + ours = [r for r in records if r.log_record.body == "hello from test"] + assert len(ours) == 1, ( + f"expected exactly one exported record for our test logger; " + f"got {len(ours)} (full set: {[r.log_record.body for r in records]})" + ) + attrs = dict(ours[0].log_record.attributes or {}) + assert attrs.get("openarmature.correlation_id") == "test-cid-export-1", ( + f"correlation_id MUST appear on the exported OTel LogRecord attributes; " + f"got {attrs.get('openarmature.correlation_id')!r}" + ) finally: - # install_log_bridge mutates the process-wide root logger; - # restore the prior handler + filter set so this test does - # not leak state into others. root.handlers[:] = prior_handlers root.filters[:] = prior_filters + logging.setLogRecordFactory(prior_factory) # --------------------------------------------------------------------------- diff --git a/uv.lock b/uv.lock index dc697ff..b7c9280 100644 --- a/uv.lock +++ b/uv.lock @@ -166,6 +166,7 @@ dependencies = [ [package.optional-dependencies] otel = [ { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation-logging" }, { name = "opentelemetry-sdk" }, ] @@ -183,8 +184,9 @@ dev = [ [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.27" }, - { name = "opentelemetry-api", marker = "extra == 'otel'", specifier = ">=1.27,<2" }, - { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.27,<2" }, + { name = "opentelemetry-api", marker = "extra == 'otel'", specifier = ">=1.27,<3" }, + { name = "opentelemetry-instrumentation-logging", marker = "extra == 'otel'", specifier = ">=0.62.0b1" }, + { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.27,<3" }, { name = "pydantic", specifier = ">=2.7" }, ] provides-extras = ["otel"] @@ -213,6 +215,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/59/3e7118ed140f76b0982ba4321bdaed1997a0473f9720de2d10788a577033/opentelemetry_api-1.41.1-py3-none-any.whl", hash = "sha256:a22df900e75c76dc08440710e51f52f1aa6b451b429298896023e60db5b3139f", size = 69007, upload-time = "2026-04-24T13:15:15.662Z" }, ] +[[package]] +name = "opentelemetry-instrumentation" +version = "0.62b1" +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/52/cb/0523b92c112a6cc70be43724343dc45225d3af134419844d7879a07755d4/opentelemetry_instrumentation-0.62b1.tar.gz", hash = "sha256:90e92a905ba4f84db06ac3aec96701df6c079b2d66e9379f8739f0a1bdcc7f45", size = 34043, upload-time = "2026-04-24T13:22:31.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/0f/45adbaea1f81b847cffdcee4f4b5f89297e42facf7fac78c7aaac4c38e75/opentelemetry_instrumentation-0.62b1-py3-none-any.whl", hash = "sha256:976fc6e640f2006599e97429c949e622c108d0c17c2059347d1e6c93c707f257", size = 34163, upload-time = "2026-04-24T13:21:31.722Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-logging" +version = "0.62b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/25/a30e0160cb3654bb63936be16d8ffe5f4a658d10bec0d5509cca3c74f103/opentelemetry_instrumentation_logging-0.62b1.tar.gz", hash = "sha256:997359d29ce06cb3768677387469431d0484b2450b5c35d7f02361431d3de338", size = 18969, upload-time = "2026-04-24T13:22:54.275Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/e4/216d1c7ff9c10815a8587ecbca0b570596921f001d1e2c2903c6f19e2e90/opentelemetry_instrumentation_logging-0.62b1-py3-none-any.whl", hash = "sha256:969330216d1ae02f4e10f1a030566ae758114caead020817192e6a02c6d1a0e1", size = 17488, upload-time = "2026-04-24T13:22:00.726Z" }, +] + [[package]] name = "opentelemetry-sdk" version = "1.41.1" @@ -553,6 +583,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/8d/edd0bd910ff803c308ee9a6b7778621af0d10252219ad9f19ef4d4982a61/virtualenv-21.2.4-py3-none-any.whl", hash = "sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac", size = 5831232, upload-time = "2026-04-14T22:15:29.342Z" }, ] +[[package]] +name = "wrapt" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/b6/1db817582c49c7fcbb7df6809d0f515af29d7c2fbf57eb44c36e98fb1492/wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9", size = 61255, upload-time = "2026-03-06T02:52:45.663Z" }, + { url = "https://files.pythonhosted.org/packages/a2/16/9b02a6b99c09227c93cd4b73acc3678114154ec38da53043c0ddc1fba0dc/wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748", size = 61848, upload-time = "2026-03-06T02:53:48.728Z" }, + { url = "https://files.pythonhosted.org/packages/af/aa/ead46a88f9ec3a432a4832dfedb84092fc35af2d0ba40cd04aea3889f247/wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e", size = 121433, upload-time = "2026-03-06T02:54:40.328Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9f/742c7c7cdf58b59085a1ee4b6c37b013f66ac33673a7ef4aaed5e992bc33/wrapt-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8", size = 123013, upload-time = "2026-03-06T02:53:26.58Z" }, + { url = "https://files.pythonhosted.org/packages/e8/44/2c3dd45d53236b7ed7c646fcf212251dc19e48e599debd3926b52310fafb/wrapt-2.1.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c", size = 117326, upload-time = "2026-03-06T02:53:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/74/e2/b17d66abc26bd96f89dec0ecd0ef03da4a1286e6ff793839ec431b9fae57/wrapt-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c", size = 121444, upload-time = "2026-03-06T02:54:09.5Z" }, + { url = "https://files.pythonhosted.org/packages/3c/62/e2977843fdf9f03daf1586a0ff49060b1b2fc7ff85a7ea82b6217c1ae36e/wrapt-2.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1", size = 116237, upload-time = "2026-03-06T02:54:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/27fc67914e68d740bce512f11734aec08696e6b17641fef8867c00c949fc/wrapt-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2", size = 120563, upload-time = "2026-03-06T02:53:20.412Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9f/b750b3692ed2ef4705cb305bd68858e73010492b80e43d2a4faa5573cbe7/wrapt-2.1.2-cp312-cp312-win32.whl", hash = "sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0", size = 58198, upload-time = "2026-03-06T02:53:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b2/feecfe29f28483d888d76a48f03c4c4d8afea944dbee2b0cd3380f9df032/wrapt-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63", size = 60441, upload-time = "2026-03-06T02:52:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/44/e1/e328f605d6e208547ea9fd120804fcdec68536ac748987a68c47c606eea8/wrapt-2.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf", size = 58836, upload-time = "2026-03-06T02:53:22.053Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" }, + { url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" }, + { url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" }, + { url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" }, + { url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" }, + { url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" }, + { url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" }, + { url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" }, + { url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" }, + { url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" }, + { url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" }, + { url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" }, + { url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" }, + { url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" }, + { url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" }, + { url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" }, + { url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" }, + { url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" }, + { url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" }, + { url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" }, + { url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" }, + { url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" }, + { url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, +] + [[package]] name = "zipp" version = "3.23.1"