diff --git a/README.md b/README.md index dece2e99..af00ed0c 100644 --- a/README.md +++ b/README.md @@ -491,6 +491,9 @@ export AZURE_API_BASE="https://your-resource.openai.azure.com/" export API_KEY="your-api-endpoint-key" ``` +#### Optional: Langfuse +After a run, you can send one trace with per-metric scores to [Langfuse](https://langfuse.com/). Install `lightspeed-evaluation[langfuse]`, set `LANGFUSE_PUBLIC_KEY` and `LANGFUSE_SECRET_KEY` (and `LANGFUSE_HOST` if not using the default cloud), then use `lightspeed-eval --langfuse` or set `LIGHTSPEED_USE_LANGFUSE=1`. From Python, pass `on_complete=build_langfuse_on_complete_callback()` (from `lightspeed_evaluation.integrations.langfuse_reporter`) to `evaluate()`. + ## šŸ“ˆ Output & Visualization ### Generated Reports diff --git a/pyproject.toml b/pyproject.toml index dfd1528d..01668658 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,8 +52,18 @@ nlp-metrics = [ "rapidfuzz>=3.0.0,<=3.14.3", # Required for semantic_similarity_distance ] +# Optional Langfuse reporting (on_complete / CLI --langfuse). Uses the v2 SDK. +# pip install 'lightspeed-evaluation[langfuse]' +# or +# uv sync --extra langfuse +langfuse = [ + "langfuse>=2.0.0,<3.0.0", +] + [dependency-groups] dev = [ + # Matches [project.optional-dependencies] langfuse — for typecheck/tests. + "langfuse>=2.0.0,<3.0.0", "bandit>=1.7.0,<=1.9.2", "black==25.1.0", "mypy>=1.15.0,<=1.17.1", diff --git a/requirements-all-extras.txt b/requirements-all-extras.txt index dc814276..68935708 100644 --- a/requirements-all-extras.txt +++ b/requirements-all-extras.txt @@ -20,6 +20,7 @@ annotated-types==0.7.0 anyio==4.13.0 # via # httpx + # langfuse # openai appdirs==1.4.4 # via ragas @@ -29,7 +30,9 @@ attrs==26.1.0 # jsonschema # referencing backoff==2.2.1 - # via posthog + # via + # langfuse + # posthog certifi==2026.2.25 # via # httpcore @@ -134,6 +137,7 @@ httpcore==1.0.9 httpx==0.28.1 # via # huggingface-hub + # langfuse # langgraph-sdk # langsmith # lightspeed-evaluation @@ -152,6 +156,7 @@ idna==3.11 # via # anyio # httpx + # langfuse # requests # yarl importlib-metadata==8.7.1 @@ -215,6 +220,8 @@ langchain-openai==1.1.12 # via ragas langchain-text-splitters==1.1.1 # via langchain-classic +langfuse==2.60.10 + # via lightspeed-evaluation langgraph==1.1.6 # via langchain langgraph-checkpoint==4.0.1 @@ -305,11 +312,12 @@ orjson==3.11.8 # langsmith ormsgpack==1.12.2 # via langgraph-checkpoint -packaging==26.0 +packaging==24.2 # via # datasets # huggingface-hub # langchain-core + # langfuse # langsmith # marshmallow # matplotlib @@ -365,6 +373,7 @@ pydantic==2.11.7 # langchain-classic # langchain-core # langchain-google-genai + # langfuse # langgraph # langsmith # lightspeed-evaluation @@ -448,6 +457,7 @@ requests==2.33.1 # instructor # langchain-classic # langchain-community + # langfuse # langsmith # posthog # requests-toolbelt @@ -587,6 +597,8 @@ uuid-utils==0.14.1 # langsmith wheel==0.46.3 # via deepeval +wrapt==1.17.3 + # via langfuse xxhash==3.6.0 # via # datasets diff --git a/requirements-local-embeddings.txt b/requirements-local-embeddings.txt index 972bccf4..7a03146b 100644 --- a/requirements-local-embeddings.txt +++ b/requirements-local-embeddings.txt @@ -293,7 +293,7 @@ orjson==3.11.8 # langsmith ormsgpack==1.12.2 # via langgraph-checkpoint -packaging==26.0 +packaging==24.2 # via # datasets # huggingface-hub diff --git a/requirements-nlp-metrics.txt b/requirements-nlp-metrics.txt index 10189763..13ae7911 100644 --- a/requirements-nlp-metrics.txt +++ b/requirements-nlp-metrics.txt @@ -291,7 +291,7 @@ orjson==3.11.8 # langsmith ormsgpack==1.12.2 # via langgraph-checkpoint -packaging==26.0 +packaging==24.2 # via # datasets # huggingface-hub diff --git a/requirements.txt b/requirements.txt index 07269d45..70da467f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -279,7 +279,7 @@ orjson==3.11.8 # langsmith ormsgpack==1.12.2 # via langgraph-checkpoint -packaging==26.0 +packaging==24.2 # via # datasets # huggingface-hub diff --git a/src/lightspeed_evaluation/__init__.py b/src/lightspeed_evaluation/__init__.py index c137854a..d36239dd 100644 --- a/src/lightspeed_evaluation/__init__.py +++ b/src/lightspeed_evaluation/__init__.py @@ -26,6 +26,7 @@ APIConfig, EvaluationData, EvaluationResult, + EvaluationRunContext, LLMConfig, LoggingConfig, TurnData, @@ -80,6 +81,10 @@ "EvaluationData": ("lightspeed_evaluation.core.models", "EvaluationData"), "TurnData": ("lightspeed_evaluation.core.models", "TurnData"), "EvaluationResult": ("lightspeed_evaluation.core.models", "EvaluationResult"), + "EvaluationRunContext": ( + "lightspeed_evaluation.core.models", + "EvaluationRunContext", + ), "EvaluationSummary": ( "lightspeed_evaluation.core.models.summary", "EvaluationSummary", diff --git a/src/lightspeed_evaluation/api.py b/src/lightspeed_evaluation/api.py index 2543287a..a5ec7f60 100644 --- a/src/lightspeed_evaluation/api.py +++ b/src/lightspeed_evaluation/api.py @@ -23,11 +23,13 @@ print(summary.by_metric) """ +from collections.abc import Callable from typing import Optional from lightspeed_evaluation.core.models import ( EvaluationData, EvaluationResult, + EvaluationRunContext, SystemConfig, TurnData, ) @@ -36,10 +38,15 @@ from lightspeed_evaluation.pipeline.evaluation import EvaluationPipeline -def evaluate( +def evaluate( # pylint: disable=too-many-arguments config: SystemConfig, data: list[EvaluationData], output_dir: Optional[str] = None, + *, + evaluation_data_path: Optional[str] = None, + on_complete: Optional[ + Callable[[list[EvaluationResult], EvaluationRunContext], None] + ] = None, ) -> list[EvaluationResult]: """Run evaluation on the provided data using the given configuration. @@ -51,6 +58,12 @@ def evaluate( config: A pre-built SystemConfig instance. data: List of EvaluationData conversations to evaluate. output_dir: Optional override for the output directory. + evaluation_data_path: Optional path to the evaluation data file, used + for run naming and in :class:`EvaluationRunContext` (e.g. Langfuse). + on_complete: Optional callback after a successful run; receives results + and an :class:`EvaluationRunContext`. See + :mod:`lightspeed_evaluation.integrations.langfuse_reporter` for + a Langfuse helper. Failures in the callback do not fail the run. Returns: List of EvaluationResult objects (one per metric per turn/conversation). @@ -61,16 +74,25 @@ def evaluate( loader = ConfigLoader.from_config(config) pipeline = EvaluationPipeline(loader, output_dir) try: - return pipeline.run_evaluation(data) + return pipeline.run_evaluation( + data, + original_data_path=evaluation_data_path, + on_complete=on_complete, + ) finally: pipeline.close() -def evaluate_with_summary( +def evaluate_with_summary( # pylint: disable=too-many-arguments config: SystemConfig, data: list[EvaluationData], output_dir: Optional[str] = None, compute_confidence_intervals: bool = False, + *, + evaluation_data_path: Optional[str] = None, + on_complete: Optional[ + Callable[[list[EvaluationResult], EvaluationRunContext], None] + ] = None, ) -> EvaluationSummary: """Run evaluation and return structured results with computed statistics. @@ -84,11 +106,19 @@ def evaluate_with_summary( output_dir: Optional override for the output directory. compute_confidence_intervals: Whether to compute bootstrap confidence intervals. Default False. + evaluation_data_path: Same as for :func:`evaluate`. + on_complete: Same as for :func:`evaluate`. Returns: EvaluationSummary with results and computed statistics. """ - results = evaluate(config, data, output_dir=output_dir) + results = evaluate( + config, + data, + output_dir=output_dir, + evaluation_data_path=evaluation_data_path, + on_complete=on_complete, + ) return EvaluationSummary.from_results( results, evaluation_data=data if data else None, @@ -96,10 +126,15 @@ def evaluate_with_summary( ) -def evaluate_conversation( +def evaluate_conversation( # pylint: disable=too-many-arguments config: SystemConfig, data: EvaluationData, output_dir: Optional[str] = None, + *, + evaluation_data_path: Optional[str] = None, + on_complete: Optional[ + Callable[[list[EvaluationResult], EvaluationRunContext], None] + ] = None, ) -> list[EvaluationResult]: """Evaluate a single conversation group. @@ -109,18 +144,31 @@ def evaluate_conversation( config: A pre-built SystemConfig instance. data: A single EvaluationData conversation to evaluate. output_dir: Optional override for the output directory. + evaluation_data_path: Same as for :func:`evaluate`. + on_complete: Same as for :func:`evaluate`. Returns: List of EvaluationResult objects. """ - return evaluate(config, [data], output_dir=output_dir) + return evaluate( + config, + [data], + output_dir=output_dir, + evaluation_data_path=evaluation_data_path, + on_complete=on_complete, + ) -def evaluate_conversation_with_summary( +def evaluate_conversation_with_summary( # pylint: disable=too-many-arguments config: SystemConfig, data: EvaluationData, output_dir: Optional[str] = None, compute_confidence_intervals: bool = False, + *, + evaluation_data_path: Optional[str] = None, + on_complete: Optional[ + Callable[[list[EvaluationResult], EvaluationRunContext], None] + ] = None, ) -> EvaluationSummary: """Evaluate a single conversation and return structured results. @@ -132,6 +180,8 @@ def evaluate_conversation_with_summary( output_dir: Optional override for the output directory. compute_confidence_intervals: Whether to compute bootstrap confidence intervals. Default False. + evaluation_data_path: Same as for :func:`evaluate`. + on_complete: Same as for :func:`evaluate`. Returns: EvaluationSummary with results and computed statistics. @@ -141,15 +191,22 @@ def evaluate_conversation_with_summary( [data], output_dir=output_dir, compute_confidence_intervals=compute_confidence_intervals, + evaluation_data_path=evaluation_data_path, + on_complete=on_complete, ) -def evaluate_turn( +def evaluate_turn( # pylint: disable=too-many-arguments config: SystemConfig, turn: TurnData, metrics: Optional[list[str]] = None, conversation_group_id: str = "programmatic_eval", output_dir: Optional[str] = None, + *, + evaluation_data_path: Optional[str] = None, + on_complete: Optional[ + Callable[[list[EvaluationResult], EvaluationRunContext], None] + ] = None, ) -> list[EvaluationResult]: """Evaluate a single turn. @@ -163,6 +220,8 @@ def evaluate_turn( metrics: Optional list of metric identifiers to override turn_metrics. conversation_group_id: Conversation group ID for the wrapper. output_dir: Optional override for the output directory. + evaluation_data_path: Same as for :func:`evaluate`. + on_complete: Same as for :func:`evaluate`. Returns: List of EvaluationResult objects. @@ -174,15 +233,26 @@ def evaluate_turn( conversation_group_id=conversation_group_id, turns=[turn], ) - return evaluate(config, [data], output_dir=output_dir) + return evaluate( + config, + [data], + output_dir=output_dir, + evaluation_data_path=evaluation_data_path, + on_complete=on_complete, + ) -def evaluate_turn_with_summary( +def evaluate_turn_with_summary( # pylint: disable=too-many-arguments config: SystemConfig, turn: TurnData, metrics: Optional[list[str]] = None, conversation_group_id: str = "programmatic_eval", output_dir: Optional[str] = None, + *, + evaluation_data_path: Optional[str] = None, + on_complete: Optional[ + Callable[[list[EvaluationResult], EvaluationRunContext], None] + ] = None, ) -> EvaluationSummary: """Evaluate a single turn and return structured results. @@ -194,6 +264,8 @@ def evaluate_turn_with_summary( metrics: Optional list of metric identifiers to override turn_metrics. conversation_group_id: Conversation group ID for the wrapper. output_dir: Optional override for the output directory. + evaluation_data_path: Same as for :func:`evaluate`. + on_complete: Same as for :func:`evaluate`. Returns: EvaluationSummary with results and computed statistics. @@ -210,4 +282,6 @@ def evaluate_turn_with_summary( [data], output_dir=output_dir, compute_confidence_intervals=False, + evaluation_data_path=evaluation_data_path, + on_complete=on_complete, ) diff --git a/src/lightspeed_evaluation/core/models/__init__.py b/src/lightspeed_evaluation/core/models/__init__.py index a5d97336..2b850c53 100644 --- a/src/lightspeed_evaluation/core/models/__init__.py +++ b/src/lightspeed_evaluation/core/models/__init__.py @@ -14,6 +14,9 @@ MetricResult, TurnData, ) +from lightspeed_evaluation.core.models.evaluation_run_context import ( + EvaluationRunContext, +) from lightspeed_evaluation.core.models.mixins import StreamingMetricsMixin from lightspeed_evaluation.core.models.system import ( APIConfig, @@ -37,6 +40,7 @@ "JudgeScore", "MetricResult", "EvaluationResult", + "EvaluationRunContext", "EvaluationScope", # Metric metadata models (GEval config, etc.) "GEvalConfig", diff --git a/src/lightspeed_evaluation/core/models/evaluation_run_context.py b/src/lightspeed_evaluation/core/models/evaluation_run_context.py new file mode 100644 index 00000000..b39c9ee4 --- /dev/null +++ b/src/lightspeed_evaluation/core/models/evaluation_run_context.py @@ -0,0 +1,17 @@ +"""Context passed to optional evaluation completion hooks (e.g. Langfuse).""" + +from dataclasses import dataclass, field +from typing import Any, Optional + + +@dataclass(frozen=True, slots=True) +class EvaluationRunContext: + """Metadata for a single evaluation run. + + Emitted to optional callbacks such as ``on_complete`` in :func:`evaluate` so + integrations (Langfuse, custom dashboards) can label the run. + """ + + run_name: str + original_data_path: Optional[str] = None + metadata: dict[str, Any] = field(default_factory=dict) diff --git a/src/lightspeed_evaluation/integrations/__init__.py b/src/lightspeed_evaluation/integrations/__init__.py new file mode 100644 index 00000000..c4a1ff72 --- /dev/null +++ b/src/lightspeed_evaluation/integrations/__init__.py @@ -0,0 +1,11 @@ +"""Optional integrations (Langfuse, etc.) — all dependencies are opt-in per extra.""" + +from lightspeed_evaluation.integrations.langfuse_reporter import ( + build_langfuse_on_complete_callback, + push_evaluation_results_to_langfuse, +) + +__all__ = [ + "build_langfuse_on_complete_callback", + "push_evaluation_results_to_langfuse", +] diff --git a/src/lightspeed_evaluation/integrations/langfuse_reporter.py b/src/lightspeed_evaluation/integrations/langfuse_reporter.py new file mode 100644 index 00000000..282d3e96 --- /dev/null +++ b/src/lightspeed_evaluation/integrations/langfuse_reporter.py @@ -0,0 +1,235 @@ +"""Optional Langfuse export for evaluation results. + +Install with: ``pip install 'lightspeed-evaluation[langfuse]'`` + +Requires Langfuse v2 (``langfuse>=2,<3``) per :class:`langfuse.Langfuse` API +used here (trace and scores). + +Environment variables (also read by the Langfuse client if arguments are +omitted): ``LANGFUSE_PUBLIC_KEY``, ``LANGFUSE_SECRET_KEY``, ``LANGFUSE_HOST``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging +from collections.abc import Callable +from typing import Any, Optional + +from lightspeed_evaluation.core.models import EvaluationResult, EvaluationRunContext + +logger = logging.getLogger(__name__) + +try: + from langfuse import Langfuse +except ImportError: # pragma: no cover - requires optional extra + Langfuse = None + + +@dataclass(frozen=True, slots=True) +class LangfuseClientConfig: + """Optional explicit client kwargs for ``Langfuse(...)``.""" + + public_key: Optional[str] = None + secret_key: Optional[str] = None + host: Optional[str] = None + + +# --- Public API ----------------------------------------------------------------- + + +def push_evaluation_results_to_langfuse( + results: list[EvaluationResult], + context: EvaluationRunContext, + *, + client_config: Optional[LangfuseClientConfig] = None, + tags: Optional[list[str]] = None, + **deprecated_client_kwargs: Optional[str], +) -> None: + """Create one Langfuse trace for the run and one score per evaluation row. + + Uses numeric ``value`` from each scored result and a ``comment`` with + pass/fail status and a truncated reason. Rows with ``score=None`` are + skipped (common for ERROR/SKIPPED). Client initialization failures are + logged and treated as no-op; write-time SDK errors from + ``trace/score/flush`` are propagated to the caller. + + Args: + results: Output of :func:`lightspeed_evaluation.api.evaluate`. + context: Run context from the ``on_complete`` callback. + client_config: Optional explicit config for Langfuse constructor. + tags: Merged with ``['lightspeed-evaluation']`` on the trace. + **deprecated_client_kwargs: Deprecated keys (``public_key``, + ``secret_key``, ``host``) kept for compatibility. + """ + if not results: + logger.info("langfuse: no results to report; skipping") + return + + if Langfuse is None: + logger.error( + "langfuse is not installed. Add: pip install 'lightspeed-evaluation[langfuse]'" + ) + return + + runtime_cfg = _resolve_runtime_config( + client_config=client_config, + deprecated_client_kwargs=deprecated_client_kwargs, + ) + client = _create_langfuse_client(runtime_cfg) + if client is None: + return + _write_langfuse_trace_and_scores(client, results, context, tags=tags) + + +def _resolve_runtime_config( + *, + client_config: Optional[LangfuseClientConfig], + deprecated_client_kwargs: dict[str, Optional[str]], +) -> LangfuseClientConfig: + """Normalize deprecated convenience kwargs into a single config object.""" + host = deprecated_client_kwargs.get("host") + public_key = deprecated_client_kwargs.get("public_key") + secret_key = deprecated_client_kwargs.get("secret_key") + unsupported = set(deprecated_client_kwargs) - {"host", "public_key", "secret_key"} + if unsupported: + logger.warning("langfuse: ignoring unsupported client kwargs: %s", unsupported) + + if client_config is None: + return LangfuseClientConfig( + host=host, public_key=public_key, secret_key=secret_key + ) + return LangfuseClientConfig( + host=host if host is not None else client_config.host, + public_key=(public_key if public_key is not None else client_config.public_key), + secret_key=(secret_key if secret_key is not None else client_config.secret_key), + ) + + +def _create_langfuse_client( + client_config: LangfuseClientConfig, +) -> Optional[Any]: + """Create a Langfuse client, logging known runtime failures.""" + if Langfuse is None: + return None + + kwargs: dict[str, Any] = {} + if client_config.public_key is not None: + kwargs["public_key"] = client_config.public_key + if client_config.secret_key is not None: + kwargs["secret_key"] = client_config.secret_key + if client_config.host is not None: + kwargs["host"] = client_config.host + + try: + client = Langfuse(**kwargs) + return client + except (RuntimeError, ValueError, OSError, ConnectionError): + logger.exception("langfuse: failed to initialize client") + return None + + +def _write_langfuse_trace_and_scores( + langfuse: Any, + results: list[EvaluationResult], + context: EvaluationRunContext, + *, + tags: Optional[list[str]] = None, +) -> None: + """Create trace, one score per result, flush.""" + trace_meta: dict[str, Any] = { + "run_name": context.run_name, + "result_count": len(results), + } + if context.original_data_path is not None: + trace_meta["original_data_path"] = context.original_data_path + trace_meta.update(context.metadata or {}) + + merged = list(tags) if tags else [] + if "lightspeed-evaluation" not in merged: + merged.insert(0, "lightspeed-evaluation") + + trace = langfuse.trace( + name=_truncate(f"lightspeed_eval__{context.run_name}", 256), + metadata=trace_meta, + tags=merged, + ) + + for i, r in enumerate(results): + if r.score is None: + logger.debug( + "langfuse: skipping score for %s (status=%s, no numeric score)", + r.metric_identifier, + r.result, + ) + continue + name = _score_name(i, r) + value = float(r.score) + comment = _format_comment(r) + trace.score( + name=name, + value=value, + comment=comment, + ) + + langfuse.flush() + + +def build_langfuse_on_complete_callback( + *, + client_config: Optional[LangfuseClientConfig] = None, + public_key: Optional[str] = None, + secret_key: Optional[str] = None, + host: Optional[str] = None, + tags: Optional[list[str]] = None, +) -> Callable[[list[EvaluationResult], EvaluationRunContext], None]: + """Build an ``on_complete`` callback for :func:`lightspeed_evaluation.api.evaluate`. + + Example:: + + from lightspeed_evaluation import evaluate + from lightspeed_evaluation.integrations.langfuse_reporter import ( + build_langfuse_on_complete_callback, + ) + + on_complete = build_langfuse_on_complete_callback() + results = evaluate( + system_config, data, on_complete=on_complete + ) + """ + return lambda res, ctx: push_evaluation_results_to_langfuse( + res, + ctx, + client_config=client_config, + public_key=public_key, + secret_key=secret_key, + host=host, + tags=tags, + ) + + +def _format_comment(r: EvaluationResult) -> str: + parts: list[str] = [f"result={r.result}"] + if r.reason: + max_reason = 1200 + reason = ( + r.reason + if len(r.reason) <= max_reason + else r.reason[: max_reason - 3] + "..." + ) + parts.append(f"reason={reason}") + return " | ".join(parts) + + +def _score_name(idx: int, r: EvaluationResult) -> str: + # Langfuse score names: keep stable and unique; avoid colons in name if the + # UI is picky — replace with underscores for identifiers. + raw = f"{idx:04d}__{r.conversation_group_id}__{r.metric_identifier}__{r.tag}" + safe = raw.replace(":", "_").replace("/", "_").replace(" ", "_") + return _truncate(safe, 200) + + +def _truncate(s: str, max_len: int) -> str: + if len(s) <= max_len: + return s + return s[: max_len - 3] + "..." diff --git a/src/lightspeed_evaluation/pipeline/evaluation/pipeline.py b/src/lightspeed_evaluation/pipeline/evaluation/pipeline.py index b04611e1..f51531df 100644 --- a/src/lightspeed_evaluation/pipeline/evaluation/pipeline.py +++ b/src/lightspeed_evaluation/pipeline/evaluation/pipeline.py @@ -3,6 +3,7 @@ import asyncio import concurrent.futures import logging +from collections.abc import Callable from typing import Optional import litellm @@ -14,6 +15,7 @@ from lightspeed_evaluation.core.models import ( EvaluationData, EvaluationResult, + EvaluationRunContext, SystemConfig, ) from lightspeed_evaluation.core.output.data_persistence import save_evaluation_data @@ -127,12 +129,20 @@ def run_evaluation( self, evaluation_data: list[EvaluationData], original_data_path: Optional[str] = None, + *, + on_complete: Optional[ + Callable[[list[EvaluationResult], EvaluationRunContext], None] + ] = None, ) -> list[EvaluationResult]: """Run evaluation on provided data. Args: evaluation_data: List of conversation data to evaluate original_data_path: Path to original data file for saving updates + on_complete: Optional callback invoked with raw results and run + context after a successful run (and after amended-data save + when API is enabled). Exceptions in the callback are logged + and do not fail the evaluation. Returns: List of evaluation results. @@ -161,6 +171,19 @@ def run_evaluation( self._save_amended_data(evaluation_data) logger.info("Evaluation complete: %d results generated", len(results)) + + if on_complete is not None: + run_ctx = EvaluationRunContext( + run_name=run_name, + original_data_path=original_data_path, + ) + try: + on_complete(results, run_ctx) + except Exception: # pylint: disable=broad-exception-caught + logger.exception( + "on_complete callback raised; evaluation results are still valid" + ) + return results def _process_eval_data( diff --git a/src/lightspeed_evaluation/runner/evaluation.py b/src/lightspeed_evaluation/runner/evaluation.py index 9e9b9783..d705c864 100644 --- a/src/lightspeed_evaluation/runner/evaluation.py +++ b/src/lightspeed_evaluation/runner/evaluation.py @@ -1,6 +1,7 @@ """LightSpeed Evaluation Framework - Main Evaluation Runner.""" import argparse +import os import shutil import sys import traceback @@ -160,8 +161,28 @@ def run_evaluation( # pylint: disable=too-many-locals print("\nāš™ļø Initializing Evaluation Pipeline...") print("\nšŸ”„ Running Evaluation...") + on_complete = None + use_langfuse = bool(eval_args.langfuse) or os.environ.get( + "LIGHTSPEED_USE_LANGFUSE", "" + ).lower() in ( + "1", + "true", + "yes", + ) + if use_langfuse: + # Optional extra: pip install 'lightspeed-evaluation[langfuse]' + from lightspeed_evaluation.integrations.langfuse_reporter import ( # pylint: disable=import-outside-toplevel + build_langfuse_on_complete_callback, + ) + + on_complete = build_langfuse_on_complete_callback() + results = evaluate( - system_config, evaluation_data, output_dir=eval_args.output_dir + system_config, + evaluation_data, + output_dir=eval_args.output_dir, + evaluation_data_path=eval_args.eval_data, + on_complete=on_complete, ) file_entries = [ @@ -254,6 +275,15 @@ def main() -> int: action="store_true", help="Enable cache warmup mode - rebuild caches without reading existing entries", ) + parser.add_argument( + "--langfuse", + action="store_true", + help=( + "After the run, send scores to Langfuse (requires the " + "'langfuse' extra and LANGFUSE_* credentials). Or set " + "LIGHTSPEED_USE_LANGFUSE=1." + ), + ) eval_args = parser.parse_args() diff --git a/tests/unit/integrations/test_langfuse_reporter.py b/tests/unit/integrations/test_langfuse_reporter.py new file mode 100644 index 00000000..34eae494 --- /dev/null +++ b/tests/unit/integrations/test_langfuse_reporter.py @@ -0,0 +1,107 @@ +"""Tests for optional Langfuse reporter (mocked SDK).""" + +from __future__ import annotations + +from typing import Any + +import pytest +from pytest_mock import MockerFixture + +from lightspeed_evaluation.core.models import EvaluationResult, EvaluationRunContext +from lightspeed_evaluation.integrations import langfuse_reporter +from lightspeed_evaluation.integrations.langfuse_reporter import ( + _format_comment, + _score_name, + build_langfuse_on_complete_callback, + push_evaluation_results_to_langfuse, +) + + +def _one_result() -> EvaluationResult: + return EvaluationResult( + conversation_group_id="g1", + turn_id="t1", + tag="eval", + metric_identifier="custom:x", + score=0.9, + result="PASS", + reason="ok", + ) + + +def _install_fake_langfuse(mocker: MockerFixture) -> tuple[Any, Any]: + """Patch reporter module to use a fake ``Langfuse`` client.""" + mock_lf = mocker.Mock() + mock_trace = mocker.Mock() + mock_lf.trace.return_value = mock_trace + + mocker.patch.object(langfuse_reporter, "Langfuse", lambda **kwargs: mock_lf) + return mock_lf, mock_trace + + +def test_build_callback_invokes_push( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture +) -> None: + """Build callback and ensure push path is exercised (mocked Langfuse).""" + mock_lf, mock_trace = _install_fake_langfuse(mocker) + + cb = build_langfuse_on_complete_callback() + results = [_one_result()] + ctx = EvaluationRunContext(run_name="r1", original_data_path="/a.yaml") + caplog.set_level("ERROR") + cb(results, ctx) + + mock_lf.trace.assert_called_once() + assert mock_trace.score.call_count == 1 + mock_lf.flush.assert_called_once() + + +def test_push_with_mock_langfuse( + mocker: MockerFixture, caplog: pytest.LogCaptureFixture +) -> None: + """Direct push with mocked client.""" + mock_lf, _ = _install_fake_langfuse(mocker) + + caplog.set_level("ERROR") + push_evaluation_results_to_langfuse( + [_one_result()], EvaluationRunContext(run_name="n") + ) + assert mock_lf.flush.called + + +def test_push_skips_none_scores(mocker: MockerFixture) -> None: + """Rows without numeric score are skipped (no fake 0.0 in Langfuse).""" + mock_lf, mock_trace = _install_fake_langfuse(mocker) + scored = _one_result() + unscored = _one_result().model_copy(update={"score": None, "result": "SKIPPED"}) + + push_evaluation_results_to_langfuse( + [scored, unscored], EvaluationRunContext(run_name="n") + ) + + assert mock_trace.score.call_count == 1 + kwargs = mock_trace.score.call_args.kwargs + assert kwargs["value"] == 0.9 + assert mock_lf.flush.called + + +def test_push_no_results() -> None: + """No Langfuse client when there are no rows.""" + push_evaluation_results_to_langfuse([], EvaluationRunContext(run_name="n")) + + +def test_format_comment() -> None: + """Format comment helper.""" + r = _one_result() + c = _format_comment(r) + assert "result=PASS" in c + assert "ok" in c + + +def test_score_name_sanitizes() -> None: + """Score names replace problematic characters.""" + r = _one_result() + r = r.model_copy(update={"metric_identifier": "a:b", "tag": "x y"}) + name = _score_name(0, r) + assert "a_b" in name + assert len(name) <= 200 diff --git a/tests/unit/pipeline/evaluation/test_pipeline.py b/tests/unit/pipeline/evaluation/test_pipeline.py index 5e59f4c2..86438303 100644 --- a/tests/unit/pipeline/evaluation/test_pipeline.py +++ b/tests/unit/pipeline/evaluation/test_pipeline.py @@ -6,6 +6,7 @@ from lightspeed_evaluation.core.models import ( EvaluationData, EvaluationResult, + EvaluationRunContext, ) from lightspeed_evaluation.core.system.loader import ConfigLoader from lightspeed_evaluation.pipeline.evaluation.pipeline import EvaluationPipeline @@ -159,6 +160,105 @@ def test_run_evaluation_success( assert len(results) == 1 assert results[0].result == "PASS" + def test_on_complete_receives_results_and_context( + self, + mock_config_loader: ConfigLoader, + sample_evaluation_data: list[EvaluationData], + mocker: MockerFixture, + ) -> None: + """Test optional on_complete is invoked with results and EvaluationRunContext.""" + mocker.patch("lightspeed_evaluation.pipeline.evaluation.pipeline.MetricManager") + mocker.patch( + "lightspeed_evaluation.pipeline.evaluation.pipeline.APIDataAmender" + ) + mocker.patch( + "lightspeed_evaluation.pipeline.evaluation.pipeline.EvaluationErrorHandler" + ) + mocker.patch( + "lightspeed_evaluation.pipeline.evaluation.pipeline.ScriptExecutionManager" + ) + mocker.patch( + "lightspeed_evaluation.pipeline.evaluation.pipeline.MetricsEvaluator" + ) + + mock_result = EvaluationResult( + conversation_group_id="conv1", + turn_id="turn1", + metric_identifier="ragas:faithfulness", + score=0.85, + result="PASS", + threshold=0.7, + reason="Good", + ) + mock_processor = mocker.Mock() + mock_processor.process_conversation.return_value = [mock_result] + mocker.patch( + "lightspeed_evaluation.pipeline.evaluation.pipeline.ConversationProcessor", + return_value=mock_processor, + ) + + received: list[tuple[list[EvaluationResult], EvaluationRunContext]] = [] + + def on_complete(res: list[EvaluationResult], ctx: EvaluationRunContext) -> None: + received.append((res, ctx)) + + pipeline = EvaluationPipeline(mock_config_loader) + path = "/tmp/eval_data.yaml" + results = pipeline.run_evaluation( + sample_evaluation_data, path, on_complete=on_complete + ) + + assert results[0] is mock_result + assert len(received) == 1 + assert received[0][0] == results + assert received[0][1].run_name == path + assert received[0][1].original_data_path == path + + def test_on_complete_error_does_not_prevent_result_return( + self, + mock_config_loader: ConfigLoader, + sample_evaluation_data: list[EvaluationData], + mocker: MockerFixture, + ) -> None: + """If the optional callback raises, the pipeline still returns results.""" + mocker.patch("lightspeed_evaluation.pipeline.evaluation.pipeline.MetricManager") + mocker.patch( + "lightspeed_evaluation.pipeline.evaluation.pipeline.APIDataAmender" + ) + mocker.patch( + "lightspeed_evaluation.pipeline.evaluation.pipeline.EvaluationErrorHandler" + ) + mocker.patch( + "lightspeed_evaluation.pipeline.evaluation.pipeline.ScriptExecutionManager" + ) + mocker.patch( + "lightspeed_evaluation.pipeline.evaluation.pipeline.MetricsEvaluator" + ) + mock_result = EvaluationResult( + conversation_group_id="conv1", + turn_id="t1", + metric_identifier="m1", + score=1.0, + result="PASS", + reason="x", + ) + mock_processor = mocker.Mock() + mock_processor.process_conversation.return_value = [mock_result] + mocker.patch( + "lightspeed_evaluation.pipeline.evaluation.pipeline.ConversationProcessor", + return_value=mock_processor, + ) + + def on_complete( + _res: list[EvaluationResult], _ctx: EvaluationRunContext + ) -> None: + raise RuntimeError("integration failed") + + pipeline = EvaluationPipeline(mock_config_loader) + out = pipeline.run_evaluation(sample_evaluation_data, on_complete=on_complete) + assert len(out) == 1 + assert out[0] is mock_result + def test_run_evaluation_saves_amended_data_when_api_enabled( self, mock_config_loader: ConfigLoader, diff --git a/tests/unit/runner/test_evaluation.py b/tests/unit/runner/test_evaluation.py index 402f06f0..0160ee5f 100644 --- a/tests/unit/runner/test_evaluation.py +++ b/tests/unit/runner/test_evaluation.py @@ -58,6 +58,7 @@ def _make_eval_args(**kwargs: Any) -> argparse.Namespace: "tags": None, "conv_ids": None, "cache_warmup": False, + "langfuse": False, } defaults.update(kwargs) return argparse.Namespace(**defaults) @@ -167,7 +168,11 @@ def test_run_evaluation_success( assert result["TOTAL"] == 1 assert result["PASS"] == 1 mock_evaluate.assert_called_once_with( - mock_config, mock_eval_data, output_dir=None + mock_config, + mock_eval_data, + output_dir=None, + evaluation_data_path="config/evaluation_data.yaml", + on_complete=None, ) def test_run_evaluation_with_output_dir_override( @@ -224,7 +229,11 @@ def test_run_evaluation_with_output_dir_override( # Verify custom output dir was passed to evaluate() mock_evaluate.assert_called_once_with( - mock_config, mock_eval_data, output_dir="/custom/output" + mock_config, + mock_eval_data, + output_dir="/custom/output", + evaluation_data_path="config/evaluation_data.yaml", + on_complete=None, ) def test_run_evaluation_file_not_found( diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index df2026f5..91117cde 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -44,7 +44,9 @@ def test_evaluate_success(self, mocker: MockerFixture) -> None: results = evaluate(config, data) assert results == mock_results - mock_pipeline.run_evaluation.assert_called_once_with(data) + mock_pipeline.run_evaluation.assert_called_once_with( + data, original_data_path=None, on_complete=None + ) mock_pipeline.close.assert_called_once() def test_evaluate_empty_data(self) -> None: @@ -111,7 +113,13 @@ def test_delegates_to_evaluate_with_list(self, mocker: MockerFixture) -> None: results = evaluate_conversation(config, data, output_dir="/output") - mock_evaluate.assert_called_once_with(config, [data], output_dir="/output") + mock_evaluate.assert_called_once_with( + config, + [data], + output_dir="/output", + evaluation_data_path=None, + on_complete=None, + ) assert results == mock_evaluate.return_value def test_delegates_without_output_dir(self, mocker: MockerFixture) -> None: @@ -124,7 +132,13 @@ def test_delegates_without_output_dir(self, mocker: MockerFixture) -> None: evaluate_conversation(config, data) - mock_evaluate.assert_called_once_with(config, [data], output_dir=None) + mock_evaluate.assert_called_once_with( + config, + [data], + output_dir=None, + evaluation_data_path=None, + on_complete=None, + ) class TestEvaluateTurn: @@ -281,6 +295,8 @@ def test_conversation_with_summary_delegates(self, mocker: MockerFixture) -> Non [data], output_dir="/out", compute_confidence_intervals=False, + evaluation_data_path=None, + on_complete=None, ) assert result == mock_eval.return_value diff --git a/uv.lock b/uv.lock index f49ea872..0ef43616 100644 --- a/uv.lock +++ b/uv.lock @@ -1485,6 +1485,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/66/d9e0c3b83b0ad75ee746c51ba347cacecb8d656b96e1d513f3e334d1ccab/langchain_text_splitters-1.1.1-py3-none-any.whl", hash = "sha256:5ed0d7bf314ba925041e7d7d17cd8b10f688300d5415fb26c29442f061e329dc", size = 35734, upload-time = "2026-02-18T23:02:41.913Z" }, ] +[[package]] +name = "langfuse" +version = "2.60.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "backoff" }, + { name = "httpx" }, + { name = "idna" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/45/77fdf53c9e9f49bb78f72eba3f992f2f3d8343e05976aabfe1fca276a640/langfuse-2.60.10.tar.gz", hash = "sha256:a26d0d927a28ee01b2d12bb5b862590b643cc4e60a28de6e2b0c2cfff5dbfc6a", size = 152648, upload-time = "2025-09-16T15:08:12.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/69/08584fbd69e14398d3932a77d0c8d7e20389da3e6470210d6719afba2801/langfuse-2.60.10-py3-none-any.whl", hash = "sha256:815c6369194aa5b2a24f88eb9952f7c3fc863272c41e90642a71f3bc76f4a11f", size = 275568, upload-time = "2025-09-16T15:08:10.166Z" }, +] + [[package]] name = "langgraph" version = "1.1.6" @@ -1588,6 +1607,9 @@ dependencies = [ ] [package.optional-dependencies] +langfuse = [ + { name = "langfuse" }, +] local-embeddings = [ { name = "sentence-transformers" }, { name = "torch", version = "2.10.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, @@ -1603,6 +1625,7 @@ nlp-metrics = [ dev = [ { name = "bandit" }, { name = "black" }, + { name = "langfuse" }, { name = "mypy" }, { name = "pydocstyle" }, { name = "pylint-pydantic" }, @@ -1623,6 +1646,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.0,<=0.28.1" }, { name = "langchain", extras = ["huggingface"], specifier = ">=0.3.30,<=1.2.12" }, { name = "langchain-google-genai", specifier = ">=2.0.0,<=2.1.12" }, + { name = "langfuse", marker = "extra == 'langfuse'", specifier = ">=2.0.0,<3.0.0" }, { name = "litellm", specifier = ">=1.80.0,<=1.82.4" }, { name = "matplotlib", specifier = ">=3.5.0,<=3.10.6" }, { name = "numpy", specifier = ">=1.23.0,<=2.3.2" }, @@ -1641,12 +1665,13 @@ requires-dist = [ { name = "torch", marker = "extra == 'local-embeddings'", specifier = ">=2.5.0,<=2.10.0", index = "https://download.pytorch.org/whl/cpu" }, { name = "tqdm", specifier = "==4.67.1" }, ] -provides-extras = ["local-embeddings", "nlp-metrics"] +provides-extras = ["local-embeddings", "nlp-metrics", "langfuse"] [package.metadata.requires-dev] dev = [ { name = "bandit", specifier = ">=1.7.0,<=1.9.2" }, { name = "black", specifier = "==25.1.0" }, + { name = "langfuse", specifier = ">=2.0.0,<3.0.0" }, { name = "mypy", specifier = ">=1.15.0,<=1.17.1" }, { name = "pydocstyle", specifier = "==6.3.0" }, { name = "pylint-pydantic", specifier = ">=0.3.0,<=0.3.5" }, @@ -2288,11 +2313,11 @@ wheels = [ [[package]] name = "packaging" -version = "26.0" +version = "24.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950, upload-time = "2024-11-08T09:47:47.202Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" }, ] [[package]] @@ -3782,16 +3807,16 @@ dependencies = [ { name = "typing-extensions", marker = "sys_platform == 'darwin'" }, ] wheels = [ - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-1-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:0826ac8e409551e12b2360ac18b4161a838cbd111933e694752f351191331d09" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-1-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:7fbbf409143a4fe0812a40c0b46a436030a7e1d14fe8c5234dfbe44df47f617e" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-1-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:b39cafff7229699f9d6e172cac74d85fd71b568268e439e08d9c540e54732a3e" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-2-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:90821a3194b8806d9fa9fdaa9308c1bc73df0c26808274b14129a97c99f35794" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-2-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:358bd7125cbec6e692d60618a5eec7f55a51b29e3652a849fd42af021d818023" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-2-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:470de4176007c2700735e003a830828a88d27129032a3add07291da07e2a94e8" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:4584ab167995c0479f6821e3dceaf199c8166c811d3adbba5d8eedbbfa6764fd" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:45a1c5057629444aeb1c452c18298fa7f30f2f7aeadd4dc41f9d340980294407" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:339e05502b6c839db40e88720cb700f5a3b50cda332284873e851772d41b2c1e" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:840351da59cedb7bcbc51981880050813c19ef6b898a7fecf73a3afc71aff3fe" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-1-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:0826ac8e409551e12b2360ac18b4161a838cbd111933e694752f351191331d09", upload-time = "2026-02-06T16:27:14Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-1-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:7fbbf409143a4fe0812a40c0b46a436030a7e1d14fe8c5234dfbe44df47f617e", upload-time = "2026-02-06T16:27:14Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-1-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:b39cafff7229699f9d6e172cac74d85fd71b568268e439e08d9c540e54732a3e", upload-time = "2026-02-06T16:27:17Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-2-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:90821a3194b8806d9fa9fdaa9308c1bc73df0c26808274b14129a97c99f35794", upload-time = "2026-02-10T19:55:42Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-2-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:358bd7125cbec6e692d60618a5eec7f55a51b29e3652a849fd42af021d818023", upload-time = "2026-02-10T19:55:42Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-2-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:470de4176007c2700735e003a830828a88d27129032a3add07291da07e2a94e8", upload-time = "2026-02-10T19:55:43Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:4584ab167995c0479f6821e3dceaf199c8166c811d3adbba5d8eedbbfa6764fd", upload-time = "2026-01-23T15:09:55Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:45a1c5057629444aeb1c452c18298fa7f30f2f7aeadd4dc41f9d340980294407", upload-time = "2026-01-23T15:09:55Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:339e05502b6c839db40e88720cb700f5a3b50cda332284873e851772d41b2c1e", upload-time = "2026-01-23T15:09:57Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:840351da59cedb7bcbc51981880050813c19ef6b898a7fecf73a3afc71aff3fe", upload-time = "2026-01-23T15:09:59Z" }, ] [[package]] @@ -3813,29 +3838,29 @@ dependencies = [ { name = "typing-extensions", marker = "sys_platform != 'darwin'" }, ] wheels = [ - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp311-cp311-linux_aarch64.whl", hash = "sha256:ce5c113d1f55f8c1f5af05047a24e50d11d293e0cbbb5bf7a75c6c761edd6eaa" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp311-cp311-linux_s390x.whl", hash = "sha256:0e286fcf6ce0cc7b204396c9b4ea0d375f1f0c3e752f68ce3d3aeb265511db8c" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:1cfcb9b1558c6e52dffd0d4effce83b13c5ae5d97338164c372048c21f9cfccb" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:b7cb1ec66cefb90fd7b676eac72cfda3b8d4e4d0cacd7a531963bc2e0a9710ab" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp311-cp311-win_amd64.whl", hash = "sha256:17a09465bab2aab8f0f273410297133d8d8fb6dd84dccbd252ca4a4f3a111847" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp311-cp311-win_arm64.whl", hash = "sha256:c35c0de592941d4944698dbfa87271ab85d3370eca3b694943a2ab307ac34b3f" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-linux_aarch64.whl", hash = "sha256:8de5a36371b775e2d4881ed12cc7f2de400b1ad3d728aa74a281f649f87c9b8c" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-linux_s390x.whl", hash = "sha256:9accc30b56cb6756d4a9d04fcb8ebc0bb68c7d55c1ed31a8657397d316d31596" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:179451716487f8cb09b56459667fa1f5c4c0946c1e75fbeae77cfc40a5768d87" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ee40b8a4b4b2cf0670c6fd4f35a7ef23871af956fecb238fbf5da15a72650b1d" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:21cb5436978ef47c823b7a813ff0f8c2892e266cfe0f1d944879b5fba81bf4e1" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-win_arm64.whl", hash = "sha256:3eaa727e6a73affa61564d86b9d03191df45c8650d0666bd3d57c8597ef61e78" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-linux_aarch64.whl", hash = "sha256:fd215f3d0f681905c5b56b0630a3d666900a37fcc3ca5b937f95275c66f9fd9c" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-linux_s390x.whl", hash = "sha256:170a0623108055be5199370335cf9b41ba6875b3cb6f086db4aee583331a4899" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e51994492cdb76edce29da88de3672a3022f9ef0ffd90345436948d4992be2c7" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:8d316e5bf121f1eab1147e49ad0511a9d92e4c45cc357d1ab0bee440da71a095" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:b719da5af01b59126ac13eefd6ba3dd12d002dc0e8e79b8b365e55267a8189d3" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-win_arm64.whl", hash = "sha256:b67d91326e4ed9eccbd6b7d84ed7ffa43f93103aa3f0b24145f3001f3b11b714" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-linux_aarch64.whl", hash = "sha256:5af75e5f49de21b0bdf7672bc27139bd285f9e8dbcabe2d617a2eb656514ac36" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-linux_s390x.whl", hash = "sha256:ba51ef01a510baf8fff576174f702c47e1aa54389a9f1fba323bb1a5003ff0bf" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0fedcb1a77e8f2aaf7bfd21591bf6d1e0b207473268c9be16b17cb7783253969" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:106dd1930cb30a4a337366ba3f9b25318ebf940f51fd46f789281dd9e736bdc4" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-win_amd64.whl", hash = "sha256:eb1bde1ce198f05c8770017de27e001d404499cf552aaaa014569eff56ca25c0" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp311-cp311-linux_aarch64.whl", hash = "sha256:ce5c113d1f55f8c1f5af05047a24e50d11d293e0cbbb5bf7a75c6c761edd6eaa", upload-time = "2026-01-23T15:10:11Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp311-cp311-linux_s390x.whl", hash = "sha256:0e286fcf6ce0cc7b204396c9b4ea0d375f1f0c3e752f68ce3d3aeb265511db8c", upload-time = "2026-01-23T15:10:12Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:1cfcb9b1558c6e52dffd0d4effce83b13c5ae5d97338164c372048c21f9cfccb", upload-time = "2026-01-23T15:10:15Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:b7cb1ec66cefb90fd7b676eac72cfda3b8d4e4d0cacd7a531963bc2e0a9710ab", upload-time = "2026-01-23T15:10:15Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp311-cp311-win_amd64.whl", hash = "sha256:17a09465bab2aab8f0f273410297133d8d8fb6dd84dccbd252ca4a4f3a111847", upload-time = "2026-01-23T15:10:19Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp311-cp311-win_arm64.whl", hash = "sha256:c35c0de592941d4944698dbfa87271ab85d3370eca3b694943a2ab307ac34b3f", upload-time = "2026-01-23T15:10:20Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-linux_aarch64.whl", hash = "sha256:8de5a36371b775e2d4881ed12cc7f2de400b1ad3d728aa74a281f649f87c9b8c", upload-time = "2026-01-23T15:10:22Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-linux_s390x.whl", hash = "sha256:9accc30b56cb6756d4a9d04fcb8ebc0bb68c7d55c1ed31a8657397d316d31596", upload-time = "2026-01-23T15:10:24Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:179451716487f8cb09b56459667fa1f5c4c0946c1e75fbeae77cfc40a5768d87", upload-time = "2026-01-23T15:10:25Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ee40b8a4b4b2cf0670c6fd4f35a7ef23871af956fecb238fbf5da15a72650b1d", upload-time = "2026-01-23T15:10:27Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:21cb5436978ef47c823b7a813ff0f8c2892e266cfe0f1d944879b5fba81bf4e1", upload-time = "2026-01-23T15:10:30Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp312-cp312-win_arm64.whl", hash = "sha256:3eaa727e6a73affa61564d86b9d03191df45c8650d0666bd3d57c8597ef61e78", upload-time = "2026-01-23T15:10:31Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-linux_aarch64.whl", hash = "sha256:fd215f3d0f681905c5b56b0630a3d666900a37fcc3ca5b937f95275c66f9fd9c", upload-time = "2026-01-23T15:10:34Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-linux_s390x.whl", hash = "sha256:170a0623108055be5199370335cf9b41ba6875b3cb6f086db4aee583331a4899", upload-time = "2026-01-23T15:10:35Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e51994492cdb76edce29da88de3672a3022f9ef0ffd90345436948d4992be2c7", upload-time = "2026-01-23T15:10:37Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:8d316e5bf121f1eab1147e49ad0511a9d92e4c45cc357d1ab0bee440da71a095", upload-time = "2026-01-23T15:10:38Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:b719da5af01b59126ac13eefd6ba3dd12d002dc0e8e79b8b365e55267a8189d3", upload-time = "2026-01-23T15:10:41Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313-win_arm64.whl", hash = "sha256:b67d91326e4ed9eccbd6b7d84ed7ffa43f93103aa3f0b24145f3001f3b11b714", upload-time = "2026-01-23T15:10:42Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-linux_aarch64.whl", hash = "sha256:5af75e5f49de21b0bdf7672bc27139bd285f9e8dbcabe2d617a2eb656514ac36", upload-time = "2026-01-23T15:10:44Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-linux_s390x.whl", hash = "sha256:ba51ef01a510baf8fff576174f702c47e1aa54389a9f1fba323bb1a5003ff0bf", upload-time = "2026-01-23T15:10:48Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0fedcb1a77e8f2aaf7bfd21591bf6d1e0b207473268c9be16b17cb7783253969", upload-time = "2026-01-23T15:10:48Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:106dd1930cb30a4a337366ba3f9b25318ebf940f51fd46f789281dd9e736bdc4", upload-time = "2026-01-23T15:10:50Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.10.0%2Bcpu-cp313-cp313t-win_amd64.whl", hash = "sha256:eb1bde1ce198f05c8770017de27e001d404499cf552aaaa014569eff56ca25c0", upload-time = "2026-01-23T15:10:50Z" }, ] [[package]] @@ -3978,6 +4003,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/22/b76d483683216dde3d67cba61fb2444be8d5be289bf628c13fc0fd90e5f9/wheel-0.46.3-py3-none-any.whl", hash = "sha256:4b399d56c9d9338230118d705d9737a2a468ccca63d5e813e2a4fc7815d8bc4d", size = 30557, upload-time = "2026-01-22T12:39:48.099Z" }, ] +[[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/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/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.6.0"