diff --git a/README.md b/README.md index 8a7c57d21..0c2d3d5eb 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,13 @@ To quickly get hands on LCS, we can run it using the default configurations prov ```bash uv sync --group dev --group ogxlibdev ``` + + If you plan to enable OpenTelemetry tracing with in-process PII redaction + (OTEL_TRACES_EXPORTER=redacting_otlp), also download the spaCy model: + ```bash + uv run python -m spacy download en_core_web_lg + ``` + 1. create OGX `run.yaml`. you can do this by running the local run generation script ```bash ./scripts/generate_local_run.sh diff --git a/deploy/lightspeed-stack/Containerfile b/deploy/lightspeed-stack/Containerfile index 6160daf5e..ce1ec1c09 100644 --- a/deploy/lightspeed-stack/Containerfile +++ b/deploy/lightspeed-stack/Containerfile @@ -39,7 +39,9 @@ COPY ${LSC_SOURCE_DIR}/pyproject.toml ${LSC_SOURCE_DIR}/LICENSE ${LSC_SOURCE_DIR COPY ${LSC_SOURCE_DIR}/providers/lightspeed_stack_providers /app-root/lightspeed_stack_providers COPY ${LSC_SOURCE_DIR}/providers/resources/external_providers /app-root/providers.d -# Bundle additional dependencies for library mode. +# Bundle additional dependencies for library mode and install the spaCy model for PII +# redaction. The model is baked into the image so it is available in disconnected +# environments where runtime downloads are not possible. # Source cachi2 environment for hermetic builds if available, otherwise use normal installation # cachi2.env has these env vars: # PIP_FIND_LINKS=/cachi2/output/deps/pip @@ -51,7 +53,8 @@ RUN if [ -f /cachi2/cachi2.env ]; then \ pip install --no-cache-dir --ignore-installed --no-index --find-links ${PIP_FIND_LINKS} --no-deps -r requirements.hashes.wheel.txt -r requirements.hashes.wheel.pypi.txt -r requirements.hashes.source.txt && \ pip check; \ else \ - uv sync --locked --no-dev --group ogxlibdev; \ + uv sync --locked --no-dev --group ogxlibdev && \ + uv run --with pip python -m spacy download en_core_web_lg; \ fi # Explicitly remove some packages to mitigate some CVEs diff --git a/docker-compose-library.yaml b/docker-compose-library.yaml index 0f29b33f8..1823deb4a 100755 --- a/docker-compose-library.yaml +++ b/docker-compose-library.yaml @@ -80,6 +80,10 @@ services: - OTEL_SERVICE_NAME=${OTEL_SERVICE_NAME:-lightspeed-stack-e2e} - OTEL_ANONYMIZATION_SECRET=${OTEL_ANONYMIZATION_SECRET:-lightspeed-stack-otel-anonymization-dev-default} - OTEL_SDK_DISABLED=${OTEL_SDK_DISABLED:-false} + # In-process PII redaction is enabled: OTEL_TRACES_EXPORTER=redacting_otlp + # routes spans through the RedactingSpanExporter before export. The spaCy + # model (en_core_web_lg) is baked into the image at build time. + - OTEL_TRACES_EXPORTER=${OTEL_TRACES_EXPORTER:-redacting_otlp} healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/liveness"] interval: 10s # how often to run the check diff --git a/docker-compose.yaml b/docker-compose.yaml index 958f865b0..1f11f2740 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -123,6 +123,10 @@ services: - OTEL_SERVICE_NAME=${OTEL_SERVICE_NAME:-lightspeed-stack-e2e} - OTEL_ANONYMIZATION_SECRET=${OTEL_ANONYMIZATION_SECRET:-lightspeed-stack-otel-anonymization-dev-default} - OTEL_SDK_DISABLED=${OTEL_SDK_DISABLED:-false} + # In-process PII redaction is enabled: OTEL_TRACES_EXPORTER=redacting_otlp + # routes spans through the RedactingSpanExporter before export. The spaCy + # model (en_core_web_lg) is baked into the image at build time. + - OTEL_TRACES_EXPORTER=${OTEL_TRACES_EXPORTER:-redacting_otlp} depends_on: ogx: condition: service_healthy @@ -229,7 +233,6 @@ services: retries: 3 start_period: 5s - volumes: ogx-storage: mock-tls-certs: diff --git a/docs/devel_doc/installation_linux.md b/docs/devel_doc/installation_linux.md index f724b9119..eb2c45dc0 100644 --- a/docs/devel_doc/installation_linux.md +++ b/docs/devel_doc/installation_linux.md @@ -15,6 +15,8 @@ 1. `cd service` 1. `uv info` -- should return no error 1. `uv install` +1. (Optional) If you plan to use OpenTelemetry tracing with in-process PII redaction: + `uv run python -m spacy download en_core_web_lg` Note: `pip` installs the `uv` binary into `~/.local/bin`, which is often missing from `$PATH` on fresh distributions. If the `uv` command is not found, please diff --git a/docs/devel_doc/installation_macos.md b/docs/devel_doc/installation_macos.md index a1ec0e62a..9ac4e12cd 100644 --- a/docs/devel_doc/installation_macos.md +++ b/docs/devel_doc/installation_macos.md @@ -15,3 +15,5 @@ `git clone https://github.com/lightspeed-core/lightspeed-stack` 1. `cd lightspeed-stack` 1. `uv sync` +1. (Optional) If you plan to use OpenTelemetry tracing with in-process PII redaction: + `uv run python -m spacy download en_core_web_lg` diff --git a/pyproject.toml b/pyproject.toml index 01a53ea07..2347e9409 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,6 +94,7 @@ dependencies = [ "presidio-analyzer>=2.2.364", "presidio-anonymizer>=2.2.364", "boto3", + # Used by the redacting span exporter for safe HTML redaction "nh3>=0.3.6", ] @@ -116,6 +117,11 @@ paths = ["tests/e2e/features"] [project.scripts] lightspeed-stack = "lightspeed_stack:main" +# In-process PII redaction span exporter. Enable by setting +# OTEL_TRACES_EXPORTER=redacting_otlp (auto-instrumentation picks it up). +[project.entry-points.opentelemetry_traces_exporter] +redacting_otlp = "observability.redacting_span_exporter:RedactingSpanExporter" + [project.urls] Homepage = "https://github.com/lightspeed-core/lightspeed-stack" Issues = "https://github.com/lightspeed-core/lightspeed-stack/issues" diff --git a/src/observability/redacting_span_exporter.py b/src/observability/redacting_span_exporter.py new file mode 100644 index 000000000..fdc8f1477 --- /dev/null +++ b/src/observability/redacting_span_exporter.py @@ -0,0 +1,182 @@ +"""Span exporter that redacts PII from span content before delegating to OTLP. + +In-process (producer-side) sanitization: redaction happens inside lightspeed-core, +before spans leave the process, so no unredacted span is ever put on the wire. + +It is wired via the ``opentelemetry_traces_exporter`` entry point, selected with +``OTEL_TRACES_EXPORTER=redacting_otlp``. Auto-instrumentation +(``opentelemetry-instrument``) instantiates it and wraps it in the +``BatchSpanProcessor``, so ``export()`` runs on the processor's background thread, +off the request path. The request/handler code is unchanged. + +Redaction is delegated to the vendored ``pii_redaction_engine`` (Presidio + spaCy). +""" + +from __future__ import annotations + +import os +from collections.abc import Sequence + +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter as GRPCSpanExporter, +) +from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( + OTLPSpanExporter as HTTPSpanExporter, +) +from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult + +from log import get_logger +from pii_redaction_engine.anonymizer import PIIAnonymizer + +logger = get_logger(__name__) + +# Span attributes whose string values may carry PII (see utils.otel_tracing). +# Note: request.safety_identifier is intentionally excluded (it is a caller-supplied +# identifier for tracking, not user content). +CONTENT_ATTRIBUTES: tuple[str, ...] = ( + "request.input", + "response.output", + "rag.input", + "tool_results", + "feedback.comment", +) + + +def _default_otlp_exporter() -> SpanExporter: + """Return an OTLP span exporter matching the configured protocol. + + Matches the stock SDK's supported values exactly: ``grpc`` (default) or + ``http/protobuf``. Both read the standard ``OTEL_EXPORTER_OTLP_*`` environment + for endpoint, headers, and TLS. Any other value (e.g. ``http/json``, which + neither OTLP span exporter supports) is rejected rather than silently sending + protobuf. + + Returns: + A gRPC or HTTP OTLP span exporter. + + Raises: + ValueError: if the configured protocol is not ``grpc`` or ``http/protobuf``. + + """ + protocol = ( + ( + os.getenv("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL") + or os.getenv("OTEL_EXPORTER_OTLP_PROTOCOL") + or "grpc" + ) + .strip() + .lower() + ) + if protocol == "grpc": + return GRPCSpanExporter() + if protocol == "http/protobuf": + return HTTPSpanExporter() + raise ValueError( + f"unsupported OTEL_EXPORTER_OTLP_PROTOCOL {protocol!r}; " + "supported values are 'grpc' and 'http/protobuf'" + ) + + +class RedactingSpanExporter(SpanExporter): + """Wraps an OTLP span exporter, redacting content attributes before export. + + Attributes: + delegate: The downstream exporter that actually ships the (redacted) spans. + + """ + + def __init__( + self, + delegate: SpanExporter | None = None, + anonymizer: PIIAnonymizer | None = None, + ) -> None: + """Initialize the exporter. + + Args: + delegate: Downstream exporter. Defaults to an OTLP exporter matching + ``OTEL_EXPORTER_OTLP_PROTOCOL`` (gRPC or HTTP), which reads the + standard ``OTEL_EXPORTER_OTLP_*`` environment. + anonymizer: Redaction engine. Defaults to a ``PIIAnonymizer``, which + loads the spaCy model once at construction (off the request path). + Injectable for testing. + + """ + self._delegate = delegate or _default_otlp_exporter() + self._anonymizer = anonymizer or PIIAnonymizer() + logger.info( + "RedactingSpanExporter active; content attributes redacted in-process" + ) + + def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: + """Redact content attributes, then delegate the export. + + Fail closed: if redaction of a span raises, that span is dropped rather + than exported unredacted, so a redaction failure can never leak PII. Other + spans in the batch are still exported. + + Args: + spans: The spans to redact and export. + + Returns: + The delegate's export result, or ``SUCCESS`` if every span was dropped + (reporting failure would only make the batch processor retry the same + failing spans). + + """ + safe_spans: list[ReadableSpan] = [] + for span in spans: + try: + self._redact_span(span) + except Exception: # pylint: disable=broad-except # noqa: BLE001 + # Log the span name only, never the content being redacted. + logger.error( + "in-process redaction failed for span %r; dropping it to avoid " + "exporting unredacted PII", + span.name, + ) + continue + safe_spans.append(span) + + if not safe_spans: + return SpanExportResult.SUCCESS + return self._delegate.export(safe_spans) + + def _redact_span(self, span: ReadableSpan) -> None: + """Rewrite the content attributes of a span in place with redacted text. + + Args: + span: The span whose content attributes are redacted in place. + + Raises: + Exception: if the redaction engine fails. The caller drops the span + (fail closed) rather than exporting unredacted content. + + """ + attributes = span.attributes + if not attributes: + return + + new_attributes = dict(attributes) + changed = False + for key in CONTENT_ATTRIBUTES: + value = new_attributes.get(key) + if not isinstance(value, str) or not value: + continue + redacted = self._anonymizer.anonymize_text(value) + if redacted != value: + new_attributes[key] = redacted + changed = True + + if changed: + # ReadableSpan exposes attributes via self._attributes; replacing it + # with a plain dict is what the OTLP encoder reads at export time. + span._attributes = new_attributes # pylint: disable=protected-access + + def shutdown(self) -> None: + """Shut down the delegate exporter.""" + self._delegate.shutdown() + + def force_flush(self, timeout_millis: int = 30_000) -> bool: + """Force-flush the delegate exporter.""" + return self._delegate.force_flush(timeout_millis) diff --git a/tests/e2e/features/otel_redaction.feature b/tests/e2e/features/otel_redaction.feature new file mode 100644 index 000000000..e921ac7fd --- /dev/null +++ b/tests/e2e/features/otel_redaction.feature @@ -0,0 +1,63 @@ +# In-process PII redaction e2e. +# +# The e2e stack runs with in-process redaction on by default: lightspeed-stack is +# built with the spaCy model (en_core_web_lg, baked into the image) and runs the +# RedactingSpanExporter (OTEL_TRACES_EXPORTER=redacting_otlp, set in docker-compose). +# Spans are redacted in-process before being exported over OTLP to the mock +# collector (mock-otel), which the test uses as a telemetry sink. This scenario +# asserts a known PII value sent in a query never reaches the collector, while a +# non-PII marker still arrives. +@cfg_authorized @OTel @OTel-redaction @skip-in-prow +Feature: In-process PII redaction of exported telemetry + + Background: + Given The service is started locally + And The system is in default state + And An OpenTelemetry service is running and listening for OTLP data + And I set the Authorization header to Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ikpva + And REST API service prefix is /v1 + And the Lightspeed stack configuration directory is "tests/e2e/configuration" + And The service uses the lightspeed-stack-authorized.yaml configuration + And The service is restarted + + + Scenario: PII in a responses endpoint query is redacted in-process before telemetry is exported + Given The system is in default state + When I use "responses" to ask question with authorization header + """ + { + "input": "Reach me at sam@redhat.com regarding e2e-redaction-marker", + "model": "{PROVIDER}/{MODEL}", + "stream": false, + "safety_identifier": "e2e-redaction-marker" + } + """ + Then The status code of the response is 200 + And The OpenTelemetry service received data containing e2e-redaction-marker + And The OpenTelemetry service did not receive data containing sam@redhat.com + + + Scenario: PII in a query endpoint request is redacted in-process before telemetry is exported + Given The system is in default state + When I use "query" to ask question with authorization header + """ + { + "query": "My email is john.doe@redhat.com for e2e-query-redaction-marker" + } + """ + Then The status code of the response is 200 + And The OpenTelemetry service received data containing e2e-query-redaction-marker + And The OpenTelemetry service did not receive data containing john.doe@redhat.com + + + Scenario: PII in a streaming_query endpoint request is redacted in-process before telemetry is exported + Given The system is in default state + When I use "streaming_query" to ask question with authorization header + """ + { + "query": "Contact me at alice.smith@redhat.com for e2e-streaming-redaction-marker" + } + """ + Then The status code of the response is 200 + And The OpenTelemetry service received data containing e2e-streaming-redaction-marker + And The OpenTelemetry service did not receive data containing alice.smith@redhat.com diff --git a/tests/e2e/features/steps/opentelemetry.py b/tests/e2e/features/steps/opentelemetry.py index 4d9f25939..6bb3a0753 100644 --- a/tests/e2e/features/steps/opentelemetry.py +++ b/tests/e2e/features/steps/opentelemetry.py @@ -85,3 +85,29 @@ def collector_received_data(context: Context, marker: str) -> None: f"Mock OTEL collector did not receive data containing {marker!r} " f"within {_DELIVERY_TIMEOUT_S:.0f}s" ) + + +@then("The OpenTelemetry service did not receive data containing {marker}") +def collector_did_not_receive_data(context: Context, marker: str) -> None: + """Assert the mock collector buffered telemetry that does NOT contain ``marker``. + + Used to verify in-process redaction: a known PII value sent in a query must be + absent from the exported telemetry. Ordering matters — a preceding "received + data containing ..." step should already have confirmed the span was delivered, + so a single check proves the value is absent (redacted) rather than merely + not-yet-delivered. + """ + assert getattr( + context, "otel_collector_ready", False + ), "The OpenTelemetry service must be started before asserting on delivery" + marker = marker.strip() + response = requests.get( + f"{MOCK_OTEL_CONTROL_BASE}/received", params={"contains": marker}, timeout=5 + ) + assert ( + response.status_code == 200 + ), f"Failed to query mock OTEL collector: HTTP {response.status_code}" + assert not response.json().get("found"), ( + f"Mock OTEL collector unexpectedly received data containing {marker!r} " + "(expected it to be redacted in-process)" + ) diff --git a/tests/e2e/test_list.txt b/tests/e2e/test_list.txt index e3bd6d7ce..128b1988e 100644 --- a/tests/e2e/test_list.txt +++ b/tests/e2e/test_list.txt @@ -16,6 +16,7 @@ features/authorized_noop_token.feature features/conversations.feature features/faiss.feature features/opentelemetry.feature +features/otel_redaction.feature features/prompts.feature features/query.feature features/responses.feature diff --git a/tests/unit/observability/test_redacting_span_exporter.py b/tests/unit/observability/test_redacting_span_exporter.py new file mode 100644 index 000000000..3fff90588 --- /dev/null +++ b/tests/unit/observability/test_redacting_span_exporter.py @@ -0,0 +1,139 @@ +"""Unit tests for the in-process redacting span exporter.""" + +from collections.abc import Callable +from typing import Any + +import pytest +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter as GRPCSpanExporter, +) +from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( + OTLPSpanExporter as HTTPSpanExporter, +) +from opentelemetry.sdk.trace.export import SpanExportResult +from pytest_mock import MockerFixture + +from observability.redacting_span_exporter import ( + RedactingSpanExporter, + _default_otlp_exporter, +) + + +class _FakeSpan: # pylint: disable=too-few-public-methods + """Minimal stand-in for a ReadableSpan: exposes .name, .attributes, ._attributes.""" + + def __init__(self, attributes: dict, name: str = "test.span") -> None: + self._attributes = dict(attributes) + self.name = name + + @property + def attributes(self) -> dict: + """Return the span's attribute mapping, as ReadableSpan does.""" + return self._attributes + + +def _make_exporter( + mocker: MockerFixture, + anonymize_side_effect: Callable[[str], str] | None = None, +) -> tuple[RedactingSpanExporter, Any, Any]: + """Build a RedactingSpanExporter with a mocked delegate and anonymizer.""" + delegate = mocker.Mock() + delegate.export.return_value = SpanExportResult.SUCCESS + anonymizer = mocker.Mock() + anonymizer.anonymize_text.side_effect = anonymize_side_effect or ( + lambda text: text.replace("sam@redhat.com", "") + ) + exporter = RedactingSpanExporter(delegate=delegate, anonymizer=anonymizer) + return exporter, delegate, anonymizer + + +def test_redacts_content_attributes_and_delegates(mocker: MockerFixture) -> None: + """Content attributes are redacted; other attributes and the span pass through.""" + exporter, delegate, _ = _make_exporter(mocker) + span = _FakeSpan( + { + "request.input": "sam@redhat.com asks about K8s", + "response.output": "hello sam@redhat.com", + "llm.model.id": "gpt-4o-mini", + } + ) + + result = exporter.export([span]) + + assert result is SpanExportResult.SUCCESS + assert span.attributes["request.input"] == " asks about K8s" + assert span.attributes["response.output"] == "hello " + assert span.attributes["llm.model.id"] == "gpt-4o-mini" + delegate.export.assert_called_once_with([span]) + + +def test_skips_missing_non_string_and_empty_values(mocker: MockerFixture) -> None: + """Empty, non-string, and absent content attributes are left alone.""" + exporter, _, anonymizer = _make_exporter(mocker) + span = _FakeSpan({"request.input": "", "rag.input": 123}) + + exporter.export([span]) + + anonymizer.anonymize_text.assert_not_called() + assert span.attributes == {"request.input": "", "rag.input": 123} + + +def test_fail_closed_drops_span_on_redaction_error(mocker: MockerFixture) -> None: + """A redaction error drops the span (fail closed): it is never exported.""" + + def boom(_text: str) -> str: + raise RuntimeError("anonymizer unavailable") + + exporter, delegate, _ = _make_exporter(mocker, anonymize_side_effect=boom) + span = _FakeSpan({"request.input": "sam@redhat.com"}) + + result = exporter.export([span]) + + delegate.export.assert_not_called() + assert result is SpanExportResult.SUCCESS + + +def test_fail_closed_drops_only_the_failing_span(mocker: MockerFixture) -> None: + """A span whose redaction fails is dropped; healthy spans still export.""" + + def selective(text: str) -> str: + if "boom" in text: + raise RuntimeError("anonymizer unavailable") + return text.replace("sam@redhat.com", "") + + exporter, delegate, _ = _make_exporter(mocker, anonymize_side_effect=selective) + good = _FakeSpan({"request.input": "contact sam@redhat.com"}) + bad = _FakeSpan({"request.input": "boom trigger"}) + + result = exporter.export([good, bad]) + + assert result is SpanExportResult.SUCCESS + delegate.export.assert_called_once_with([good]) + assert good.attributes["request.input"] == "contact " + + +def test_shutdown_and_force_flush_delegate(mocker: MockerFixture) -> None: + """shutdown and force_flush delegate to the downstream exporter.""" + exporter, delegate, _ = _make_exporter(mocker) + + exporter.shutdown() + delegate.shutdown.assert_called_once() + + exporter.force_flush(1234) + delegate.force_flush.assert_called_once_with(1234) + + +def test_default_exporter_defaults_to_grpc(monkeypatch: pytest.MonkeyPatch) -> None: + """With no protocol set, the default delegate is the gRPC OTLP exporter.""" + monkeypatch.delenv("OTEL_EXPORTER_OTLP_PROTOCOL", raising=False) + monkeypatch.delenv("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", raising=False) + assert isinstance(_default_otlp_exporter(), GRPCSpanExporter) + + +def test_default_exporter_honors_http_protocol( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf selects the HTTP OTLP exporter.""" + monkeypatch.delenv("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", raising=False) + monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf") + assert isinstance(_default_otlp_exporter(), HTTPSpanExporter) diff --git a/uv.lock b/uv.lock index 4573cff11..d35ee6b7b 100644 --- a/uv.lock +++ b/uv.lock @@ -584,30 +584,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.43.121" +version = "1.43.122" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/5c/41fb4571d14c1cfc87543a1e6f491f36f89c1ceac966aeddd61651ee4ba3/boto3-1.43.121.tar.gz", hash = "sha256:0599bdde7d0c03d914d9247b0a328ea48c15b0b3b5fb31a153857fa5bc7a4fd3", size = 112685, upload-time = "2026-10-28T19:19:00.615Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/0b/6fb3820f86bf7d65313e85f7fb360147eb35170b5c306753eca69539b24f/boto3-1.43.122.tar.gz", hash = "sha256:c923650d09e4c445564ca14d5ce89466c3083884b6f6c189f0f445fc17f02eb5", size = 112646, upload-time = "2026-10-29T19:19:55.597Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/57/a5fda6560d519037cc7df6dd116d5d66e7914be2f562e8c7b1ef888161a2/boto3-1.43.121-py3-none-any.whl", hash = "sha256:fc41ea80507416c30a3f7bdea3682124551ec0c36405c0d8149efaf65d9628e7", size = 140041, upload-time = "2026-10-28T19:18:58.973Z" }, + { url = "https://files.pythonhosted.org/packages/ee/fd/fef3d36be7875f413d8ef3a17c1743b64f9686693d1aed8d5126e7fa1dcd/boto3-1.43.122-py3-none-any.whl", hash = "sha256:cca51b3650080594a1df344b8a2e76007eab6d5fa50504a0b622dfdc14cdeaa1", size = 140042, upload-time = "2026-10-29T19:19:54.241Z" }, ] [[package]] name = "botocore" -version = "1.43.121" +version = "1.43.122" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/98/1a07ef43bc267080b40709eba041fb39353f341401064912935b7e92bc07/botocore-1.43.121.tar.gz", hash = "sha256:69eb571c74ff140cc3a716aed3bc25d09eb7b12de4ea2f990c03785a634f9dcf", size = 16475471, upload-time = "2026-10-28T19:18:54.938Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/56/30404d13529fd3fc0a142f9ec2b6680778c0b8183eeef932401e7954dc9c/botocore-1.43.122.tar.gz", hash = "sha256:2fc53b68b35314b1738cad46e6170d0556496863913451428fc3afccb8c7fa95", size = 16478232, upload-time = "2026-10-29T19:19:51.025Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/a8/5a0730dda5e076588c45c8ce73a56d1fc630accb994bfad0503c6825daf5/botocore-1.43.121-py3-none-any.whl", hash = "sha256:39bfd17e69f3370350e6aac2072df64045d002f9878e09115e85b87c772bd473", size = 16174916, upload-time = "2026-10-28T19:18:52.083Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ee/bde83952daa267697a73c0c0dada587127e5022056c802bf07e0a9ad8b95/botocore-1.43.122-py3-none-any.whl", hash = "sha256:668e608edf8847fc4c3c6b38688e183c3bcf19b8a1f7f58d33c03a6f3039bd49", size = 16178674, upload-time = "2026-10-29T19:19:47.726Z" }, ] [[package]] @@ -1928,8 +1928,8 @@ name = "httpcore2" version = "2.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11", marker = "sys_platform != 'emscripten'" }, - { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "h11" }, + { name = "truststore" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/61/afa9d6b7452f9eca73f73d45324eb0bb52f098fd5e6049bf6b8263be9b4e/httpcore2-2.16.0.tar.gz", hash = "sha256:c72cd0413563b17e1cd8354677dff887466f8ec2551679759e93126534428643", size = 69886, upload-time = "2026-10-21T07:09:45.31Z" } wheels = [ @@ -5380,8 +5380,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "jeepney", marker = "sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "cryptography" }, + { name = "jeepney" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ @@ -5942,13 +5942,13 @@ resolution-markers = [ "python_full_version < '3.13' and sys_platform == 'darwin'", ] dependencies = [ - { name = "filelock", marker = "sys_platform == 'darwin'" }, - { name = "fsspec", marker = "sys_platform == 'darwin'" }, - { name = "jinja2", marker = "sys_platform == 'darwin'" }, - { name = "networkx", marker = "sys_platform == 'darwin'" }, - { name = "setuptools", marker = "sys_platform == 'darwin'" }, - { name = "sympy", marker = "sys_platform == 'darwin'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "typing-extensions" }, ] wheels = [ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:43b35116802c85fb88d99f4a396b8bd4472bfca1dd82e69499e5a4f9b8b4e252", upload-time = "2026-03-23T15:16:58Z" }, @@ -5974,13 +5974,13 @@ resolution-markers = [ "python_full_version < '3.13' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "filelock", marker = "sys_platform != 'darwin'" }, - { name = "fsspec", marker = "sys_platform != 'darwin'" }, - { name = "jinja2", marker = "sys_platform != 'darwin'" }, - { name = "networkx", marker = "sys_platform != 'darwin'" }, - { name = "setuptools", marker = "sys_platform != 'darwin'" }, - { name = "sympy", marker = "sys_platform != 'darwin'" }, - { name = "typing-extensions", marker = "sys_platform != 'darwin'" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "typing-extensions" }, ] wheels = [ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp312-cp312-linux_s390x.whl", hash = "sha256:2db3ae5404e32cb42b5fcbd94f13607761eaec0cf1687fde95095289d1e26cfb", upload-time = "2026-04-28T00:06:06Z" },